1// @vitest-environment jsdom
2
3import "./dom";
4// The DOM-bound half of the mention machinery: widget rendering, atomic chips in a live view, and
5// the clipboard filters. The pure serialization properties live in mentions.test.ts under kiln.
6import { EditorState } from "@codemirror/state";
7import { EditorView } from "@codemirror/view";
8import { describe, expect, it } from "vite-plus/test";
9
10import {
11 createMentionRegistry,
12 hydrateMentions,
13 insertMention,
14 mentionsExtension,
15 serializeMentions,
16 staticMentionProvider,
17} from "../src/mentions.ts";
18
19describe("mention widgets", () => {
20 it("rehydrates saved file ranges with their line count", () => {
21 const registry = createMentionRegistry();
22 const saved = "review @sys/ui/chat/src/editor.ts:12-18 please";
23 const view = new EditorView({
24 state: EditorState.create({
25 doc: hydrateMentions(registry, saved),
26 extensions: mentionsExtension(registry),
27 }),
28 parent: document.body,
29 });
30 expect(view.dom.querySelector(".chip-label")?.textContent).toBe("editor.ts");
31 expect(view.dom.querySelector(".chip-detail")?.textContent).toBe("L12-18");
32 expect(serializeMentions(registry, view.state.doc.toString())).toBe(saved);
33 view.destroy();
34 });
35
36 it("replaces the @query with an atomic chip that serializes back to @token", () => {
37 const registry = createMentionRegistry();
38 const view = new EditorView({
39 state: EditorState.create({ doc: "see brok", extensions: mentionsExtension(registry) }),
40 });
41 insertMention(
42 view,
43 registry,
44 {
45 kind: "file",
46 label: "session.ts",
47 token: "sys/agent-backend/src/providers/claude/session.ts",
48 },
49 { from: 4, to: 8 },
50 );
51 expect(serializeMentions(registry, view.state.doc.toString()).trim()).toBe(
52 "see @sys/agent-backend/src/providers/claude/session.ts",
53 );
54 view.destroy();
55 });
56
57 it("round-trips copied mention text through the clipboard filters", () => {
58 const registry = createMentionRegistry();
59 const view = new EditorView({
60 state: EditorState.create({
61 doc: "@src/app.ts:3-5",
62 extensions: mentionsExtension(registry),
63 }),
64 });
65 const input = view.state.facet(EditorView.clipboardInputFilter);
66 const output = view.state.facet(EditorView.clipboardOutputFilter);
67 const hydrated = input.reduce(
68 (text, filter) => filter(text, view.state),
69 view.state.doc.toString(),
70 );
71
72 expect(hydrated).toMatch(/⟦mention:\d+⟧/);
73 expect(output.reduce((text, filter) => filter(text, view.state), hydrated)).toBe(
74 "@src/app.ts:3-5",
75 );
76 view.destroy();
77 });
78
79 it("renders a provided file icon URL in the mention chip", () => {
80 const registry = createMentionRegistry();
81 const view = new EditorView({
82 state: EditorState.create({ doc: "@file", extensions: mentionsExtension(registry) }),
83 parent: document.body,
84 });
85 insertMention(
86 view,
87 registry,
88 {
89 kind: "file",
90 label: "app.ts",
91 token: "src/app.ts",
92 icon: "data:image/svg+xml;base64,PHN2Zy8+",
93 },
94 { from: 0, to: 5 },
95 );
96 expect(view.dom.querySelector<HTMLImageElement>(".chip-icon img")?.src).toContain(
97 "data:image/svg+xml;base64,PHN2Zy8+",
98 );
99 view.destroy();
100 });
101
102 it("serializes symbol mentions to their source range token", () => {
103 const registry = createMentionRegistry();
104 const view = new EditorView({
105 state: EditorState.create({ doc: "@acti", extensions: mentionsExtension(registry) }),
106 });
107 insertMention(
108 view,
109 registry,
110 {
111 kind: "symbol",
112 label: "activate",
113 detail: "extension · sys/extension/src/extension.ts",
114 token: "sys/extension/src/extension.ts:12:3",
115 },
116 { from: 0, to: 5 },
117 );
118 expect(serializeMentions(registry, view.state.doc.toString()).trim()).toBe(
119 "@sys/extension/src/extension.ts:12:3",
120 );
121 view.destroy();
122 });
123});
124
125describe("staticMentionProvider", () => {
126 it("substring-filters across label, token, and detail", async () => {
127 const provider = staticMentionProvider([
128 {
129 kind: "file",
130 label: "session.ts",
131 detail: "agent-backend/src/providers/claude",
132 token: "src/providers/claude/session.ts",
133 },
134 { kind: "symbol", label: "AgentSession", detail: "interface", token: "AgentSession" },
135 { kind: "file", label: "editor.ts", detail: "agent-ui/src", token: "src/editor.ts" },
136 ]);
137 const hits = await provider.search("session");
138 expect(hits.map((h) => h.label)).toEqual(["session.ts", "AgentSession"]);
139 expect(await provider.search("agent-ui")).toHaveLength(1);
140 });
141});