1// @vitest-environment jsdom
2import { cursorCharLeft, cursorCharRight } from "@codemirror/commands";
3import { syntaxTree } from "@codemirror/language";
4import { EditorView } from "@codemirror/view";
5import { act } from "react";
6import { createRoot, type Root } from "react-dom/client";
7import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
8
9import { CommitMessageEditor } from "./CommitMessageEditor";
10
11let root: Root;
12let container: HTMLDivElement;
13const change = vi.fn();
14
15beforeEach(() => {
16 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
17 container = document.createElement("div");
18 document.body.append(container);
19 root = createRoot(container);
20});
21
22afterEach(() => {
23 act(() => root.unmount());
24 container.remove();
25 change.mockReset();
26 vi.unstubAllGlobals();
27});
28
29function render(value: string, readOnly = false) {
30 act(() =>
31 root.render(<CommitMessageEditor value={value} readOnly={readOnly} onChange={change} />),
32 );
33 const view = EditorView.findFromDOM(container.querySelector<HTMLElement>(".cm-editor")!)!;
34 return view;
35}
36
37function typeAt(view: EditorView, position: number, text: string) {
38 act(() =>
39 view.dispatch({
40 changes: { from: position, insert: text },
41 selection: { anchor: position + text.length },
42 userEvent: "input",
43 }),
44 );
45}
46
47describe("CommitMessageEditor", () => {
48 it("keeps the title decoration on line 1 as lines move", () => {
49 const view = render("fix: title\nbody");
50 const title = () => container.querySelector(".cm-commit-title")?.textContent;
51 expect(title()).toBe("fix: title");
52
53 typeAt(view, 0, "\n");
54 expect(title()).toBe("");
55 expect(view.state.doc.line(2).text).toBe("fix: title");
56 expect(change).toHaveBeenLastCalledWith("\nfix: title\nbody");
57 });
58
59 it("never parses the title as a heading, list, or setext heading", () => {
60 for (const value of ["# not a heading\nbody", "- not a list\nbody", "title\n---"]) {
61 const view = render(value);
62 const tree = syntaxTree(view.state);
63 expect(tree.resolveInner(1, 1).name).toBe("CommitTitle");
64 expect(container.querySelector(".cm-commit-title")?.textContent).toBe(value.split("\n")[0]);
65 }
66 });
67
68 it("colors conventional-commit titles", () => {
69 render("[ABC-1, ABC-22] feat(review)!: add editor\nbody");
70 expect(container.querySelector(".cm-commit-issues")?.textContent).toBe("[ABC-1, ABC-22]");
71 expect(container.querySelector(".cm-commit-type-add")?.textContent).toBe("feat(review)!:");
72 expect(container.querySelector(".cm-commit-scope")?.textContent).toBe("(review)");
73
74 render("hotfix: x");
75 expect(container.querySelector(".cm-commit-type-fix")?.textContent).toBe("hotfix:");
76 render("chore: x");
77 expect(container.querySelector(".cm-commit-type-other")?.textContent).toBe("chore:");
78 render("WIP: x");
79 expect(container.querySelector(".cm-commit-type-wip")?.textContent).toBe("WIP:");
80 render("feat: x\nfeat: body is not a title");
81 expect(container.querySelectorAll(".cm-commit-type-add")).toHaveLength(1);
82 });
83
84 it("shows each Assisted-by trailer once, as an atomic provider chip", () => {
85 const value = "feat: x\nbody\n\nAssisted-by: claude-opus-5.5\nAssisted-by: mystery-1";
86 const view = render(value);
87
88 const chips = [...container.querySelectorAll(".cm-commit-trailer")];
89 expect(chips.map((chip) => chip.textContent)).toEqual(["claude-opus-5.5", "mystery-1"]);
90 expect(chips.every((chip) => chip.querySelector("svg"))).toBe(true);
91 expect(container.querySelector(".cm-content")?.textContent).not.toContain("Assisted-by");
92 expect(view.state.doc.toString()).toBe(value);
93
94 const trailer = view.state.doc.line(4);
95 act(() => view.dispatch({ selection: { anchor: trailer.from } }));
96 act(() => void cursorCharRight(view));
97 expect(view.state.selection.main.head).toBe(trailer.to);
98 act(() => void cursorCharLeft(view));
99 expect(view.state.selection.main.head).toBe(trailer.from);
100 });
101
102 it("adopts external values without echoing onChange and keeps the caret", () => {
103 const view = render("title\nbody");
104 act(() => view.dispatch({ selection: { anchor: "title\nbo".length } }));
105
106 render("new title\nbody");
107 expect(view.state.doc.toString()).toBe("new title\nbody");
108 expect(view.state.selection.main.head).toBe("new title\nbo".length);
109 expect(change).not.toHaveBeenCalled();
110 });
111
112 it("is read-only when asked", () => {
113 const view = render("title", true);
114 expect(view.state.readOnly).toBe(true);
115 expect(view.contentDOM.getAttribute("contenteditable")).toBe("false");
116
117 render("title", false);
118 expect(view.state.readOnly).toBe(false);
119 });
120});