1// @vitest-environment jsdom
2
3import { StateEffect } from "@codemirror/state";
4import { EditorView } from "@codemirror/view";
5import { afterEach, describe, expect, it, vi } from "vite-plus/test";
6
7const tokenization = vi.hoisted(() => {
8 let resolve: (value: {
9 codeToTokens: (code: string) => { tokens: Array<Array<{ content: string; color: string }>> };
10 }) => void;
11 const ready = new Promise<{
12 codeToTokens: (code: string) => { tokens: Array<Array<{ content: string; color: string }>> };
13 }>((done) => {
14 resolve = done;
15 });
16 return { ready, resolve: (value: Parameters<typeof resolve>[0]) => resolve(value) };
17});
18vi.mock("~/lib/syntaxHighlighting", () => ({
19 getSyntaxHighlighterPromise: () => tokenization.ready,
20}));
21
22import { createComposerMarkdownEditor } from "./composerMarkdownEditor";
23import { composerCodeBlocks, composerCodeBlockHighlight } from "./composerCodeBlockHighlight";
24import type { ComposerPromptEditorProps } from "./ComposerPromptEditor";
25
26let editor: ReturnType<typeof createComposerMarkdownEditor>;
27afterEach(() => {
28 editor?.destroy();
29 editor?.dom.remove();
30});
31function mount(value: string) {
32 const props: ComposerPromptEditorProps = {
33 value,
34 cursor: value.length,
35 contextRecords: new Map(),
36 skills: [],
37 disabled: false,
38 richTextEnabled: true,
39 placeholder: "Message",
40 editorRef: { current: null },
41 onChange: () => {},
42 onPaste: () => {},
43 };
44 editor = createComposerMarkdownEditor({
45 props: () => props,
46 mountChip: () => {},
47 unmountChip: () => {},
48 });
49 document.body.append(editor.dom);
50 return editor;
51}
52
53it("lands an empty fence's language selection in its body rather than inside the info string", () => {
54 mount("```ts\n\n```");
55 const block = composerCodeBlocks(editor.view.state)[0]!;
56 expect(editor.view.state.doc.lineAt(block.codeTo).number).toBe(2);
57 editor.view.dispatch({
58 changes: { from: block.infoFrom, to: block.infoTo, insert: "python" },
59 selection: { anchor: block.codeTo + 4 },
60 });
61 expect(editor.getValue()).toBe("```python\n\n```");
62 expect(editor.view.state.doc.lineAt(editor.view.state.selection.main.head).number).toBe(2);
63});
64
65it("keeps the trailing line of an unfinished fence as part of its code body", () => {
66 mount("```ts\nunfinished\n");
67 const block = composerCodeBlocks(editor.view.state)[0]!;
68 expect(block.code).toBe("unfinished\n");
69 expect(block.codeTo).toBe(editor.view.state.doc.length);
70});
71
72describe("asynchronous code highlighting", () => {
73 it("paints current text after initialization and never reintroduces an edited-away tokenization", async () => {
74 mount("```ts\nold\n```");
75 editor.setValue("```ts\nnew\n```");
76 const settled = new Promise<void>((resolve) => {
77 editor.view.dispatch({
78 effects: StateEffect.appendConfig.of(
79 EditorView.updateListener.of(() => {
80 if ((editor.view.plugin(composerCodeBlockHighlight)?.decorations.size ?? 0) > 0)
81 resolve();
82 }),
83 ),
84 });
85 });
86 const codeToTokens = vi.fn((code: string) => ({
87 tokens: [[{ content: code, color: "#123456" }]],
88 }));
89 tokenization.resolve({ codeToTokens });
90 await settled;
91 expect(codeToTokens.mock.calls.map(([code]) => code)).not.toContain("old");
92 expect(codeToTokens.mock.calls.map(([code]) => code)).toContain("new");
93 expect(editor.getValue()).toBe("```ts\nnew\n```");
94 expect(
95 [...editor.dom.querySelectorAll<HTMLElement>("span")].find(
96 (span) => span.style.color === "rgb(18, 52, 86)",
97 )?.textContent,
98 ).toBe("new");
99 });
100});