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
237 lines
7.9 KiB
TypeScript
237 lines
7.9 KiB
TypeScript
// @vitest-environment jsdom
|
|
|
|
import { describe, expect, it } from 'vitest';
|
|
import {
|
|
$createParagraphNode,
|
|
$getNodeByKey,
|
|
$getRoot,
|
|
createEditor,
|
|
type LexicalEditor,
|
|
} from 'lexical';
|
|
|
|
import {
|
|
MentionNode,
|
|
$createMentionNode,
|
|
$isMentionNode,
|
|
} from '../../../src/components/composer/MentionNode';
|
|
|
|
function makeEditor(): LexicalEditor {
|
|
return createEditor({
|
|
namespace: 'mention-node-test',
|
|
nodes: [MentionNode],
|
|
onError: (e) => {
|
|
throw e;
|
|
},
|
|
});
|
|
}
|
|
|
|
describe('MentionNode', () => {
|
|
it('uses the @token as its text content so the wire format is free', () => {
|
|
const editor = makeEditor();
|
|
editor.update(
|
|
() => {
|
|
const node = $createMentionNode({
|
|
mentionId: 'deck-builder',
|
|
mentionKind: 'skill',
|
|
token: '@Deck Builder',
|
|
label: 'Deck Builder',
|
|
});
|
|
expect(node.getTextContent()).toBe('@Deck Builder');
|
|
expect(node.getToken()).toBe('@Deck Builder');
|
|
expect($isMentionNode(node)).toBe(true);
|
|
expect(node.isToken()).toBe(true);
|
|
expect(node.getMode()).toBe('token');
|
|
expect(node.canInsertTextBefore()).toBe(true);
|
|
expect(node.canInsertTextAfter()).toBe(true);
|
|
},
|
|
{ discrete: true },
|
|
);
|
|
});
|
|
|
|
it('exposes the backing entity (id/kind/label/title)', () => {
|
|
const editor = makeEditor();
|
|
editor.update(
|
|
() => {
|
|
const node = $createMentionNode({
|
|
mentionId: 'slack',
|
|
mentionKind: 'mcp',
|
|
token: '@Slack MCP',
|
|
label: 'Slack MCP',
|
|
title: 'MCP: Slack MCP',
|
|
});
|
|
expect(node.getEntity()).toEqual({
|
|
id: 'slack',
|
|
kind: 'mcp',
|
|
label: 'Slack MCP',
|
|
token: '@Slack MCP',
|
|
title: 'MCP: Slack MCP',
|
|
});
|
|
},
|
|
{ discrete: true },
|
|
);
|
|
});
|
|
|
|
it('round-trips through export/import JSON', () => {
|
|
const editor = makeEditor();
|
|
editor.update(
|
|
() => {
|
|
const node = $createMentionNode({
|
|
mentionId: 'designs/x.html',
|
|
mentionKind: 'file',
|
|
token: '@designs/x.html',
|
|
label: 'designs/x.html',
|
|
});
|
|
const json = node.exportJSON();
|
|
expect(json.type).toBe('composer-mention');
|
|
expect(json.mentionId).toBe('designs/x.html');
|
|
const clone = MentionNode.importJSON(json);
|
|
expect(clone.getEntity()).toEqual(node.getEntity());
|
|
},
|
|
{ discrete: true },
|
|
);
|
|
});
|
|
|
|
it('survives cloning an existing node without recursing (token-mode regression)', () => {
|
|
// Regression: the constructor used to call `this.setMode('token')`, which
|
|
// recurses setMode → getWritable → clone() → new MentionNode → setMode …
|
|
// whenever Lexical clones an EXISTING mention node. That happens on every
|
|
// mention delete / range-select, so the editor crashed with a
|
|
// "Maximum call stack size exceeded" RangeError. Token mode now lives in
|
|
// $createMentionNode and clones inherit it via TextNode.afterCloneFrom.
|
|
const editor = makeEditor();
|
|
let key = '';
|
|
editor.update(
|
|
() => {
|
|
const p = $createParagraphNode();
|
|
const node = $createMentionNode({
|
|
mentionId: 'p1',
|
|
mentionKind: 'plugin',
|
|
token: '@Deck',
|
|
label: 'Deck',
|
|
});
|
|
p.append(node);
|
|
$getRoot().clear();
|
|
$getRoot().append(p);
|
|
key = node.getKey();
|
|
},
|
|
{ discrete: true },
|
|
);
|
|
|
|
// A second update that mutates the existing node forces Lexical to clone
|
|
// it through getWritable — the exact path that used to stack-overflow.
|
|
expect(() =>
|
|
editor.update(
|
|
() => {
|
|
const node = $getNodeByKey(key);
|
|
if ($isMentionNode(node)) node.getWritable();
|
|
},
|
|
{ discrete: true },
|
|
),
|
|
).not.toThrow();
|
|
|
|
editor.getEditorState().read(() => {
|
|
const node = $getNodeByKey(key);
|
|
expect($isMentionNode(node)).toBe(true);
|
|
// Narrow via the type guard so `getMode` (a TextNode method) is in scope.
|
|
expect($isMentionNode(node) ? node.getMode() : null).toBe('token');
|
|
});
|
|
});
|
|
|
|
it('renders a kind-scoped pill span in the DOM when mounted in an editor', () => {
|
|
// `createDOM` requires the reconciler's active-editor DOM context, so drive
|
|
// it through a real mounted editor + root element rather than calling
|
|
// createDOM() in isolation. The reconciler renders the MentionNode's span
|
|
// with the kind class + data attributes.
|
|
const root = document.createElement('div');
|
|
document.body.appendChild(root);
|
|
const editor = makeEditor();
|
|
editor.setRootElement(root);
|
|
editor.update(
|
|
() => {
|
|
const p = $createParagraphNode();
|
|
p.append(
|
|
$createMentionNode({
|
|
mentionId: 'p1',
|
|
mentionKind: 'plugin',
|
|
token: '@Deck',
|
|
label: 'Deck',
|
|
}),
|
|
);
|
|
$getRoot().clear();
|
|
$getRoot().append(p);
|
|
},
|
|
{ discrete: true },
|
|
);
|
|
const pill = root.querySelector('.composer-inline-mention');
|
|
expect(pill).not.toBeNull();
|
|
expect(pill?.className).toContain('composer-inline-mention--plugin');
|
|
expect(pill?.getAttribute('data-mention-id')).toBe('p1');
|
|
expect(pill?.getAttribute('data-mention-kind')).toBe('plugin');
|
|
expect(pill?.getAttribute('contenteditable')).toBe('false');
|
|
expect(pill?.textContent).toBe('@Deck');
|
|
editor.setRootElement(null);
|
|
root.remove();
|
|
});
|
|
|
|
it('re-stamps a mounted connector pill hue when the live theme flips', async () => {
|
|
// Regression: applyBrandHue only ran from createDOM / node mutation, so a
|
|
// connector pill inserted in light mode kept its near-black light hue after
|
|
// a live theme switch — dark-on-dark, unreadable text in dark mode. The
|
|
// theme observer must recompute --m-hue when <html data-theme> changes.
|
|
const parseHex = (hex: string): { r: number; g: number; b: number } => {
|
|
const int = parseInt(hex.replace('#', ''), 16);
|
|
return { r: (int >> 16) & 0xff, g: (int >> 8) & 0xff, b: int & 0xff };
|
|
};
|
|
const luminance = (hex: string): number => {
|
|
const { r, g, b } = parseHex(hex);
|
|
return (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
|
|
};
|
|
|
|
const prevTheme = document.documentElement.getAttribute('data-theme');
|
|
document.documentElement.setAttribute('data-theme', 'light');
|
|
|
|
const root = document.createElement('div');
|
|
document.body.appendChild(root);
|
|
const editor = makeEditor();
|
|
editor.setRootElement(root);
|
|
editor.update(
|
|
() => {
|
|
const p = $createParagraphNode();
|
|
p.append(
|
|
$createMentionNode({
|
|
// Notion's curated brand is near-black (#0B0B0B) in light mode.
|
|
mentionId: 'notion',
|
|
mentionKind: 'connector',
|
|
token: '@Notion',
|
|
label: 'Notion',
|
|
}),
|
|
);
|
|
$getRoot().clear();
|
|
$getRoot().append(p);
|
|
},
|
|
{ discrete: true },
|
|
);
|
|
|
|
const pill = root.querySelector<HTMLElement>('.composer-inline-mention--connector');
|
|
expect(pill).not.toBeNull();
|
|
expect(pill?.getAttribute('data-mention-label')).toBe('Notion');
|
|
const lightHue = pill!.style.getPropertyValue('--m-hue').trim();
|
|
expect(lightHue).toMatch(/^#[0-9a-fA-F]{6}$/i);
|
|
const lightLum = luminance(lightHue);
|
|
|
|
// Flip to dark mode. jsdom delivers MutationObserver callbacks on a
|
|
// microtask, so let the queue drain before reading the re-stamped hue.
|
|
document.documentElement.setAttribute('data-theme', 'dark');
|
|
await Promise.resolve();
|
|
const darkHue = pill!.style.getPropertyValue('--m-hue').trim();
|
|
expect(darkHue).toMatch(/^#[0-9a-fA-F]{6}$/i);
|
|
expect(darkHue).not.toBe(lightHue);
|
|
expect(luminance(darkHue)).toBeGreaterThan(lightLum);
|
|
|
|
editor.setRootElement(null);
|
|
root.remove();
|
|
if (prevTheme === null) document.documentElement.removeAttribute('data-theme');
|
|
else document.documentElement.setAttribute('data-theme', prevTheme);
|
|
});
|
|
});
|