test(desktop): cover the composer chip plaintext-demotion bug class

Backspace path-ascend keeping the leading command pill, folder picks
alongside a command pill, commits spanning Chromium-split text nodes,
slash-pill hydration boundaries (committed vs half-typed vs arg-taking),
and replaceBeforeCaret refusing across chip boundaries.
This commit is contained in:
Brooklyn Nicholson 2026-07-29 19:01:12 -05:00
parent 5fa03c6f3e
commit e4b21efa56
3 changed files with 164 additions and 0 deletions

View file

@ -223,3 +223,72 @@ describe('useComposerTrigger — free-text slash arguments', () => {
expect(editor.querySelector('[data-slash-kind]')?.getAttribute('data-ref-text')).toBe('/personality creative')
})
})
describe('useComposerTrigger — chip survival (the plaintext-demotion bug class)', () => {
it('keeps a leading command pill through a Backspace path-ascend', () => {
// The reported repro: `/work @folder…` then Backspace — both chips went
// plaintext because ascend re-rendered the whole editor from text.
const editor = mountEditor('/work @Desktop/')
const { hook } = mountTrigger(editor, [])
expect(editor.querySelector('[data-slash-kind]')).not.toBeNull()
act(() => hook.result.current.refreshTrigger())
expect(hook.result.current.trigger).toMatchObject({ kind: '@', query: 'Desktop/' })
let ran = false
act(() => {
ran = hook.result.current.ascendTriggerPath()
})
expect(ran).toBe(true)
expect(composerPlainText(editor)).toBe('/work @')
expect(editor.querySelector('[data-slash-kind]')).not.toBeNull()
})
it('keeps a leading command pill when a folder pick commits its ref chip', () => {
const editor = mountEditor('/work @Desk')
const folder: Unstable_TriggerItem = {
id: 'folder:Desktop',
type: 'folder',
label: 'Desktop',
metadata: { rawText: '@folder:Desktop', insertId: 'Desktop' }
}
const { hook } = mountTrigger(editor, [folder])
act(() => hook.result.current.refreshTrigger())
act(() => hook.result.current.replaceTriggerWithChip(folder))
expect(composerPlainText(editor)).toBe('/work @folder:`Desktop` ')
expect(editor.querySelector('[data-slash-kind]')).not.toBeNull()
expect(editor.querySelector('[data-ref-kind="folder"]')).not.toBeNull()
})
it('commits in place when Chromium has split the token across text nodes', () => {
// Chromium fragments text nodes around contenteditable=false chips; the
// commit path must span the fragments instead of bailing to a full
// re-render.
const editor = document.createElement('div')
editor.dataset.slot = RICH_INPUT_SLOT
editor.contentEditable = 'true'
document.body.append(editor)
editor.append(document.createTextNode('please run /c'), document.createTextNode('le'))
const caret = document.createRange()
caret.setStart(editor.lastChild!, 2)
caret.collapse(true)
const selection = window.getSelection()!
selection.removeAllRanges()
selection.addRange(caret)
const { hook } = mountTrigger(editor, [item('/clean')])
act(() => hook.result.current.refreshTrigger())
act(() => hook.result.current.replaceTriggerWithChip(item('/clean')))
expect(composerPlainText(editor)).toBe('please run /clean ')
expect(editor.querySelector('[data-slash-kind]')).not.toBeNull()
})
})

View file

