Skip to content
Source
Sign inSign up

ChatView.browser.test.tsx

305 lines12.6 KB

1import { createMemoryHistory } from "@tanstack/react-router";
2import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment";
3import { ThreadId } from "@t3tools/contracts";
4import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings";
5import type * as Context from "effect/Context";
6import { expect, it, vi } from "vite-plus/test";
7import { page, userEvent } from "vite-plus/test/browser";
8​
9import { AppRoot } from "~/AppRoot";
10// The route loads ChatView lazily, and its first transform (React Compiler over ~4k lines)
11// takes 6-11s; importing it here keeps that out of the test's timeout.
12import "~/components/ChatView";
13import { DraftId, useComposerDraftStore } from "~/composerDraftStore";
14import { selectThreadDiffPanelSelection, useDiffPanelStore } from "~/diffPanelStore";
15import { __setClientSettingsForTests } from "~/hooks/useSettings";
16import { useRightPanelStore } from "~/rightPanelStore";
17import { getRouter } from "~/router";
18import {
19 ENVIRONMENT_ID,
20 PROJECT_ID,
21 dispatchedCommands,
22 installFakeEnvironment,
23 rejectPendingCommand,
24 threadWithHistory,
25 type FakeDriver,
26} from "../../test/fakeEnvironment";
27import { act, fakeTimers, render } from "../../test/render";
28​
29// The socket is the only fake layer; see test/appScenarios.renders.tsx.
30vi.mock("../../../../packages/client-runtime/src/connection/driver.ts", async (importOriginal) => {
31 const [actual, { fakeDriver }, Layer] = await Promise.all([
32 importOriginal<{ readonly ConnectionDriver: Context.Key<unknown, FakeDriver> }>(),
33 import("../../test/fakeEnvironment"),
34 import("effect/Layer"),
35 ]);
36 return { ...actual, layer: Layer.succeed(actual.ConnectionDriver, fakeDriver) };
37});
38​
39vi.mock(import("~/rpc/requestLatencyState"), async (original) => ({
40 ...(await original()),
41 trackRpcRequestSent: () => {},
42}));
43​
44it("a new draft does not load its reserved thread before the server lists it", async () => {
45 using _environment = installFakeEnvironment([]);
46 using fetchSpy = vi.spyOn(globalThis, "fetch");
47 __setClientSettingsForTests({
48 ...DEFAULT_CLIENT_SETTINGS,
49 onboardingCompletedAt: "2026-01-01T00:00:00.000Z",
50 });
51 const draftId = DraftId.make("draft-27443");
52 const threadId = ThreadId.make("thread-27443");
53 useComposerDraftStore
54 .getState()
55 .setProjectDraftThreadId(scopeProjectRef(ENVIRONMENT_ID, PROJECT_ID), draftId, { threadId });
56​
57 const router = getRouter(createMemoryHistory({ initialEntries: [`/draft/${draftId}`] }));
58 await router.load();
59 await using _app = await render(<AppRoot router={router} />, {
60 // AppRoot brings its own router.
61 router: false,
62 });
63 await expect.element(page.getByTestId("composer-editor")).toBeVisible();
64 // The shell snapshot has arrived once the sidebar lists the project, so the connection is up.
65 await expect.element(page.getByText("render", { exact: true }).first()).toBeVisible();
66​
67 const threadRequests = fetchSpy.mock.calls
68 .map(([input]) => String(input instanceof Request ? input.url : input))
69 .filter((url) => url.includes(`/api/orchestration/threads/${threadId}/`));
70 // A 404 here marks the thread deleted, and the view never loads it once the send creates it.
71 expect(threadRequests).toEqual([]);
72});
73​
74it("opens the thread actions menu for an older settled thread the shell leaves out", async () => {
75 const thread = { ...threadWithHistory("older-settled", 1), olderSettled: true as const };
76 using _environment = installFakeEnvironment([thread]);
77 __setClientSettingsForTests({
78 ...DEFAULT_CLIENT_SETTINGS,
79 onboardingCompletedAt: "2026-01-01T00:00:00.000Z",
80 });
81​
82 const router = getRouter(
83 createMemoryHistory({ initialEntries: [`/${ENVIRONMENT_ID}/${thread.id}`] }),
84 );
85 await router.load();
86 await using _app = await render(<AppRoot router={router} />, {
87 // AppRoot brings its own router.
88 router: false,
89 });
90 await expect.element(page.getByText("older-settled: question 0")).toBeVisible();
91​
92 await page.getByRole("button", { name: `Thread actions for ${thread.title}` }).click();
93 // Without a bound the poll runs to the test deadline and reports a bare timeout.
94 await expect
95 .element(page.getByRole("menuitem", { name: "Rename thread" }), { timeout: 5_000 })
96 .toBeVisible();
97});
98​
99it("retains each thread's diff selection when returning from another thread or Settings", async () => {
100 const a = threadWithHistory("diff-selection-a", 1);
101 const b = threadWithHistory("diff-selection-b", 1);
102 using _environment = installFakeEnvironment([a, b]);
103 __setClientSettingsForTests({
104 ...DEFAULT_CLIENT_SETTINGS,
105 onboardingCompletedAt: "2026-01-01T00:00:00.000Z",
106 });
107 const aRef = scopeThreadRef(ENVIRONMENT_ID, a.id);
108 const bRef = scopeThreadRef(ENVIRONMENT_ID, b.id);
109 useRightPanelStore.getState().open(aRef, "diff");
110 useRightPanelStore.getState().open(bRef, "diff");
111 useDiffPanelStore.getState().selectBranchBaseRef(bRef, "origin/release");
112 const router = getRouter(createMemoryHistory({ initialEntries: [`/${ENVIRONMENT_ID}/${a.id}`] }));
113 await router.load();
114 await using _app = await render(<AppRoot router={router} />, {
115 // AppRoot brings its own router.
116 router: false,
117 });
118 await expect.element(page.getByText(`Answer 0 for ${a.id}.`)).toBeVisible();
119 await act(() => useDiffPanelStore.getState().selectGitScope(aRef, "unstaged"));
120​
121 await act(() =>
122 router.navigate({
123 to: "/$environmentId/$threadId",
124 params: { environmentId: ENVIRONMENT_ID, threadId: b.id },
125 }),
126 );
127 await expect.element(page.getByText(`Answer 0 for ${b.id}.`)).toBeVisible();
128 expect(selectThreadDiffPanelSelection(useDiffPanelStore.getState().byThreadKey, bRef)).toEqual({
129 kind: "branch",
130 baseRef: "origin/release",
131 });
132 await act(() =>
133 router.navigate({
134 to: "/$environmentId/$threadId",
135 params: { environmentId: ENVIRONMENT_ID, threadId: a.id },
136 }),
137 );
138 await expect.element(page.getByText(`Answer 0 for ${a.id}.`)).toBeVisible();
139 expect(selectThreadDiffPanelSelection(useDiffPanelStore.getState().byThreadKey, aRef)).toEqual({
140 kind: "unstaged",
141 });
142​
143 await act(() => useDiffPanelStore.getState().selectTurn(aRef, a.runs[0]!.id));
144 await act(() => router.navigate({ to: "/settings/general" }));
145 await expect
146 .element(page.getByRole("heading", { name: "Time format", exact: true }))
147 .toBeVisible();
148 await act(() =>
149 router.navigate({
150 to: "/$environmentId/$threadId",
151 params: { environmentId: ENVIRONMENT_ID, threadId: a.id },
152 }),
153 );
154 await expect.element(page.getByText(`Answer 0 for ${a.id}.`)).toBeVisible();
155 expect(
156 selectThreadDiffPanelSelection(useDiffPanelStore.getState().byThreadKey, aRef),
157 ).toMatchObject({
158 kind: "turn",
159 turnId: a.runs[0]!.id,
160 });
161});
162​
163it("a stashed prompt ending in a file chip restores with the chip", async () => {
164 using _environment = installFakeEnvironment([]);
165 __setClientSettingsForTests({
166 ...DEFAULT_CLIENT_SETTINGS,
167 onboardingCompletedAt: "2026-01-01T00:00:00.000Z",
168 });
169 const draftId = DraftId.make("draft-28338");
170 const drafts = useComposerDraftStore.getState();
171 drafts.setProjectDraftThreadId(scopeProjectRef(ENVIRONMENT_ID, PROJECT_ID), draftId, {
172 threadId: ThreadId.make("thread-28338"),
173 });
174 drafts.setPrompt(draftId, "Check [package.json](package.json) ");
175 const router = getRouter(createMemoryHistory({ initialEntries: [`/draft/${draftId}`] }));
176 await router.load();
177 await using _app = await render(<AppRoot router={router} />, {
178 // AppRoot brings its own router.
179 router: false,
180 });
181 const chip = page.getByRole("button", { name: "Preview package.json" });
182 await expect.element(chip).toBeVisible();
183 // The stash shortcut is a no-op until the shell snapshot lists the project.
184 await expect.element(page.getByText("render", { exact: true }).first()).toBeVisible();
185​
186 const editor = page.getByTestId("composer-editor");
187 await userEvent.click(editor);
188 await userEvent.keyboard("{ControlOrMeta>}s{/ControlOrMeta}");
189 await expect.element(chip).not.toBeInTheDocument();
190 await userEvent.keyboard("{ControlOrMeta>}s{/ControlOrMeta}");
191​
192 await vi.waitFor(() => expect(editor.element().textContent).toContain("Check"));
193 expect(editor.element().textContent).not.toContain("[package.json](package.json)");
194 await expect.element(chip).toBeVisible();
195});
196​
197it("a rejected send leaves the timeline when a new draft was typed meanwhile", async () => {
198 const thread = threadWithHistory("rejected-send", 1);
199 using _environment = installFakeEnvironment([thread], {
200 acceptCommands: true,
201 holdCommand: (command) => command.type === "message.dispatch",
202 });
203 __setClientSettingsForTests({
204 ...DEFAULT_CLIENT_SETTINGS,
205 onboardingCompletedAt: "2026-01-01T00:00:00.000Z",
206 });
207 const router = getRouter(
208 createMemoryHistory({ initialEntries: [`/${ENVIRONMENT_ID}/${thread.id}`] }),
209 );
210 await router.load();
211 await using _app = await render(<AppRoot router={router} />, {
212 // AppRoot brings its own router.
213 router: false,
214 });
215 await expect.element(page.getByText(`Answer 0 for ${thread.id}.`)).toBeVisible();
216​
217 const editor = page.getByTestId("composer-editor");
218 await userEvent.click(editor);
219 await userEvent.keyboard("Failed prompt{Enter}");
220 await vi.waitFor(() =>
221 expect(dispatchedCommands().map((command) => command.type)).toContain("message.dispatch"),
222 );
223 await expect.element(page.getByText("Failed prompt")).toBeVisible();
224 await userEvent.keyboard("Next draft");
225​
226 await rejectPendingCommand("Attachment 'sample.png' cannot be sent.");
227​
228 await expect.element(page.getByText("Attachment 'sample.png' cannot be sent.")).toBeVisible();
229 await expect.element(page.getByText("Failed prompt")).not.toBeInTheDocument();
230 expect(editor.element().textContent).toContain("Next draft");
231});
232​
233it("Enter on an empty prompt resumes an interrupted thread", async () => {
234 const history = threadWithHistory("interrupted", 1);
235 const interruptedRun = { ...history.runs[0]!, status: "interrupted" as const };
236 const thread = { ...history, runs: [interruptedRun] };
237 using _environment = installFakeEnvironment([thread], { acceptCommands: true });
238 __setClientSettingsForTests({
239 ...DEFAULT_CLIENT_SETTINGS,
240 onboardingCompletedAt: "2026-01-01T00:00:00.000Z",
241 });
242 const router = getRouter(
243 createMemoryHistory({ initialEntries: [`/${ENVIRONMENT_ID}/${thread.id}`] }),
244 );
245 await router.load();
246 await using _app = await render(<AppRoot router={router} />, {
247 // AppRoot brings its own router.
248 router: false,
249 });
250 await expect.element(page.getByRole("button", { name: "Resume thread" })).toBeVisible();
251​
252 await userEvent.click(page.getByTestId("composer-editor"));
253 await userEvent.keyboard("{Enter}");
254​
255 await vi.waitFor(() =>
256 expect(dispatchedCommands()).toContainEqual(
257 expect.objectContaining({ manualContinuationOfRunId: interruptedRun.id }),
258 ),
259 );
260});
261​
262it("opening a thread or returning to the window on a touch tablet does not focus the composer", async () => {
263 const realMatchMedia = window.matchMedia.bind(window);
264 using _touch = vi
265 .spyOn(window, "matchMedia")
266 .mockImplementation((query) => realMatchMedia(query === "(pointer: coarse)" ? "all" : query));
267 const first = threadWithHistory("thread-29469-a", 1);
268 const second = threadWithHistory("thread-29469-b", 1);
269 using _environment = installFakeEnvironment([first, second]);
270 // iPad Pro 11 in landscape: the desktop layout, above max-sm.
271 await page.viewport(1194, 834);
272 __setClientSettingsForTests({
273 ...DEFAULT_CLIENT_SETTINGS,
274 onboardingCompletedAt: "2026-01-01T00:00:00.000Z",
275 });
276 const router = getRouter(
277 createMemoryHistory({ initialEntries: [`/${ENVIRONMENT_ID}/${first.id}`] }),
278 );
279 await router.load();
280 await using _app = await render(<AppRoot router={router} />, {
281 // AppRoot brings its own router.
282 router: false,
283 });
284 const composer = page.getByTestId("composer-editor");
285 await expect.element(page.getByText(`Answer 0 for ${first.id}.`)).toBeVisible();
286​
287 // Fake frames hold the next thread entry's focus frame until the test advances it.
288 using _timers = fakeTimers();
289 await act(() =>
290 router.navigate({
291 to: "/$environmentId/$threadId",
292 params: { environmentId: ENVIRONMENT_ID, threadId: second.id },
293 }),
294 );
295 await expect.element(page.getByText(`Answer 0 for ${second.id}.`)).toBeVisible();
296 vi.advanceTimersToNextFrame();
297 // Focusing the editor raises the on-screen keyboard, which shrinks the page on iPadOS.
298 expect(document.activeElement).not.toBe(composer.element());
299​
300 // ChatView waits two frames after window focus before refocusing the composer.
301 window.dispatchEvent(new FocusEvent("focus"));
302 vi.advanceTimersToNextFrame();
303 vi.advanceTimersToNextFrame();
304 expect(document.activeElement).not.toBe(composer.element());
305});