| 1 | // @vitest-environment jsdom |
| 2 | |
| 3 | import { StateEffect } from "@codemirror/state"; |
| 4 | import { EditorView } from "@codemirror/view"; |
| 5 | import { afterEach, describe, expect, it, vi } from "vite-plus/test"; |
| 6 | |
| 7 | const 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 | }); |
| 18 | vi.mock("~/lib/syntaxHighlighting", () => ({ |
| 19 | getSyntaxHighlighterPromise: () => tokenization.ready, |
| 20 | })); |
| 21 | |
| 22 | import { createComposerMarkdownEditor } from "./composerMarkdownEditor"; |
| 23 | import { composerCodeBlocks, composerCodeBlockHighlight } from "./composerCodeBlockHighlight"; |
| 24 | import type { ComposerPromptEditorProps } from "./ComposerPromptEditor"; |
| 25 | |
| 26 | let editor: ReturnType<typeof createComposerMarkdownEditor>; |
| 27 | afterEach(() => { |
| 28 | editor?.destroy(); |
| 29 | editor?.dom.remove(); |
| 30 | }); |
| 31 | function 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 | |
| 53 | it("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 | |
| 65 | it("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 | |
| 72 | describe("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 | }); |