1import { act, StrictMode, useLayoutEffect } from "react";
2import { createRoot, type Root } from "react-dom/client";
3import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
4
5import { detectComposerTrigger } from "../../composer-logic";
6import { useComposerTriggerState } from "./useComposerTriggerState";
7
8const command = "pnpm install -g @openai/codex@latest";
9const initialPrompt = "pnpm install -g @openai";
10let root: Root;
11let composer: ReturnType<typeof useComposerTriggerState>;
12
13function ComposerProbe() {
14 const state = useComposerTriggerState(() =>
15 detectComposerTrigger(initialPrompt, initialPrompt.length),
16 );
17 useLayoutEffect(() => {
18 composer = state;
19 });
20 return null;
21}
22
23async function updatePrompt(text: string, cursor = text.length) {
24 await act(() => composer.setTrigger(detectComposerTrigger(text, cursor)));
25}
26
27beforeEach(async () => {
28 // The probe renders no DOM nodes, but ReactDOM still needs an event target.
29 const document = {
30 nodeType: 9,
31 addEventListener() {},
32 removeEventListener() {},
33 };
34 const container = {
35 nodeType: 1,
36 tagName: "DIV",
37 namespaceURI: "http://www.w3.org/1999/xhtml",
38 ownerDocument: document,
39 addEventListener() {},
40 removeEventListener() {},
41 };
42 vi.stubGlobal("document", document);
43 vi.stubGlobal("window", { document, HTMLIFrameElement: EventTarget });
44 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
45 root = createRoot(container as unknown as HTMLElement);
46 await act(() =>
47 root.render(
48 <StrictMode>
49 <ComposerProbe />
50 </StrictMode>,
51 ),
52 );
53});
54
55afterEach(async () => {
56 await act(() => root.unmount());
57 vi.unstubAllGlobals();
58});
59
60describe("composer suggestion dismissal", () => {
61 it("closes suggestions and rejects keyboard selection before the next render", async () => {
62 const candidate = detectComposerTrigger(initialPrompt, initialPrompt.length);
63 expect(composer.trigger).toEqual(candidate);
64
65 await act(() => {
66 composer.dismissTrigger(candidate);
67 expect(composer.resolveTrigger(candidate)).toBeNull();
68 });
69 expect(composer.trigger).toBeNull();
70 });
71
72 it("stays dismissed while typing a scoped package, including its second @", async () => {
73 await act(() => composer.dismissTrigger(composer.trigger));
74
75 for (let cursor = initialPrompt.length; cursor <= command.length; cursor += 1) {
76 const text = command.slice(0, cursor);
77 await updatePrompt(text);
78 expect(composer.trigger).toBeNull();
79 expect(composer.resolveTrigger(detectComposerTrigger(text, cursor))).toBeNull();
80 }
81 });
82
83 it("stays dismissed while deleting characters or moving within the same word", async () => {
84 await act(() => composer.dismissTrigger(composer.trigger));
85
86 for (let cursor = initialPrompt.length - 1; cursor > initialPrompt.indexOf("@"); cursor -= 1) {
87 await updatePrompt(initialPrompt, cursor);
88 expect(composer.trigger).toBeNull();
89 await updatePrompt(initialPrompt.slice(0, cursor));
90 expect(composer.trigger).toBeNull();
91 }
92 });
93
94 it("opens suggestions for a new @ word after a space", async () => {
95 await act(() => composer.dismissTrigger(composer.trigger));
96 await updatePrompt(`${command} `);
97 await updatePrompt(`${command} @src`);
98
99 expect(composer.trigger?.query).toBe("src");
100 expect(composer.trigger?.rangeStart).toBe(command.length + 1);
101 });
102
103 it("opens a different token when the caret moves directly to it", async () => {
104 await act(() => composer.dismissTrigger(composer.trigger));
105 await updatePrompt(`${command} @src`);
106
107 expect(composer.trigger?.query).toBe("src");
108 });
109
110 it("can reopen after the caret leaves the dismissed word", async () => {
111 await act(() => composer.dismissTrigger(composer.trigger));
112 await updatePrompt(initialPrompt, 0);
113 await updatePrompt(initialPrompt);
114
115 expect(composer.trigger?.query).toBe("openai");
116 });
117
118 it("can reopen at the same position after deleting and retyping @", async () => {
119 await act(() => composer.dismissTrigger(composer.trigger));
120 const prefix = initialPrompt.slice(0, initialPrompt.indexOf("@"));
121 await updatePrompt(prefix);
122 await updatePrompt(`${prefix}@`);
123
124 expect(composer.trigger?.query).toBe("");
125 });
126
127 it("clears dismissal when switching drafts or pending questions", async () => {
128 await act(() => composer.dismissTrigger(composer.trigger));
129 const candidate = detectComposerTrigger(initialPrompt, initialPrompt.length);
130 await act(() => composer.resetTrigger(candidate));
131
132 expect(composer.trigger).toEqual(candidate);
133 expect(composer.resolveTrigger(candidate)).toEqual(candidate);
134 });
135
136 it.each(["/plan", "$skill", "#123", ":dizzy"])("also dismisses %s suggestions", async (text) => {
137 await updatePrompt(text);
138 await act(() => composer.dismissTrigger(composer.trigger));
139 await updatePrompt(`${text}x`);
140
141 expect(composer.trigger).toBeNull();
142 await updatePrompt("@src");
143 expect(composer.trigger?.kind).toBe("path");
144 });
145});