070959e133
landing-page-staging / Deploy landing page to staging (push) Has been skipped
landing-page-ci / Validate landing page (push) Failing after 4s
visual-baseline / Capture visual baselines (push) Has been cancelled
bake-plugin-previews / Bake plugin previews (push) Has been cancelled
134 lines
5.1 KiB
TypeScript
134 lines
5.1 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import { createEditor, $getRoot } from 'lexical';
|
|
|
|
import { MentionNode } from '../../../src/components/composer/MentionNode';
|
|
import { serializeComposer } from '../../../src/components/composer/serialize';
|
|
import { setComposerFromText } from '../../../src/components/composer/deserialize';
|
|
import type { InlineMentionEntity } from '../../../src/utils/inlineMentions';
|
|
|
|
// Headless Lexical editor — no DOM required. `createEditor` runs purely in
|
|
// memory, so these specs stay in the default `node` test environment.
|
|
function makeEditor() {
|
|
return createEditor({
|
|
namespace: 'serialize-test',
|
|
nodes: [MentionNode],
|
|
onError(err) {
|
|
throw err;
|
|
},
|
|
});
|
|
}
|
|
|
|
const FILE_ENTITY: InlineMentionEntity = {
|
|
id: 'designs/landing.html',
|
|
kind: 'file',
|
|
label: 'designs/landing.html',
|
|
token: '@designs/landing.html',
|
|
title: 'File: designs/landing.html',
|
|
};
|
|
|
|
const MCP_ENTITY: InlineMentionEntity = {
|
|
id: 'slack',
|
|
kind: 'mcp',
|
|
label: 'Slack MCP',
|
|
token: '@Slack MCP',
|
|
title: 'MCP: Slack MCP',
|
|
};
|
|
|
|
const SKILL_ENTITY: InlineMentionEntity = {
|
|
id: 'deck-builder',
|
|
kind: 'skill',
|
|
label: 'Deck Builder',
|
|
token: '@Deck Builder',
|
|
};
|
|
|
|
describe('serializeComposer / setComposerFromText round-trip', () => {
|
|
it('keeps a plain prompt with no mentions byte-identical', () => {
|
|
const editor = makeEditor();
|
|
setComposerFromText(editor, 'hello world', []);
|
|
const result = serializeComposer(editor.getEditorState());
|
|
expect(result.text).toBe('hello world');
|
|
expect(result.present).toHaveLength(0);
|
|
});
|
|
|
|
it('round-trips a known file mention as a single @token plus trailing text', () => {
|
|
const editor = makeEditor();
|
|
const text = 'Use @designs/landing.html now';
|
|
setComposerFromText(editor, text, [FILE_ENTITY]);
|
|
const result = serializeComposer(editor.getEditorState());
|
|
expect(result.text).toBe(text);
|
|
expect(result.present).toHaveLength(1);
|
|
expect(result.present[0]?.id).toBe('designs/landing.html');
|
|
expect(result.present[0]?.kind).toBe('file');
|
|
});
|
|
|
|
it('preserves a Shift+Enter newline as a single \\n (no \\n\\n gap)', () => {
|
|
const editor = makeEditor();
|
|
// The exact shape a Shift+Enter produces: one LineBreakNode inside the
|
|
// single paragraph. It must serialize back to a single `\n`.
|
|
const text = '@designs/landing.html hi\nworld';
|
|
setComposerFromText(editor, text, [FILE_ENTITY]);
|
|
expect(serializeComposer(editor.getEditorState()).text).toBe(text);
|
|
});
|
|
|
|
it('preserves multi-newline spacing around a mention (no \\n collapse)', () => {
|
|
const editor = makeEditor();
|
|
const text = 'Plan:\n\n@designs/landing.html \n\nKeep spacing';
|
|
setComposerFromText(editor, text, [FILE_ENTITY]);
|
|
expect(serializeComposer(editor.getEditorState()).text).toBe(text);
|
|
});
|
|
|
|
it('emits the MCP token text verbatim including the space inside the label', () => {
|
|
const editor = makeEditor();
|
|
setComposerFromText(editor, '@Slack MCP ', [MCP_ENTITY]);
|
|
const result = serializeComposer(editor.getEditorState());
|
|
expect(result.text).toBe('@Slack MCP ');
|
|
expect(result.present[0]?.id).toBe('slack');
|
|
expect(result.present[0]?.kind).toBe('mcp');
|
|
});
|
|
|
|
it('round-trips a skill mention carrying its real id', () => {
|
|
const editor = makeEditor();
|
|
setComposerFromText(editor, 'Plan with @Deck Builder please', [SKILL_ENTITY]);
|
|
const result = serializeComposer(editor.getEditorState());
|
|
expect(result.text).toBe('Plan with @Deck Builder please');
|
|
expect(result.present).toHaveLength(1);
|
|
expect(result.present[0]?.id).toBe('deck-builder');
|
|
expect(result.present[0]?.kind).toBe('skill');
|
|
});
|
|
|
|
it('keeps unknown @tokens as plain text (not a MentionNode)', () => {
|
|
const editor = makeEditor();
|
|
setComposerFromText(editor, 'hi @nobody there', [FILE_ENTITY]);
|
|
const result = serializeComposer(editor.getEditorState());
|
|
expect(result.text).toBe('hi @nobody there');
|
|
expect(result.present).toHaveLength(0);
|
|
});
|
|
|
|
it('builds a single root paragraph (no \\n\\n block join)', () => {
|
|
const editor = makeEditor();
|
|
setComposerFromText(editor, 'line one\nline two', []);
|
|
editor.getEditorState().read(() => {
|
|
expect($getRoot().getChildrenSize()).toBe(1);
|
|
});
|
|
expect(serializeComposer(editor.getEditorState()).text).toBe('line one\nline two');
|
|
});
|
|
});
|
|
|
|
describe('MentionNode atomic behaviour', () => {
|
|
it('reports token text as its node text content and stays token mode', () => {
|
|
const editor = makeEditor();
|
|
setComposerFromText(editor, '@designs/landing.html', [FILE_ENTITY]);
|
|
editor.getEditorState().read(() => {
|
|
const para = $getRoot().getFirstChild();
|
|
const mention =
|
|
para && 'getFirstChild' in para
|
|
? (para as unknown as { getFirstChild: () => unknown }).getFirstChild()
|
|
: null;
|
|
expect(mention).toBeInstanceOf(MentionNode);
|
|
expect((mention as MentionNode).getTextContent()).toBe('@designs/landing.html');
|
|
expect((mention as MentionNode).getToken()).toBe('@designs/landing.html');
|
|
expect((mention as MentionNode).getMode()).toBe('token');
|
|
});
|
|
});
|
|
});
|