@ -35,6 +35,89 @@ describe('renderComposerContents', () => {
expect(editor.textContent).toContain('<b>raw</b>')
expect(composerPlainText(editor)).toBe('@file:`<img src=x onerror=alert(1)>` <b>raw</b>')
})
it('hydrates a committed leading slash command back to its pill', () => {
// Text-hydration parity with @ refs: a re-render from serialized text
// (draft restore, undo, the trigger commit fallback) must not demote a
// committed no-arg command chip to plain text.
const editor = document.createElement('div')
editor.dataset.slot = RICH_INPUT_SLOT
renderComposerContents(editor, '/some-skill @folder:`Desktop` ')
const pill = editor.querySelector('[data-slash-kind]')
expect(pill?.getAttribute('data-ref-text')).toBe('/some-skill')
expect(editor.querySelector('[data-ref-kind="folder"]')).not.toBeNull()
expect(composerPlainText(editor)).toBe('/some-skill @folder:`Desktop` ')
})
it('keeps a still-typed leading slash token as editable text', () => {
const editor = document.createElement('div')
editor.dataset.slot = RICH_INPUT_SLOT
// No trailing whitespace — not committed yet.
renderComposerContents(editor, '/some-skil')
expect(editor.querySelector('[data-slash-kind]')).toBeNull()
expect(composerPlainText(editor)).toBe('/some-skil')
})
it('keeps an arg-taking command as text — its tail may be uncommitted prose', () => {
const editor = document.createElement('div')
editor.dataset.slot = RICH_INPUT_SLOT
renderComposerContents(editor, '/goal ship the redesign')
expect(editor.querySelector('[data-slash-kind]')).toBeNull()
expect(composerPlainText(editor)).toBe('/goal ship the redesign')
})
})
describe('replaceBeforeCaret across split text nodes', () => {
it('replaces a token that Chromium fragmented into multiple text nodes', () => {
const editor = document.createElement('div')
editor.dataset.slot = RICH_INPUT_SLOT
editor.contentEditable = 'true'
document.body.append(editor)
editor.append(document.createTextNode('see @Desk'), document.createTextNode('top/'))
const caret = document.createRange()
caret.setStart(editor.lastChild!, 4)
caret.collapse(true)
const selection = window.getSelection()!
selection.removeAllRanges()
selection.addRange(caret)
const fragment = document.createDocumentFragment()
fragment.append(refChipElement('folder', '`Desktop`'), document.createTextNode(' '))
// Token `@Desktop/` (9 chars) spans both text nodes.
expect(replaceBeforeCaret(editor, 9, fragment)).toBe(true)
expect(composerPlainText(editor)).toBe('see @folder:`Desktop` ')
editor.remove()
})
it('refuses when a chip interrupts the span — the token is not contiguous text', () => {
const editor = document.createElement('div')
editor.dataset.slot = RICH_INPUT_SLOT
editor.contentEditable = 'true'
document.body.append(editor)
editor.append(document.createTextNode('a'), refChipElement('file', '`x`'), document.createTextNode('bc'))
const caret = document.createRange()
caret.setStart(editor.lastChild!, 2)
caret.collapse(true)
const selection = window.getSelection()!
selection.removeAllRanges()
selection.addRange(caret)
expect(replaceBeforeCaret(editor, 5, document.createDocumentFragment())).toBe(false)
expect(composerPlainText(editor)).toBe('a@file:`x`bc')
editor.remove()
})
})
describe('normalizeComposerEditorDom', () => {

View file

@ -65,6 +65,18 @@ describe('detectTrigger', () => {
})
})
it('treats a chip edge as a token boundary, like whitespace', () => {
// U+FFFC is textBeforeCaret's placeholder for a committed pill. Upstream
// assistant-ui's Lexical DirectivePlugin gets the same semantics from node
// boundaries: typing a trigger right after a chip (no space) still opens
// the popover, and a chip inside a token ends it.
expect(detectTrigger('\uFFFC@Desk')).toEqual({ kind: '@', query: 'Desk', tokenLength: 5 })
// Not position 0, so it's an inline reference — not a command invocation.
expect(detectTrigger('\uFFFC/cle')).toEqual({ inline: true, kind: '/', query: 'cle', tokenLength: 4 })
// The placeholder itself never leaks into a query.
expect(detectTrigger('@a\uFFFCb')).toBeNull()
})
it('keeps the at-mention live for a typed ref kind with a path', () => {
expect(detectTrigger('@file:src/main.tsx')).toEqual({
kind: '@',