1import {
2 AuthFilesystemReadScope,
3 EnvironmentId,
4 type AuthSessionState,
5 type ProjectListEntriesResult,
6 ProjectReadFileError,
7 type ProjectReadFileResult,
8} from "@t3tools/contracts";
9import * as Cause from "effect/Cause";
10import * as Effect from "effect/Effect";
11import * as Schema from "effect/Schema";
12import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity";
13import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
14
15const authorizationMocks = vi.hoisted(() => ({
16 sessionAtom: null as Atom.Atom<
17 AsyncResult.AsyncResult<Pick<AuthSessionState, "authenticated" | "scopes">, Error>
18 > | null,
19 phase: "connected" as "connected" | "offline",
20}));
21
22vi.mock("~/state/session", () => ({
23 environmentSession: { sessionStateAtom: () => authorizationMocks.sessionAtom },
24}));
25
26vi.mock("~/state/presentation", () => ({
27 useEnvironmentPresentation: () => ({
28 isReady: true,
29 presentation: { connection: { phase: authorizationMocks.phase, error: null } },
30 }),
31}));
32
33const projectMocks = vi.hoisted(() => ({
34 listEntries: vi.fn(),
35 optimisticFile: vi.fn(),
36 readFile: vi.fn(),
37}));
38
39const atomHooks = vi.hoisted(() => ({
40 registry: null as {
41 get(atom: object): unknown;
42 refresh(atom: object): void;
43 } | null,
44}));
45
46const reactHooks = vi.hoisted(() => {
47 let cursor = 0;
48 let refs: Array<{ current: unknown }> = [];
49 const nextIndex = () => cursor++;
50
51 return {
52 beginRender() {
53 cursor = 0;
54 },
55 reset() {
56 cursor = 0;
57 refs = [];
58 },
59 useCallback<A>(callback: A): A {
60 nextIndex();
61 return callback;
62 },
63 useEffect(effect: () => void): void {
64 nextIndex();
65 effect();
66 },
67 useRef<A>(initialValue: A): { current: A } {
68 const index = nextIndex();
69 refs[index] ??= { current: initialValue };
70 return refs[index] as { current: A };
71 },
72 };
73});
74
75vi.mock("@effect/atom-react", () => ({
76 useAtomRefresh: (atom: object) => () => {
77 atomHooks.registry?.refresh(atom);
78 },
79 useAtomValue: (atom: object) => atomHooks.registry?.get(atom),
80}));
81
82vi.mock("react", async (importOriginal) => {
83 const actual = await importOriginal<typeof import("react")>();
84 return {
85 ...actual,
86 useCallback: reactHooks.useCallback,
87 useEffect: reactHooks.useEffect,
88 useMemo: <A,>(factory: () => A) => factory(),
89 useRef: reactHooks.useRef,
90 };
91});
92
93vi.mock("~/state/projects", () => ({
94 projectEnvironment: projectMocks,
95}));
96
97vi.mock("~/state/queries", () => ({
98 useProjectPathSearch: vi.fn(),
99}));
100
101import { useWorkspaceMutationRefresh } from "~/hooks/useWorkspaceMutationRefresh";
102import { useT3ProjectFileState } from "~/hooks/useT3ProjectFileScripts";
103import { useProjectEntriesQuery, useProjectFileQuery } from "./projectFilesQueryState";
104
105const environmentId = EnvironmentId.make("environment-1");
106const decodeReadError = Schema.decodeSync(ProjectReadFileError);
107
108function deferred<A>() {
109 let resolve!: (value: A) => void;
110 const promise = new Promise<A>((complete) => {
111 resolve = complete;
112 });
113 return { promise, resolve };
114}
115
116function file(contents: string): ProjectReadFileResult {
117 return {
118 relativePath: "src/preview.ts",
119 contents,
120 byteLength: contents.length,
121 truncated: false,
122 };
123}
124
125function projectEntries(paths: readonly string[]): ProjectListEntriesResult {
126 return {
127 entries: paths.map((path) => ({ path, kind: "file" })),
128 truncated: false,
129 };
130}
131
132async function flushEffects(): Promise<void> {
133 await Promise.resolve();
134 await Promise.resolve();
135 await Promise.resolve();
136}
137
138describe("project query refresh", () => {
139 beforeEach(() => {
140 authorizationMocks.sessionAtom = Atom.make(
141 AsyncResult.success({ authenticated: true, scopes: [AuthFilesystemReadScope] }),
142 );
143 authorizationMocks.phase = "connected";
144 projectMocks.listEntries.mockReset();
145 projectMocks.optimisticFile.mockReset();
146 projectMocks.readFile.mockReset();
147 reactHooks.reset();
148 });
149
150 it("does not query or expose optimistic file contents without read permission", () => {
151 authorizationMocks.sessionAtom = Atom.make(
152 AsyncResult.success({ authenticated: true, scopes: [] }),
153 );
154 const registry = AtomRegistry.make();
155 atomHooks.registry = registry;
156 projectMocks.optimisticFile.mockReturnValue(Atom.make({ data: file("cached contents") }));
157 try {
158 const query = useProjectFileQuery(environmentId, "/repo", "src/preview.ts");
159 expect(projectMocks.readFile).not.toHaveBeenCalled();
160 expect(query.data).toBeNull();
161 expect(query.error).toBe("This connection cannot read host files.");
162 expect(query.isPending).toBe(false);
163 const entries = useProjectEntriesQuery(environmentId, "/repo");
164 expect(projectMocks.listEntries).not.toHaveBeenCalled();
165 expect(entries.data).toBeNull();
166 expect(entries.error).toBe("This connection cannot read host files.");
167 expect(entries.isPending).toBe(false);
168 } finally {
169 registry.dispose();
170 atomHooks.registry = null;
171 }
172 });
173
174 it("keeps t3.json and the file tree loading until the file grant arrives", () => {
175 authorizationMocks.sessionAtom = Atom.make(AsyncResult.initial());
176 const registry = AtomRegistry.make();
177 atomHooks.registry = registry;
178 const config = {
179 defaultThreadEnvMode: "worktree",
180 scripts: [{ name: "Test", command: "vp test" }],
181 };
182 projectMocks.readFile.mockReturnValue(
183 Atom.make(AsyncResult.success(file(JSON.stringify(config)))),
184 );
185 projectMocks.listEntries.mockReturnValue(
186 Atom.make(AsyncResult.success(projectEntries(["t3.json"]))),
187 );
188 projectMocks.optimisticFile.mockReturnValue(Atom.make(null));
189 try {
190 expect(useProjectFileQuery(environmentId, "/repo", "t3.json")).toMatchObject({
191 data: null,
192 error: null,
193 isPending: true,
194 });
195 expect(useProjectEntriesQuery(environmentId, "/repo")).toMatchObject({
196 data: null,
197 error: null,
198 isPending: true,
199 });
200 expect(useT3ProjectFileState(environmentId, "/repo").status).toBe("loading");
201 expect(projectMocks.readFile).not.toHaveBeenCalled();
202 expect(projectMocks.listEntries).not.toHaveBeenCalled();
203
204 authorizationMocks.sessionAtom = Atom.make(
205 AsyncResult.success({ authenticated: true, scopes: [AuthFilesystemReadScope] }),
206 );
207 expect(useT3ProjectFileState(environmentId, "/repo")).toEqual({
208 status: "valid",
209 file: config,
210 scripts: config.scripts,
211 });
212 expect(useProjectEntriesQuery(environmentId, "/repo")).toMatchObject({
213 data: projectEntries(["t3.json"]),
214 error: null,
215 isPending: false,
216 });
217 } finally {
218 registry.dispose();
219 atomHooks.registry = null;
220 }
221 });
222
223 it.each(["connected", "offline"] as const)(
224 "preserves cached t3.json defaults and scripts during a granted refresh while %s",
225 (phase) => {
226 authorizationMocks.phase = phase;
227 authorizationMocks.sessionAtom = Atom.make(
228 AsyncResult.success(
229 { authenticated: true, scopes: [AuthFilesystemReadScope] },
230 { waiting: true },
231 ),
232 );
233 const registry = AtomRegistry.make();
234 atomHooks.registry = registry;
235 const config = {
236 defaultThreadEnvMode: "worktree",
237 scripts: [{ name: "Test", command: "vp test" }],
238 };
239 projectMocks.readFile.mockReturnValue(
240 Atom.make(AsyncResult.success(file(JSON.stringify(config)), { waiting: true })),
241 );
242 projectMocks.optimisticFile.mockReturnValue(Atom.make(null));
243 try {
244 expect(useProjectFileQuery(environmentId, "/repo", "t3.json")).toMatchObject({
245 error: null,
246 isPending: true,
247 });
248 expect(useT3ProjectFileState(environmentId, "/repo")).toEqual({
249 status: "valid",
250 file: config,
251 scripts: config.scripts,
252 });
253 } finally {
254 registry.dispose();
255 atomHooks.registry = null;
256 }
257 },
258 );
259
260 it.each([
261 { phase: "connected", sessionError: "The session request timed out." },
262 { phase: "offline", sessionError: null },
263 ] as const)(
264 "reports unavailable file access for $phase connections",
265 ({ phase, sessionError }) => {
266 authorizationMocks.phase = phase;
267 authorizationMocks.sessionAtom = Atom.make(
268 sessionError === null
269 ? AsyncResult.initial()
270 : AsyncResult.failure(Cause.fail(new Error(sessionError))),
271 );
272 const registry = AtomRegistry.make();
273 atomHooks.registry = registry;
274 projectMocks.optimisticFile.mockReturnValue(Atom.make({ data: file("cached contents") }));
275 try {
276 const unavailable = {
277 data: null,
278 error: sessionError ?? "This environment is not connected.",
279 isPending: false,
280 };
281 expect(useProjectFileQuery(environmentId, "/repo", "src/preview.ts")).toMatchObject(
282 unavailable,
283 );
284 expect(useProjectEntriesQuery(environmentId, "/repo")).toMatchObject(unavailable);
285 expect(projectMocks.readFile).not.toHaveBeenCalled();
286 expect(projectMocks.listEntries).not.toHaveBeenCalled();
287 } finally {
288 registry.dispose();
289 atomHooks.registry = null;
290 }
291 },
292 );
293
294 it("leaves disabled file queries idle while the file grant loads", () => {
295 authorizationMocks.sessionAtom = Atom.make(AsyncResult.initial());
296 const registry = AtomRegistry.make();
297 atomHooks.registry = registry;
298 projectMocks.optimisticFile.mockReturnValue(Atom.make(null));
299 try {
300 expect(useProjectFileQuery(environmentId, "/repo", "t3.json", false)).toMatchObject({
301 data: null,
302 error: null,
303 isPending: false,
304 });
305 expect(useT3ProjectFileState(environmentId, null).status).toBe("missing");
306 expect(projectMocks.readFile).not.toHaveBeenCalled();
307 } finally {
308 registry.dispose();
309 atomHooks.registry = null;
310 }
311 });
312
313 it("replaces an in-flight initial read when a workspace mutation arrives", async () => {
314 const requests: Array<ReturnType<typeof deferred<ProjectReadFileResult>>> = [];
315 const readAtom = Atom.make(
316 Effect.promise(() => {
317 const request = deferred<ProjectReadFileResult>();
318 requests.push(request);
319 return request.promise;
320 }),
321 ).pipe(Atom.swr({ staleTime: 30_000, revalidateOnMount: true }));
322 const registry = AtomRegistry.make();
323 const unmount = registry.mount(readAtom);
324 projectMocks.readFile.mockReturnValue(readAtom);
325 projectMocks.optimisticFile.mockReturnValue(Atom.make(null));
326 atomHooks.registry = registry;
327 let renderedContents: string | null = null;
328
329 const render = (mutationId: string | null) => {
330 reactHooks.beginRender();
331 const query = useProjectFileQuery(environmentId, "/repo", "src/preview.ts");
332 expect(query.readError).toBeNull();
333 renderedContents = query.data?.contents ?? null;
334 useWorkspaceMutationRefresh({
335 mutationId,
336 refresh: query.refresh,
337 resourceKey: "file:environment-1:/repo:src/preview.ts",
338 });
339 };
340
341 try {
342 render(null);
343 await flushEffects();
344 expect(requests).toHaveLength(1);
345
346 render("mutation-1");
347 await flushEffects();
348 expect(requests).toHaveLength(2);
349
350 requests[1]!.resolve(file("fresh"));
351 await flushEffects();
352 render("mutation-1");
353 expect(renderedContents).toBe("fresh");
354
355 requests[0]!.resolve(file("stale"));
356 await flushEffects();
357 render("mutation-1");
358 expect(renderedContents).toBe("fresh");
359 } finally {
360 unmount();
361 registry.dispose();
362 atomHooks.registry = null;
363 }
364 });
365
366 it("revalidates cached entries when a workspace mutation is observed after mounting", async () => {
367 const requests: Array<ReturnType<typeof deferred<ProjectListEntriesResult>>> = [];
368 const entriesAtom = Atom.make(
369 Effect.promise(() => {
370 const request = deferred<ProjectListEntriesResult>();
371 requests.push(request);
372 return request.promise;
373 }),
374 ).pipe(Atom.swr({ staleTime: 30_000, revalidateOnMount: true }));
375 const registry = AtomRegistry.make();
376 const unmount = registry.mount(entriesAtom);
377 projectMocks.listEntries.mockReturnValue(entriesAtom);
378 atomHooks.registry = registry;
379 let renderedPaths: readonly string[] = [];
380
381 const render = (mutationId: string | null) => {
382 reactHooks.beginRender();
383 const query = useProjectEntriesQuery(environmentId, "/repo");
384 renderedPaths = query.data?.entries.map((entry) => entry.path) ?? [];
385 useWorkspaceMutationRefresh({
386 mutationId,
387 refresh: query.refresh,
388 resourceKey: "files:environment-1:/repo",
389 });
390 };
391
392 try {
393 await flushEffects();
394 expect(requests).toHaveLength(1);
395 requests[0]!.resolve(projectEntries(["src/old.ts"]));
396 await flushEffects();
397
398 render("mutation-1");
399 expect(renderedPaths).toEqual(["src/old.ts"]);
400 await flushEffects();
401 expect(requests).toHaveLength(2);
402
403 requests[1]!.resolve(projectEntries(["src/new.ts"]));
404 await flushEffects();
405 render("mutation-1");
406 expect(renderedPaths).toEqual(["src/new.ts"]);
407 expect(requests).toHaveLength(2);
408 } finally {
409 unmount();
410 registry.dispose();
411 atomHooks.registry = null;
412 }
413 });
414
415 it("does not issue a file read for a disabled image preview", async () => {
416 const requests: Array<ReturnType<typeof deferred<ProjectReadFileResult>>> = [];
417 const readAtom = Atom.make(
418 Effect.promise(() => {
419 const request = deferred<ProjectReadFileResult>();
420 requests.push(request);
421 return request.promise;
422 }),
423 );
424 const registry = AtomRegistry.make();
425 projectMocks.readFile.mockReturnValue(readAtom);
426 projectMocks.optimisticFile.mockReturnValue(Atom.make(null));
427 atomHooks.registry = registry;
428
429 try {
430 reactHooks.beginRender();
431 const query = useProjectFileQuery(environmentId, "/repo", "preview.png", false);
432 useWorkspaceMutationRefresh({
433 enabled: false,
434 mutationId: "mutation-1",
435 refresh: query.refresh,
436 resourceKey: "file:environment-1:/repo:preview.png",
437 });
438 await flushEffects();
439
440 expect(projectMocks.readFile).not.toHaveBeenCalled();
441 expect(query.readError).toBeNull();
442 expect(requests).toHaveLength(0);
443 } finally {
444 registry.dispose();
445 atomHooks.registry = null;
446 }
447 });
448
449 it("reports a directory named like an image as not a file", async () => {
450 const readAtom = Atom.make(
451 Effect.fail(
452 new ProjectReadFileError({
453 cwd: "/repo",
454 relativePath: "assets.png",
455 failure: "path_not_file",
456 }),
457 ),
458 );
459 const registry = AtomRegistry.make();
460 const unmount = registry.mount(readAtom);
461 projectMocks.readFile.mockReturnValue(readAtom);
462 projectMocks.optimisticFile.mockReturnValue(Atom.make(null));
463 atomHooks.registry = registry;
464
465 try {
466 await flushEffects();
467 reactHooks.beginRender();
468 const query = useProjectFileQuery(environmentId, "/repo", "assets.png");
469 expect(query.isNotFile).toBe(true);
470 expect(query.readError?.failure).toBe("path_not_file");
471 expect(query.data).toBeNull();
472 } finally {
473 unmount();
474 registry.dispose();
475 atomHooks.registry = null;
476 }
477 });
478
479 it("reports a directory read as not a file", async () => {
480 const readAtom = Atom.make(
481 Effect.fail(
482 new ProjectReadFileError({
483 cwd: "/repo",
484 relativePath: ".agents/skills",
485 failure: "path_not_file",
486 }),
487 ),
488 );
489 const registry = AtomRegistry.make();
490 const unmount = registry.mount(readAtom);
491 projectMocks.readFile.mockReturnValue(readAtom);
492 projectMocks.optimisticFile.mockReturnValue(Atom.make(null));
493 atomHooks.registry = registry;
494
495 try {
496 await flushEffects();
497 reactHooks.beginRender();
498 const query = useProjectFileQuery(environmentId, "/repo", ".agents/skills");
499 expect(query.isNotFile).toBe(true);
500 expect(query.readError?.failure).toBe("path_not_file");
501 expect(query.data).toBeNull();
502 } finally {
503 unmount();
504 registry.dispose();
505 atomHooks.registry = null;
506 }
507 });
508
509 it("retains decoded read failure context for the connected environment", async () => {
510 const error = decodeReadError({
511 _tag: "ProjectReadFileError",
512 cwd: "C:\\workspace",
513 relativePath: "workspace/outline.md",
514 failure: "operation_failed",
515 operation: "realpath-target",
516 operationPath: "C:\\workspace\\workspace\\outline.md",
517 resolvedPath: "C:\\workspace\\workspace\\outline.md",
518 message: "Failed to read workspace file.",
519 });
520 const readAtom = Atom.make(Effect.fail(error));
521 const registry = AtomRegistry.make();
522 const unmount = registry.mount(readAtom);
523 projectMocks.readFile.mockReturnValue(readAtom);
524 projectMocks.optimisticFile.mockReturnValue(Atom.make(null));
525 atomHooks.registry = registry;
526
527 try {
528 await flushEffects();
529 reactHooks.beginRender();
530 const query = useProjectFileQuery(environmentId, "C:\\workspace", "workspace/outline.md");
531 expect(query.readError).toBe(error);
532 expect(query.error).toBe(error.message);
533 expect(query.isNotFile).toBe(false);
534 expect(query.data).toBeNull();
535 } finally {
536 unmount();
537 registry.dispose();
538 atomHooks.registry = null;
539 }
540 });
541});