mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
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:
parent
5fa03c6f3e
commit
e4b21efa56
3 changed files with 164 additions and 0 deletions
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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: '@',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue