返回 DeepSeek-Reasonix
workspace-changes-errors.test.tsx
根目录 / desktop / frontend / src / __tests__ / workspace-changes-errors.test.tsx
1 // Run: tsx src/__tests__/workspace-changes-errors.test.tsx
2
3 import { JSDOM } from "jsdom";
4 import { registerHooks } from "node:module";
5 import React from "react";
6 import { act } from "react";
7 import { createRoot } from "react-dom/client";
8 import { workspaceFileIcon } from "../components/WorkspaceFileIcon";
9 import { WorkspacePanel } from "../components/WorkspacePanel";
10 import { LocaleProvider } from "../lib/i18n";
11 import { resetWorkspaceTreeMemoryForTests } from "../lib/workspaceTreeMemory";
12 import type { AppBindings } from "../lib/bridge";
13 import type { DirEntry, GitCommitView, WorkspaceChangeDetailView, WorkspaceChangesView } from "../lib/types";
14
15 // Markdown previews lazy-load MarkdownRenderer, whose KaTeX stylesheet belongs
16 // to the same production chunk. Node's tsx loader has no CSS module support,
17 // so map stylesheet imports to the existing empty asset stub in this DOM test.
18 registerHooks({
19 resolve(specifier, context, nextResolve) {
20 if (specifier.endsWith(".css")) {
21 return nextResolve("./asset-stub-for-tests.ts", { ...context, parentURL: import.meta.url });
22 }
23 return nextResolve(specifier, context);
24 },
25 });
26
27 let passed = 0;
28 let failed = 0;
29
30 function ok(value: boolean, label: string) {
31 if (value) {
32 process.stdout.write(` PASS ${label}\n`);
33 passed += 1;
34 } else {
35 process.stdout.write(` FAIL ${label}\n`);
36 failed += 1;
37 }
38 }
39
40 function flushPromises(): Promise<void> {
41 return new Promise((resolve) => setTimeout(resolve, 0));
42 }
43
44 async function waitFor(label: string, predicate: () => boolean) {
45 for (let attempt = 0; attempt < 20; attempt += 1) {
46 await act(async () => {
47 await flushPromises();
48 });
49 if (predicate()) return;
50 }
51 throw new Error(`timed out waiting for ${label}`);
52 }
53
54 class TestResizeObserver {
55 observe() {}
56 unobserve() {}
57 disconnect() {}
58 }
59
60 function installDom() {
61 const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
62 pretendToBeVisual: true,
63 url: "http://localhost/",
64 });
65 (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
66 globalThis.window = dom.window as unknown as Window & typeof globalThis;
67 globalThis.document = dom.window.document;
68 Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
69 globalThis.Node = dom.window.Node;
70 globalThis.Element = dom.window.Element;
71 globalThis.HTMLElement = dom.window.HTMLElement;
72 globalThis.Event = dom.window.Event;
73 globalThis.CustomEvent = dom.window.CustomEvent;
74 globalThis.KeyboardEvent = dom.window.KeyboardEvent;
75 globalThis.MouseEvent = dom.window.MouseEvent;
76 globalThis.PointerEvent = dom.window.MouseEvent as unknown as typeof PointerEvent;
77 globalThis.MutationObserver = dom.window.MutationObserver;
78 globalThis.ResizeObserver = TestResizeObserver;
79 dom.window.ResizeObserver = TestResizeObserver;
80 globalThis.localStorage = dom.window.localStorage;
81 globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
82 globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
83 (dom.window.HTMLElement.prototype as unknown as { attachEvent: () => void }).attachEvent = () => {};
84 (dom.window.HTMLElement.prototype as unknown as { detachEvent: () => void }).detachEvent = () => {};
85 Object.defineProperty(dom.window.HTMLElement.prototype, "scrollIntoView", { configurable: true, value: () => {} });
86 Object.defineProperty(dom.window.HTMLElement.prototype, "offsetWidth", {
87 configurable: true,
88 get: () => 320,
89 });
90 Object.defineProperty(dom.window.HTMLElement.prototype, "offsetHeight", {
91 configurable: true,
92 get: function offsetHeight(this: HTMLElement) {
93 return this.classList.contains("workspace-tree") ? 300 : this.dataset.index ? 24 : 0;
94 },
95 });
96 Object.defineProperty(dom.window.HTMLElement.prototype, "getBoundingClientRect", {
97 configurable: true,
98 value: function getBoundingClientRect(this: HTMLElement) {
99 const width = 320;
100 const height = this.classList.contains("workspace-tree") ? 300 : this.dataset.index ? 24 : 0;
101 return {
102 x: 0,
103 y: 0,
104 top: 0,
105 left: 0,
106 right: width,
107 bottom: height,
108 width,
109 height,
110 toJSON: () => ({}),
111 } as DOMRect;
112 },
113 });
114 return dom;
115 }
116
117 async function renderWorkspace(
118 changes: WorkspaceChangesView,
119 options: { creationMode?: boolean; history?: GitCommitView[]; detail?: WorkspaceChangeDetailView } = {},
120 ) {
121 resetWorkspaceTreeMemoryForTests();
122 const dom = installDom();
123 window.go = {
124 main: {
125 App: {
126 ListDirForTab: async () => [],
127 WorkspaceGitHistory: async () => options.history ?? [],
128 WorkspaceChanges: async () => changes,
129 WorkspaceChangeDetail: async () => options.detail ?? {},
130 ReadFileForTab: async (_tabID, path) => ({ path, body: "", size: 0, truncated: false, binary: false }),
131 } as Partial<AppBindings> as AppBindings,
132 },
133 };
134 const rootEl = document.getElementById("root");
135 if (!rootEl) throw new Error("missing root");
136 const root = createRoot(rootEl);
137 await act(async () => {
138 root.render(
139 <LocaleProvider>
140 <WorkspacePanel
141 open
142 tabId="tab-a"
143 cwd="/repo"
144 maximized={false}
145 initialViewMode="changed"
146 creationMode={options.creationMode}
147 onClose={() => {}}
148 onToggleMaximized={() => {}}
149 />
150 </LocaleProvider>,
151 );
152 await flushPromises();
153 });
154 await waitFor("workspace changes", () => Boolean(document.querySelector(".workspace-preview__body")));
155 return { dom, root };
156 }
157
158 async function renderFilesWorkspace(methods: Partial<AppBindings>, props: Partial<Parameters<typeof WorkspacePanel>[0]> = {}) {
159 resetWorkspaceTreeMemoryForTests();
160 const dom = installDom();
161 window.go = {
162 main: {
163 App: {
164 ListDirForTab: async () => [],
165 SearchFileRefsForTab: async () => [],
166 WorkspaceGitHistory: async () => [],
167 WorkspaceChanges: async () => ({ files: [], gitAvailable: true }),
168 WorkspaceChangeDetail: async () => ({}),
169 ReadFileForTab: async (_tabID, path) => ({ path, body: "", size: 0, truncated: false, binary: false }),
170 ...methods,
171 } as Partial<AppBindings> as AppBindings,
172 },
173 };
174 const rootEl = document.getElementById("root");
175 if (!rootEl) throw new Error("missing root");
176 const root = createRoot(rootEl);
177 let currentProps: Parameters<typeof WorkspacePanel>[0] = {
178 open: true,
179 tabId: "tab-a",
180 cwd: "/repo",
181 maximized: false,
182 initialViewMode: "files",
183 onClose: () => {},
184 onToggleMaximized: () => {},
185 ...props,
186 };
187 const rerender = async (nextProps: Partial<Parameters<typeof WorkspacePanel>[0]> = {}) => {
188 currentProps = { ...currentProps, ...nextProps };
189 await act(async () => {
190 root.render(
191 <LocaleProvider>
192 <WorkspacePanel {...currentProps} />
193 </LocaleProvider>,
194 );
195 await flushPromises();
196 });
197 };
198 await rerender();
199 return { dom, root, rerender };
200 }
201
202 console.log("\nworkspace changes git errors");
203
204 {
205 const { dom, root, rerender } = await renderFilesWorkspace({}, { open: false });
206 let threw = false;
207 try {
208 await rerender({ open: true });
209 } catch (error) {
210 threw = true;
211 process.stdout.write(` ERROR ${String(error)}\n`);
212 }
213 ok(!threw, "workspace panel can open after a closed render without changing hook order");
214 ok(document.querySelector(".workspace-panel") !== null, "workspace panel renders after the closed-to-open transition");
215 await act(async () => {
216 root.unmount();
217 });
218 dom.window.close();
219 }
220
221 {
222 const { dom, root } = await renderWorkspace({ files: [], gitAvailable: false });
223 await waitFor("git unavailable warning", () => document.body.textContent?.includes("Git status is unavailable for this workspace.") === true);
224 ok(document.body.textContent?.includes("Git status is unavailable for this workspace.") === true, "gitAvailable=false renders a warning");
225 ok(document.body.textContent?.includes("No changed files") === false, "gitAvailable=false is not shown as a clean workspace");
226 await act(async () => {
227 root.unmount();
228 });
229 dom.window.close();
230 }
231
232 {
233 const { dom, root } = await renderWorkspace({
234 files: [],
235 gitAvailable: true,
236 gitErr: "git status timed out",
237 });
238 await waitFor("git error warning without files", () => document.body.textContent?.includes("Git status is unavailable for this workspace.") === true);
239 ok(document.body.textContent?.includes("Git status is unavailable for this workspace.") === true, "gitErr without files renders a warning");
240 ok(document.body.textContent?.includes("No changed files") === false, "empty files plus gitErr is not shown as a clean workspace");
241 await act(async () => {
242 root.unmount();
243 });
244 dom.window.close();
245 }
246
247 {
248 const { dom, root } = await renderWorkspace({
249 files: [
250 {
251 path: "src/app.ts",
252 sources: ["session"],
253 gitStatus: "modified",
254 latestPrompt: "edit app",
255 },
256 ],
257 gitAvailable: true,
258 gitErr: "git status timed out",
259 });
260 await waitFor("git error warning with files", () => document.body.textContent?.includes("app.ts") === true);
261 ok(document.body.textContent?.includes("Git status is unavailable for this workspace.") === true, "gitErr renders a warning");
262 ok(document.body.textContent?.includes("app.ts") === true, "files still render when gitErr is present");
263 await act(async () => {
264 root.unmount();
265 });
266 dom.window.close();
267 }
268
269 {
270 const { dom, root } = await renderWorkspace(
271 {
272 files: [
273 { path: "src/session.ts", sources: ["session"], gitStatus: "M", latestPrompt: "edit session file" },
274 { path: "README.md", sources: ["git"], gitStatus: "M" },
275 ],
276 gitAvailable: true,
277 },
278 {
279 creationMode: true,
280 history: [{ hash: "1234567890", author: "Agent", date: "2026-07-10T12:00:00Z", message: "older commit" }],
281 },
282 );
283 await waitFor("creation changes sections", () => document.body.textContent?.includes("Session changes") === true);
284 ok(document.body.textContent?.includes("Session changes") === true, "Creation changes prioritizes session files");
285 ok(document.body.textContent?.includes("Uncommitted workspace changes") === true, "Creation changes keeps git-only files separate");
286 ok(document.body.textContent?.includes("Commit history") === true, "Creation changes exposes commit history as a secondary section");
287 ok(document.body.textContent?.includes("older commit") === false, "Creation commit history starts collapsed");
288 const historyToggle = document.querySelector<HTMLButtonElement>(".workspace-commit-history__toggle");
289 await act(async () => {
290 historyToggle?.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
291 await flushPromises();
292 });
293 await waitFor("expanded creation commit history", () => document.body.textContent?.includes("older commit") === true);
294 ok(document.body.textContent?.includes("older commit") === true, "Creation commit history expands on demand");
295 await act(async () => {
296 root.unmount();
297 });
298 dom.window.close();
299 }
300
301 {
302 const { dom, root } = await renderWorkspace(
303 {
304 files: [{ path: "src/current.ts", sources: ["git"], gitStatus: "M" }],
305 gitAvailable: true,
306 },
307 {
308 history: [{ hash: "abcdef123456", author: "Agent", date: "2026-07-20T12:00:00Z", message: "historical commit" }],
309 detail: {
310 source: "git",
311 added: 2,
312 removed: 1,
313 diff: "diff --git a/src/current.ts b/src/current.ts\n--- a/src/current.ts\n+++ b/src/current.ts\n@@ -10,2 +10,3 @@\n-old value\n+new value\n context\n+another value",
314 },
315 },
316 );
317 await waitFor("git-only working change", () => document.body.textContent?.includes("current.ts") === true);
318 ok(document.body.textContent?.includes("No changed files") === false, "git-only working changes are not reported as a clean workspace");
319 const changeButton = document.querySelector<HTMLButtonElement>(".workspace-change");
320 await act(async () => {
321 changeButton?.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
322 await flushPromises();
323 });
324 await waitFor("current semantic diff", () => document.body.textContent?.includes("new value") === true);
325 ok(document.body.textContent?.includes("Current changes") === true, "selected working file shows the current patch before history");
326 ok(document.body.textContent?.includes("+2") === true && document.body.textContent?.includes("-1") === true, "current patch shows added and removed line totals");
327 ok(document.body.textContent?.includes("historical commit") === false, "file commit history starts collapsed");
328 const historyToggle = document.querySelector<HTMLButtonElement>(".workspace-commit-history__toggle");
329 await act(async () => {
330 historyToggle?.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
331 await flushPromises();
332 });
333 await waitFor("file commit history", () => document.body.textContent?.includes("historical commit") === true);
334 ok(document.body.textContent?.includes("historical commit") === true, "file commit history remains available on demand");
335 await act(async () => {
336 root.unmount();
337 });
338 dom.window.close();
339 }
340
341 {
342 const { dom, root } = await renderWorkspace(
343 {
344 files: [{ path: "generated/large.txt", sources: ["git"], gitStatus: "M" }],
345 gitAvailable: true,
346 },
347 { detail: { source: "git", truncated: true } },
348 );
349 await waitFor("large working change", () => document.body.textContent?.includes("large.txt") === true);
350 const changeButton = document.querySelector<HTMLButtonElement>(".workspace-change");
351 await act(async () => {
352 changeButton?.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
353 await flushPromises();
354 });
355 await waitFor("bounded change detail", () => document.body.textContent?.includes("too large to display") === true);
356 ok(document.body.textContent?.includes("too large to display") === true, "oversized workspace diffs render a bounded-state message");
357 ok(document.body.textContent?.includes("no text diff") === false, "oversized workspace diffs are not reported as empty");
358 await act(async () => {
359 root.unmount();
360 });
361 dom.window.close();
362 }
363
364 {
365 const { dom, root } = await renderFilesWorkspace({
366 ListDirForTab: async (_tabId, dir) => {
367 if (dir === "") {
368 return [
369 { name: "src", isDir: true },
370 { name: "tail-a.ts", isDir: false },
371 { name: "tail-b.ts", isDir: false },
372 ];
373 }
374 if (dir === "src/") {
375 return [
376 { name: "child-a.ts", isDir: false },
377 { name: "child-b.ts", isDir: false },
378 ];
379 }
380 return [];
381 },
382 });
383
384 const positionedRows = () =>
385 Array.from(document.querySelectorAll<HTMLElement>(".workspace-tree__sizer > div")).map((wrapper) => ({
386 path: wrapper.querySelector<HTMLElement>("[data-workspace-path]")?.dataset.workspacePath ?? "",
387 transform: wrapper.style.transform,
388 }));
389 const positionsAreUnique = (paths: string[]) => {
390 const rows = positionedRows().filter((row) => paths.includes(row.path));
391 return rows.length === paths.length && new Set(rows.map((row) => row.transform)).size === paths.length;
392 };
393
394 const collapsedPaths = ["src/", "tail-a.ts", "tail-b.ts"];
395 await waitFor("initial positioned workspace rows", () => positionsAreUnique(collapsedPaths));
396
397 const toggleSrc = () => document.querySelector<HTMLButtonElement>('[data-workspace-path="src/"]');
398 await act(async () => {
399 toggleSrc()?.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
400 await flushPromises();
401 });
402 const expandedPaths = ["src/", "src/child-a.ts", "src/child-b.ts", "tail-a.ts", "tail-b.ts"];
403 await waitFor("expanded workspace rows", () => document.body.textContent?.includes("child-b.ts") === true);
404 ok(positionsAreUnique(expandedPaths), "expanded workspace rows keep unique virtual positions");
405
406 await act(async () => {
407 toggleSrc()?.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
408 await flushPromises();
409 });
410 await waitFor("collapsed workspace rows", () => document.body.textContent?.includes("child-a.ts") === false);
411 ok(positionsAreUnique(collapsedPaths), "collapsed workspace rows keep unique virtual positions");
412
413 await act(async () => {
414 toggleSrc()?.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
415 await flushPromises();
416 });
417 await waitFor("re-expanded workspace rows", () => document.body.textContent?.includes("child-b.ts") === true);
418 ok(positionsAreUnique(expandedPaths), "re-expanded workspace rows keep unique virtual positions");
419
420 await act(async () => {
421 root.unmount();
422 });
423 dom.window.close();
424 }
425
426 {
427 const calls: string[] = [];
428 const listDirForTab = async (tabId: string, dir: string): Promise<DirEntry[]> => {
429 calls.push(`${tabId}:${dir}`);
430 return [];
431 };
432 const { dom, root, rerender } = await renderFilesWorkspace(
433 { ListDirForTab: listDirForTab },
434 { fileListRequest: { id: 1, paths: ["src/app.ts"] } },
435 );
436
437 await waitFor("initial referenced file dirs", () => calls.filter((call) => call === "tab-a:src/").length === 1);
438 await rerender({ fileListRequest: { id: 2, paths: ["src/app.ts"] } });
439 await waitFor("referenced file dirs revalidated", () => calls.filter((call) => call === "tab-a:src/").length === 2);
440
441 ok(calls.filter((call) => call === "tab-a:src/").length === 2, "workspace file tree revalidates cached directories for repeated file-list requests");
442
443 await act(async () => {
444 root.unmount();
445 });
446 dom.window.close();
447 }
448
449 {
450 const pending: Array<{ tabId: string; resolve: (entries: DirEntry[]) => void }> = [];
451 const listDirForTab = (tabId: string, dir: string): Promise<DirEntry[]> => {
452 if (dir !== "") return Promise.resolve([]);
453 return new Promise((resolve) => pending.push({ tabId, resolve }));
454 };
455 const { dom, root, rerender } = await renderFilesWorkspace(
456 { ListDirForTab: listDirForTab },
457 { tabId: "parent-tab", cwd: "/repo" },
458 );
459
460 await waitFor("parent workspace request", () => pending.some((request) => request.tabId === "parent-tab"));
461 await rerender({ tabId: "child-tab", cwd: "/repo/child" });
462 await waitFor("child workspace request", () => pending.some((request) => request.tabId === "child-tab"));
463
464 await act(async () => {
465 pending.filter((request) => request.tabId === "child-tab").forEach((request) => request.resolve([
466 { name: "child-a.txt", isDir: false },
467 { name: "child-b.txt", isDir: false },
468 ]));
469 await flushPromises();
470 });
471 await waitFor("child workspace entries", () => (document.querySelector(".workspace-tree__sizer") as HTMLElement | null)?.style.height === "48px");
472
473 await act(async () => {
474 pending.filter((request) => request.tabId === "parent-tab").forEach((request) => request.resolve([{ name: "parent-only.txt", isDir: false }]));
475 await flushPromises();
476 });
477
478 ok((document.querySelector(".workspace-tree__sizer") as HTMLElement | null)?.style.height === "48px", "late parent workspace response cannot overwrite the two-row child tree");
479
480 await act(async () => {
481 root.unmount();
482 });
483 dom.window.close();
484 }
485
486 {
487 const pending: Array<(entries: DirEntry[]) => void> = [];
488 const listDirForTab = (_tabId: string, dir: string): Promise<DirEntry[]> => {
489 if (dir !== "") return Promise.resolve([]);
490 return new Promise((resolve) => pending.push(resolve));
491 };
492 const { dom, root, rerender } = await renderFilesWorkspace(
493 { ListDirForTab: listDirForTab },
494 { tabId: "shared-tab", cwd: "/repo", workspaceScopeKey: "session-a" },
495 );
496
497 await waitFor("initial session A workspace request", () => pending.length === 1);
498 await rerender({ workspaceScopeKey: "session-b" });
499 await waitFor("session B workspace request", () => pending.length === 2);
500 await rerender({ workspaceScopeKey: "session-a" });
501 await waitFor("revisited session A workspace request", () => pending.length === 3);
502
503 await act(async () => {
504 pending[2]([
505 { name: "current-a.txt", isDir: false },
506 { name: "current-b.txt", isDir: false },
507 ]);
508 await flushPromises();
509 });
510 await waitFor("revisited session A entries", () => (document.querySelector(".workspace-tree__sizer") as HTMLElement | null)?.style.height === "48px");
511
512 await act(async () => {
513 pending[0]([{ name: "stale-initial-a.txt", isDir: false }]);
514 pending[1]([{ name: "stale-b.txt", isDir: false }]);
515 await flushPromises();
516 });
517
518 ok(
519 (document.querySelector(".workspace-tree__sizer") as HTMLElement | null)?.style.height === "48px",
520 "same-tab A→B→A session switches reject stale workspace responses",
521 );
522
523 await act(async () => {
524 root.unmount();
525 });
526 dom.window.close();
527 }
528
529 {
530 const pending: Array<(changes: WorkspaceChangesView) => void> = [];
531 const workspaceChanges = (): Promise<WorkspaceChangesView> => new Promise((resolve) => pending.push(resolve));
532 const { dom, root, rerender } = await renderFilesWorkspace(
533 { WorkspaceChanges: workspaceChanges },
534 {
535 tabId: "shared-tab",
536 cwd: "/repo",
537 workspaceScopeKey: "session-a",
538 initialViewMode: "changed",
539 },
540 );
541
542 await waitFor("initial session changes request", () => pending.length === 1);
543 await rerender({ workspaceScopeKey: "session-b" });
544 await waitFor("next session changes request", () => pending.length === 2);
545
546 await act(async () => {
547 pending[1]({
548 files: [{ path: "session-b.ts", sources: ["session"] }],
549 gitAvailable: true,
550 });
551 await flushPromises();
552 });
553 await waitFor("session B changes", () => document.body.textContent?.includes("session-b.ts") === true);
554
555 await act(async () => {
556 pending[0]({
557 files: [{ path: "stale-session-a.ts", sources: ["session"] }],
558 gitAvailable: true,
559 });
560 await flushPromises();
561 });
562
563 ok(document.body.textContent?.includes("session-b.ts") === true, "current same-tab session changes stay visible");
564 ok(document.body.textContent?.includes("stale-session-a.ts") === false, "late same-tab session changes cannot overwrite the current session");
565
566 await act(async () => {
567 root.unmount();
568 });
569 dom.window.close();
570 }
571
572 {
573 const pending = new Map<string, (detail: WorkspaceChangeDetailView) => void>();
574 const workspaceChangeDetail = (_tabID: string, path: string): Promise<WorkspaceChangeDetailView> =>
575 new Promise((resolve) => pending.set(path, resolve));
576 const { dom, root, rerender } = await renderFilesWorkspace(
577 {
578 WorkspaceChanges: async () => ({
579 files: [
580 { path: "session-a.ts", sources: ["session"] },
581 { path: "session-b.ts", sources: ["session"] },
582 ],
583 gitAvailable: true,
584 }),
585 WorkspaceChangeDetail: workspaceChangeDetail,
586 },
587 {
588 tabId: "shared-tab",
589 cwd: "/repo",
590 workspaceScopeKey: "session-a",
591 initialViewMode: "changed",
592 changeRevealRequest: { id: 1, path: "session-a.ts" },
593 },
594 );
595
596 await waitFor("session A change detail request", () => pending.has("session-a.ts"));
597 await rerender({ workspaceScopeKey: "session-b", changeRevealRequest: { id: 2, path: "session-b.ts" } });
598 await waitFor("session B change detail request", () => pending.has("session-b.ts"));
599
600 await act(async () => {
601 pending.get("session-b.ts")?.({
602 source: "session",
603 added: 1,
604 diff: "--- a/session-b.ts\n+++ b/session-b.ts\n@@ -1 +1 @@\n-old-b\n+current-b",
605 });
606 await flushPromises();
607 });
608 await waitFor("current session B detail", () => document.body.textContent?.includes("current-b") === true);
609
610 await act(async () => {
611 pending.get("session-a.ts")?.({
612 source: "session",
613 added: 1,
614 diff: "--- a/session-a.ts\n+++ b/session-a.ts\n@@ -1 +1 @@\n-old-a\n+stale-a",
615 });
616 await flushPromises();
617 });
618 ok(document.body.textContent?.includes("current-b") === true, "same-tab session switch keeps the current change detail");
619 ok(document.body.textContent?.includes("stale-a") === false, "late change detail cannot overwrite the current session");
620
621 await act(async () => {
622 root.unmount();
623 });
624 dom.window.close();
625 }
626
627 {
628 // A keyboard tab switch fires no mousedown/scroll/Escape, so floating menus
629 // that captured the previous scope's text/paths must be discarded when the
630 // tab/scope changes — otherwise Add to Chat would route the old scope's
631 // selection into the newly active session.
632 const { dom, root, rerender } = await renderFilesWorkspace(
633 {
634 ListDirForTab: async () => [{ name: "app.ts", isDir: false }],
635 ReadFileForTab: async () => ({
636 path: "app.ts",
637 body: "const value = 1;",
638 size: 16,
639 truncated: false,
640 binary: false,
641 }),
642 },
643 { revealPathRequest: { id: 1, path: "app.ts" } },
644 );
645
646 await waitFor("code preview", () => document.body.textContent?.includes("const value = 1;") === true);
647 const previewBody = document.querySelector(".workspace-preview__body") as HTMLElement;
648 const textNode = document.createTreeWalker(previewBody, 4 /* NodeFilter.SHOW_TEXT */).nextNode();
649 if (!textNode) throw new Error("preview rendered no text node to select");
650 const range = document.createRange();
651 range.selectNodeContents(textNode);
652 const selection = document.getSelection();
653 selection?.removeAllRanges();
654 selection?.addRange(range);
655 await act(async () => {
656 previewBody.dispatchEvent(new window.MouseEvent("mouseup", { bubbles: true, clientX: 60, clientY: 60 }));
657 await flushPromises();
658 });
659 ok(document.querySelector(".floating-menu") != null, "selecting preview code pops the Add to Chat toolbar");
660 await rerender({ tabId: "tab-b" });
661 ok(document.querySelector(".floating-menu") == null, "a tab switch discards the selection toolbar");
662
663 const tree = document.querySelector(".workspace-tree") as HTMLElement;
664 await act(async () => {
665 tree.dispatchEvent(new window.MouseEvent("contextmenu", { bubbles: true, cancelable: true, clientX: 30, clientY: 200 }));
666 await flushPromises();
667 });
668 ok(document.querySelector(".context-menu") != null, "right-clicking blank tree space opens the tree menu");
669 await rerender({ workspaceScopeKey: "scope-b" });
670 ok(document.querySelector(".context-menu") == null, "a scope switch discards the tree menu");
671
672 await act(async () => {
673 root.unmount();
674 });
675 dom.window.close();
676 }
677
678 {
679 const { dom, root, rerender } = await renderFilesWorkspace(
680 {
681 ListDirForTab: async (_tabId, dir) => {
682 if (dir === "") {
683 return [
684 { name: "alpha", isDir: true },
685 { name: "beta", isDir: true },
686 ];
687 }
688 if (dir === "alpha/") {
689 return [
690 { name: "nested", isDir: true },
691 { name: "alpha.txt", isDir: false },
692 ];
693 }
694 if (dir === "alpha/nested/") return [{ name: "deep.ts", isDir: false }];
695 if (dir === "beta/") return [{ name: "beta.txt", isDir: false }];
696 return [];
697 },
698 },
699 {
700 workspaceScopeKey: "scope-a",
701 workspaceMemoryKey: "session-a",
702 workspaceMemoryVisitId: 1,
703 },
704 );
705
706 const clickPath = async (path: string) => {
707 const row = document.querySelector<HTMLButtonElement>(`[data-workspace-path="${path}"]`);
708 if (!row) throw new Error(`missing workspace row ${path}`);
709 await act(async () => {
710 row.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
711 await flushPromises();
712 });
713 };
714
715 await waitFor("session A roots", () => document.querySelector('[data-workspace-path="alpha/"]') != null);
716 await clickPath("alpha/");
717 await waitFor("session A nested directory", () => document.querySelector('[data-workspace-path="alpha/nested/"]') != null);
718 await clickPath("alpha/nested/");
719 await waitFor("session A deep file", () => document.querySelector('[data-workspace-path="alpha/nested/deep.ts"]') != null);
720 await clickPath("beta/");
721 await waitFor("session A beta file", () => document.querySelector('[data-workspace-path="beta/beta.txt"]') != null);
722
723 await rerender({ initialViewMode: "changed" });
724 await rerender({ initialViewMode: "files" });
725 await waitFor("same-session restored tree", () => document.querySelector('[data-workspace-path="alpha/nested/deep.ts"]') != null);
726 ok(
727 document.querySelector('[data-workspace-path="beta/beta.txt"]') != null,
728 "Files → Changes → Files preserves the exact expanded tree in one session",
729 );
730
731 await rerender({
732 workspaceScopeKey: "scope-b",
733 workspaceMemoryKey: "session-b",
734 workspaceMemoryVisitId: 2,
735 });
736 await waitFor("session B roots", () => document.querySelector('[data-workspace-path="alpha/"]') != null);
737 await rerender({
738 workspaceScopeKey: "scope-a-returned",
739 workspaceMemoryKey: "session-a",
740 workspaceMemoryVisitId: 3,
741 });
742 await waitFor("returned session A roots", () => document.querySelector('[data-workspace-path="alpha/"]') != null);
743 ok(
744 document.querySelector('[data-workspace-path="alpha/nested/"]') == null &&
745 document.querySelector('[data-workspace-path="beta/beta.txt"]') == null,
746 "returning to a session presents every remembered root collapsed",
747 );
748
749 await clickPath("alpha/");
750 await waitFor("restored alpha subtree", () => document.querySelector('[data-workspace-path="alpha/nested/deep.ts"]') != null);
751 ok(
752 document.querySelector('[data-workspace-path="beta/beta.txt"]') == null,
753 "opening one returned root restores only that root's remembered subtree",
754 );
755
756 await act(async () => {
757 root.unmount();
758 });
759 dom.window.close();
760 }
761
762 {
763 const { dom, root } = await renderFilesWorkspace({
764 ListDirForTab: async (_tabId, dir) => {
765 if (dir === "") return [{ name: "src", isDir: true }];
766 if (dir === "src/") return [{ name: "main", isDir: true }];
767 if (dir === "src/main/") return [{ name: "java", isDir: true }];
768 if (dir === "src/main/java/") return [{ name: "App.java", isDir: false }];
769 return [];
770 },
771 });
772
773 await waitFor("compacted directory chain", () =>
774 document.querySelector('[data-workspace-path="src/main/java/"]')?.textContent?.includes("src / main / java") === true,
775 );
776 ok(
777 document.querySelectorAll('[data-workspace-path="src/main/java/"]').length === 1 &&
778 document.querySelector('[data-workspace-path="src/"]') == null,
779 "single-child directory chains render as one compact folder row",
780 );
781
782 await act(async () => {
783 document
784 .querySelector<HTMLButtonElement>('[data-workspace-path="src/main/java/"]')
785 ?.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
786 await flushPromises();
787 });
788 await waitFor("compact directory child", () => document.querySelector('[data-workspace-path="src/main/java/App.java"]') != null);
789 ok(
790 document.querySelectorAll('[data-workspace-path="src/main/java/App.java"] .workspace-tree__guide').length === 1,
791 "nested file rows render one guide for each visible ancestor level",
792 );
793 ok(
794 document.querySelector('[data-workspace-path="src/main/java/App.java"] .workspace-file-icon')?.textContent !== "",
795 "workspace files render a Seti file-type icon",
796 );
797
798 await act(async () => {
799 root.unmount();
800 });
801 dom.window.close();
802 }
803
804 {
805 const { dom, root } = await renderFilesWorkspace({
806 ListDirForTab: async (_tabId, dir) => dir === ""
807 ? [
808 { name: "code.ts", isDir: false },
809 { name: "README.md", isDir: false },
810 ]
811 : [],
812 ReadFileForTab: async (_tabId, path) => ({
813 path,
814 body: path === "README.md" ? "# Documentation" : "const value = 42;",
815 size: 17,
816 truncated: false,
817 binary: false,
818 }),
819 });
820
821 await waitFor("searchable code file", () => document.querySelector('[data-workspace-path="code.ts"]') != null);
822 await act(async () => {
823 document
824 .querySelector<HTMLButtonElement>('[data-workspace-path="code.ts"]')
825 ?.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
826 await flushPromises();
827 });
828 await waitFor("code preview search action", () => document.querySelector('button[aria-label="Find"]') != null);
829 ok(
830 document.querySelector('button[aria-label="Find"]') != null,
831 "searchable code previews expose a visible search action",
832 );
833
834 const filterInput = document.querySelector<HTMLInputElement>('input[placeholder="Filter files…"]');
835 await act(async () => {
836 filterInput?.focus();
837 filterInput?.dispatchEvent(new window.KeyboardEvent("keydown", {
838 key: "f",
839 ctrlKey: true,
840 bubbles: true,
841 cancelable: true,
842 }));
843 await flushPromises();
844 });
845 await waitFor("panel-scoped code search", () => document.querySelector(".code-search__input") != null);
846 ok(
847 document.activeElement === document.querySelector(".code-search__input"),
848 "workspace find shortcut opens and focuses code search from the file filter",
849 );
850
851 await act(async () => {
852 document
853 .querySelector<HTMLButtonElement>(".code-search__close")
854 ?.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
855 await flushPromises();
856 });
857 await waitFor("closed code search", () => document.querySelector(".code-search") == null);
858 await act(async () => {
859 document
860 .querySelector<HTMLButtonElement>('button[aria-label="Find"]')
861 ?.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
862 await flushPromises();
863 });
864 await waitFor("button-opened code search", () => document.querySelector(".code-search__input") != null);
865 ok(
866 document.activeElement === document.querySelector(".code-search__input"),
867 "visible search action opens and focuses the same search UI",
868 );
869
870 await act(async () => {
871 document
872 .querySelector<HTMLButtonElement>('[data-workspace-path="README.md"]')
873 ?.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
874 await flushPromises();
875 });
876 await waitFor("markdown preview", () => document.body.textContent?.includes("Documentation") === true);
877 ok(
878 document.querySelector('button[aria-label="Find"]') == null,
879 "Markdown previews do not expose the code-search action",
880 );
881 const markdownFindEvent = new window.KeyboardEvent("keydown", {
882 key: "f",
883 ctrlKey: true,
884 bubbles: true,
885 cancelable: true,
886 });
887 filterInput?.dispatchEvent(markdownFindEvent);
888 ok(!markdownFindEvent.defaultPrevented, "Markdown previews preserve the host find shortcut");
889
890 await act(async () => {
891 root.unmount();
892 });
893 dom.window.close();
894 }
895
896 {
897 const javaIcon = workspaceFileIcon("App.java");
898 const markdownIcon = workspaceFileIcon("README.md");
899 const mavenIcon = workspaceFileIcon("pom.xml");
900 const xmlIcon = workspaceFileIcon("layout.xml");
901 ok(javaIcon.glyph !== "" && javaIcon.glyph !== markdownIcon.glyph, "Seti icons distinguish common file extensions");
902 ok(mavenIcon.glyph !== xmlIcon.glyph, "Seti exact-name mappings take precedence over generic extensions");
903 }
904
905 console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
906 if (failed > 0) process.exit(1);
907
907 lines Plain Text