1// Render scenarios that mount the whole app (AppRoot with the app's router) on a thread,
2// against the in-process environment in fakeEnvironment.ts. Counts cover ChatView, the
3// chat and settings routes, the sidebar and the right panel, through the same stores and
4// atoms the app reads. See renderScenarios.renders.tsx for the counting rules.
5import { createMemoryHistory } from "@tanstack/react-router";
6import { scopeThreadRef } from "@t3tools/client-runtime/environment";
7import type { ThreadId } from "@t3tools/contracts";
8import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings";
9import type * as Context from "effect/Context";
10import { AsyncResult } from "effect/reactivity";
11import { expect, it, vi } from "vite-plus/test";
12import { commands, page, userEvent } from "vite-plus/test/browser";
13
14import "~/index.css";
15
16import { AppRoot } from "~/AppRoot";
17import { __setClientSettingsForTests } from "~/hooks/useSettings";
18import { useRightPanelStore } from "~/rightPanelStore";
19import { getRouter, type AppRouter } from "~/router";
20import { appAtomRegistry } from "~/rpc/atomRegistry";
21import { vcsEnvironment } from "~/state/vcs";
22import {
23 ENVIRONMENT_ID,
24 assistantItem,
25 emitTerminalMetadata,
26 emitThreadEvent,
27 emitVcsStatus,
28 installFakeEnvironment,
29 isThreadSubscribed,
30 runningRun,
31 threadWithHistory,
32 userItem,
33 type FakeDriver,
34} from "./fakeEnvironment";
35import { commitsSoFar, startCounting, stopCounting, type RenderReport } from "./renderCounter";
36import { act, fakeTimers, render } from "./render";
37
38// The socket is the only fake layer: client-runtime's driver hands every connection the
39// in-process session. A string path, since a typed import pulls client-runtime's sources
40// into this project, and imports inside, since the factory runs before this file's own.
41vi.mock("../../../packages/client-runtime/src/connection/driver.ts", async (importOriginal) => {
42 const [actual, { fakeDriver }, Layer] = await Promise.all([
43 importOriginal<{ readonly ConnectionDriver: Context.Key<unknown, FakeDriver> }>(),
44 import("./fakeEnvironment"),
45 import("effect/Layer"),
46 ]);
47 return { ...actual, layer: Layer.succeed(actual.ConnectionDriver, fakeDriver) };
48});
49
50// The fake server leaves most requests unanswered, which would raise the slow-request toast
51// 15s of wall clock after each one, at a different point in every run.
52vi.mock(import("~/rpc/requestLatencyState"), async (original) => ({
53 ...(await original()),
54 trackRpcRequestSent: () => {},
55}));
56
57const GIT_STATUS_UPDATES = 20;
58const HISTORY_TURNS = 20;
59const STREAM_DELTAS = 30;
60const TYPED_TEXT = "fix the flaky tests!";
61const TERMINAL_UPDATES = 20;
62// The stream scenario leaves a turn on its thread, so it streams into one no other scenario opens.
63const THREADS = ["thread-warm", "thread-a", "thread-b", "thread-live"].map((name) =>
64 threadWithHistory(name, HISTORY_TURNS),
65);
66const [WARM, A, B, LIVE] = THREADS.map((thread) => thread.id) as [
67 ThreadId,
68 ThreadId,
69 ThreadId,
70 ThreadId,
71];
72
73const SIDE_CHAT = { ...threadWithHistory("thread-side", HISTORY_TURNS), sideChatOf: WARM };
74
75installFakeEnvironment([...THREADS, SIDE_CHAT]);
76// Desktop-sized, so the sidebar, composer and right panel lay out as they do in the app.
77await page.viewport(1400, 900);
78// A returning user: the first-run gate would otherwise wait on onboarding evidence.
79__setClientSettingsForTests({
80 ...DEFAULT_CLIENT_SETTINGS,
81 onboardingCompletedAt: "2026-01-01T00:00:00.000Z",
82});
83
84async function quiet() {
85 let seen = -1;
86 await vi.waitFor(
87 () => {
88 const now = commitsSoFar();
89 const settled = now === seen;
90 seen = now;
91 expect(settled).toBe(true);
92 },
93 { interval: 100, timeout: 15_000 },
94 );
95}
96
97// Relative times and durations re-render on minute and second ticks of the wall clock, so a
98// window that spans a tick counts extra renders. Only Date is frozen; timers run for real.
99const frozenClock = () =>
100 fakeTimers({ toFake: ["Date"], now: new Date("2026-03-18T09:00:30.000Z") });
101
102const threadPath = (threadId: ThreadId) => `/${ENVIRONMENT_ID}/${threadId}`;
103const lastAnswer = (threadId: ThreadId) =>
104 page.getByText(`Answer ${HISTORY_TURNS - 1} for ${threadId}.`);
105
106// The app owns its router, as main.tsx does, so render() adds none.
107async function mountApp(path: string) {
108 const router = getRouter(createMemoryHistory({ initialEntries: [path] }));
109 await router.load();
110 const container = document.createElement("div");
111 container.style.cssText = "height: 900px; width: 1400px;";
112 const screen = await render(<AppRoot router={router} />, { container, router: false });
113 return { screen, router };
114}
115
116async function openThread(threadId: ThreadId) {
117 const app = await mountApp(threadPath(threadId));
118 await expect.element(lastAnswer(threadId)).toBeVisible();
119 await quiet();
120 return app;
121}
122
123async function measure(action: () => Promise<void>): Promise<RenderReport> {
124 await quiet();
125 startCounting();
126 await action();
127 await quiet();
128 return stopCounting();
129}
130
131// Commits are left out across several windows, and where frame batching splits a window's
132// commits differently from run to run while its renders stay identical.
133async function report(
134 scenario: string,
135 windows: ReadonlyArray<RenderReport>,
136 { commits: countCommits = windows.length === 1 } = {},
137) {
138 const components: Record<string, number> = {};
139 for (const window of windows) {
140 for (const [name, count] of Object.entries(window.components)) {
141 components[name] = (components[name] ?? 0) + count;
142 }
143 }
144 const total = windows.reduce((sum, window) => sum + window.renders, 0);
145 const commits = countCommits ? windows[0]!.commits : null;
146 await commands.writeRenderReport(
147 scenario,
148 JSON.stringify({ commits, renders: total, components }),
149 );
150}
151
152// A first mount loads lazy route chunks, fonts and the connection at varying times. The diff
153// panel and settings load more chunks on first open (the T3 Connect sidebar, the terminal
154// preview's renderer), which can land after a window has gone quiet, so the warm-up opens
155// each once and lets it settle.
156it("warm up", async () => {
157 using _clock = frozenClock();
158 const { screen, router } = await openThread(WARM);
159 await using _app = screen;
160 await document.fonts.ready;
161 await quiet();
162 await toggleDiff("open")();
163 await quiet();
164 await toggleDiff("close")();
165 await quiet();
166 await openSettings(router, "/settings/general")();
167 await openSettings(router, "/settings/appearance")();
168 await quiet();
169});
170
171it("app-mount-thread", async () => {
172 using _clock = frozenClock();
173 startCounting();
174 await using _app = (await mountApp(threadPath(A))).screen;
175 await expect.element(lastAnswer(A)).toBeVisible();
176 await quiet();
177 await report("app-mount-thread", [stopCounting()]);
178});
179
180it("app-stream-assistant-turn", async () => {
181 using _clock = frozenClock();
182 await using _app = (await openThread(LIVE)).screen;
183 expect(isThreadSubscribed(LIVE)).toBe(true);
184 const live = runningRun(LIVE, "live-run", HISTORY_TURNS + 1, "live-user");
185 await act(async () => {
186 await emitThreadEvent(LIVE, { type: "run.created", payload: live });
187 await emitThreadEvent(LIVE, {
188 type: "turn-item.updated",
189 payload: userItem(LIVE, live.id, "live-user", HISTORY_TURNS * 2, "Continue"),
190 });
191 });
192 await expect.element(page.getByText("Continue", { exact: true })).toBeVisible();
193 const counted = await measure(async () => {
194 let text = "";
195 for (let delta = 0; delta < STREAM_DELTAS; delta++) {
196 text += `word${delta}\n\n`;
197 await act(() =>
198 emitThreadEvent(LIVE, {
199 type: "turn-item.updated",
200 payload: assistantItem(
201 LIVE,
202 live.id,
203 "live-assistant",
204 HISTORY_TURNS * 2 + 1,
205 text,
206 true,
207 ),
208 }),
209 );
210 }
211 await expect
212 .element(page.getByText(`word${STREAM_DELTAS - 1}`, { exact: false }))
213 .toBeVisible();
214 });
215 // Only the streaming row changes. The timeline renders for the new item and its layout, not
216 // per delta, and history rows not at all.
217 expect(counted.components.ConversationTimeline).toBeLessThanOrEqual(3);
218 expect(counted.components.UserTimelineRow).toBeUndefined();
219 expect(counted.components.AssistantTimelineRow).toBe(STREAM_DELTAS);
220 // Each delta adds a paragraph; finished blocks keep their render.
221 expect(counted.components.MarkdownParagraph).toBe(STREAM_DELTAS);
222 await report("app-stream-assistant-turn", [counted], { commits: false });
223});
224
225it("app-terminal-updates-in-other-thread", async () => {
226 using _clock = frozenClock();
227 await using _app = (await openThread(A)).screen;
228 const counted = await measure(async () => {
229 for (let update = 0; update < TERMINAL_UPDATES; update++) {
230 await act(() =>
231 emitTerminalMetadata({
232 type: "upsert",
233 terminal: {
234 threadId: B,
235 terminalId: "default",
236 cwd: "/repo",
237 worktreePath: null,
238 status: "running",
239 pid: 42,
240 exitCode: null,
241 exitSignal: null,
242 hasRunningSubprocess: true,
243 label: `command ${update}`,
244 updatedAt: `2026-03-18T09:01:${String(update).padStart(2, "0")}.000Z`,
245 },
246 }),
247 );
248 }
249 });
250 // Only thread B's sidebar row shows these updates.
251 expect(counted.components.ChatView).toBeUndefined();
252 expect(counted.components.SidebarThreadRow).toBe(TERMINAL_UPDATES);
253 await report("app-terminal-updates-in-other-thread", [counted]);
254});
255
256it("app-type-in-composer", async () => {
257 using _clock = frozenClock();
258 await using _app = (await openThread(A)).screen;
259 const editor = page.getByTestId("composer-editor");
260 await userEvent.click(editor);
261 const counted = await measure(async () => {
262 await userEvent.type(editor, TYPED_TEXT);
263 await expect.element(editor).toHaveTextContent(TYPED_TEXT);
264 });
265 await report("app-type-in-composer", [counted]);
266});
267
268it("app-switch-thread", async () => {
269 using _clock = frozenClock();
270 const { screen, router } = await openThread(A);
271 await using _app = screen;
272 const go = (threadId: ThreadId) => async () => {
273 await act(() =>
274 router.navigate({
275 to: "/$environmentId/$threadId",
276 params: { environmentId: ENVIRONMENT_ID, threadId },
277 }),
278 );
279 await expect.element(lastAnswer(threadId)).toBeVisible();
280 };
281 const windows = [await measure(go(B)), await measure(go(A))];
282 // Only the rows losing and gaining the active highlight change.
283 for (const window of windows) expect(window.components.SidebarThreadRow).toBe(2);
284 await report("app-switch-thread", windows);
285});
286
287// Through the diff.toggle shortcut, the same handler as the header's diff button.
288const toggleDiff = (to: "open" | "close") => async () => {
289 await userEvent.keyboard("{Control>}d{/Control}");
290 const diff = page.getByRole("group", { name: "Diff layout" });
291 if (to === "open") await expect.element(diff).toBeVisible();
292 else await expect.element(diff).not.toBeInTheDocument();
293};
294
295it("app-toggle-diff-panel", async () => {
296 using _clock = frozenClock();
297 await using _app = (await openThread(A)).screen;
298 const open = await measure(toggleDiff("open"));
299 const close = await measure(toggleDiff("close"));
300 await report("app-toggle-diff-panel", [open, close]);
301});
302
303const openSettings =
304 (router: AppRouter, to: "/settings/general" | "/settings/appearance") => async () => {
305 await act(() => router.navigate({ to }));
306 // A section title from each page, since neither page has a heading that names it.
307 const name = to === "/settings/general" ? "Time format" : "Interface";
308 await expect.element(page.getByRole("heading", { name, exact: true })).toBeVisible();
309 // Appearance highlights its code font preview off-thread, then attaches it in a shadow root.
310 if (to === "/settings/appearance") {
311 await vi.waitFor(
312 () =>
313 expect([...document.querySelectorAll("div")].some((node) => node.shadowRoot)).toBe(true),
314 { interval: 100, timeout: 15_000 },
315 );
316 }
317 };
318
319it("app-open-settings", async () => {
320 using _clock = frozenClock();
321 const { screen, router } = await openThread(A);
322 await using _app = screen;
323 await report("app-open-settings", [await measure(openSettings(router, "/settings/general"))]);
324});
325
326it("app-switch-settings-section", async () => {
327 using _clock = frozenClock();
328 const { screen, router } = await openThread(A);
329 await using _app = screen;
330 await openSettings(router, "/settings/general")();
331 await report("app-switch-settings-section", [
332 await measure(openSettings(router, "/settings/appearance")),
333 ]);
334});
335
336// ChatView reads only a few status fields, so changing file counts should not reach it.
337// Keep git scenarios last: the status they leave cached adds renders to later scenarios.
338it("app-git-file-counts-with-side-chat", async () => {
339 using _clock = frozenClock();
340 useRightPanelStore.getState().openSideChat(scopeThreadRef(ENVIRONMENT_ID, WARM), SIDE_CHAT.id);
341 await using _app = (await openThread(WARM)).screen;
342 await expect.element(lastAnswer(SIDE_CHAT.id)).toBeVisible();
343 const gitStatus = vcsEnvironment.status({
344 environmentId: ENVIRONMENT_ID,
345 input: { cwd: "/workspace/render" },
346 });
347 const local = (insertions: number, refName = "main") => ({
348 isRepo: true,
349 hasPrimaryRemote: true,
350 isDefaultRef: true,
351 refName,
352 hasWorkingTreeChanges: true,
353 workingTree: {
354 files: [{ path: "src/app.ts", insertions, deletions: 0 }],
355 insertions,
356 deletions: 0,
357 },
358 });
359 await act(() => emitVcsStatus({ _tag: "snapshot", local: local(0), remote: null }));
360 const counted = await measure(async () => {
361 for (let update = 1; update <= GIT_STATUS_UPDATES; update++) {
362 await act(() => emitVcsStatus({ _tag: "localUpdated", local: local(update) }));
363 }
364 // Zero renders only counts if the last update arrived inside this window.
365 await vi.waitFor(() => {
366 const status = AsyncResult.getOrElse(appAtomRegistry.get(gitStatus), () => null);
367 expect(status?.workingTree.insertions).toBe(GIT_STATUS_UPDATES);
368 });
369 });
370 expect(counted.components.ChatView).toBeUndefined();
371 // A field the chat view does read still reaches both chats.
372 const branchSwitch = await measure(() =>
373 act(() => emitVcsStatus({ _tag: "localUpdated", local: local(0, "feature") })),
374 );
375 expect(branchSwitch.components.ChatView).toBe(2);
376 await report("app-git-file-counts-with-side-chat", [counted]);
377});
378
379// ChatView re-renders when the tree goes dirty or clean; the composer and side chat timeline should not.
380it("app-git-status-updates-with-side-chat", async () => {
381 using _clock = frozenClock();
382 useRightPanelStore.getState().openSideChat(scopeThreadRef(ENVIRONMENT_ID, WARM), SIDE_CHAT.id);
383 await using _app = (await openThread(WARM)).screen;
384 await expect.element(lastAnswer(SIDE_CHAT.id)).toBeVisible();
385 const local = (insertions: number) => ({
386 isRepo: true,
387 hasPrimaryRemote: true,
388 isDefaultRef: true,
389 refName: "main",
390 hasWorkingTreeChanges: insertions % 2 === 1,
391 workingTree: {
392 files: [{ path: "src/app.ts", insertions, deletions: 0 }],
393 insertions,
394 deletions: 0,
395 },
396 });
397 await act(() => emitVcsStatus({ _tag: "snapshot", local: local(0), remote: null }));
398 const counted = await measure(async () => {
399 for (let update = 1; update <= GIT_STATUS_UPDATES; update++) {
400 await act(() => emitVcsStatus({ _tag: "localUpdated", local: local(update) }));
401 }
402 });
403 expect(counted.components.ChatView).toBe(GIT_STATUS_UPDATES * 2);
404 expect(counted.components.ChatComposer).toBeUndefined();
405 expect(counted.components.ConversationTimeline).toBeUndefined();
406 await report("app-git-status-updates-with-side-chat", [counted]);
407});