Skip to content
Source
Sign inSign up

useDirectoryEntries.browser.test.tsx

216 lines7.1 KB

1import { RegistryContext } from "@effect/atom-react";
2import * as Cause from "effect/Cause";
3import * as Effect from "effect/Effect";
4import { AsyncResult, Atom } from "effect/reactivity";
5import { appAtomRegistry } from "~/rpc/atomRegistry";
6import { EnvironmentId, type ProjectEntry } from "@t3tools/contracts";
7import { useEffect } from "react";
8import { act, render } from "../../../test/render";
9import { expect, it, vi } from "vite-plus/test";
10​
11const { listEntries, mocks } = vi.hoisted(() => ({
12 listEntries: vi.fn(),
13 mocks: {
14 reconnect: false,
15 reachable: null as unknown as Atom.Writable<boolean>,
16 connectedSession: null as unknown as Atom.Writable<number>,
17 },
18}));
19vi.mock("~/connection/catalog", () => ({
20 environmentCatalog: {
21 sessionGenerationAtom: () => Atom.make((get) => get(mocks.connectedSession)),
22 },
23}));
24vi.mock("~/state/projects", () => ({
25 projectEnvironment: {
26 listEntries: (request: unknown) =>
27 mocks.reconnect
28 ? Atom.make((get) =>
29 get(mocks.reachable)
30 ? AsyncResult.success({
31 entries: [{ path: "README.md", kind: "file" }],
32 truncated: false,
33 })
34 : AsyncResult.failure(Cause.fail(new Error("Environment is reconnecting."))),
35 )
36 : Atom.make(Effect.promise(() => listEntries(request))),
37 },
38}));
39​
40import { useDirectoryEntries } from "./useDirectoryEntries";
41​
42function setup() {
43 mocks.reconnect = false;
44 mocks.reachable = Atom.make(false);
45 mocks.connectedSession = Atom.make(1);
46 let view!: ReturnType<typeof useDirectoryEntries>;
47 const calls: Array<{ path: string; resolve: (entries: readonly ProjectEntry[]) => void }> = [];
48 let activeQueries = 0;
49 let peakQueries = 0;
50 function HookProbe() {
51 const result = useDirectoryEntries(EnvironmentId.make("files-test"), "/workspace");
52 useEffect(() => {
53 view = result;
54 });
55 return null;
56 }
57​
58 async function settle(index: number, entries: readonly ProjectEntry[] = []) {
59 await act(async () => calls[index]!.resolve(entries));
60 }
61​
62 listEntries.mockReset().mockImplementation((request) => {
63 activeQueries++;
64 peakQueries = Math.max(peakQueries, activeQueries);
65 return new Promise((resolve) => {
66 calls.push({
67 path: request.input.directoryPath,
68 resolve: (entries) => {
69 activeQueries--;
70 resolve({ entries });
71 },
72 });
73 });
74 });
75 return {
76 Probe: () => (
77 <RegistryContext.Provider value={appAtomRegistry}>
78 <HookProbe />
79 </RegistryContext.Provider>
80 ),
81 settle,
82 calls,
83 get view() {
84 return view;
85 },
86 get activeQueries() {
87 return activeQueries;
88 },
89 resetPeakQueries() {
90 peakQueries = activeQueries;
91 },
92 get peakQueries() {
93 return peakQueries;
94 },
95 };
97​
98it("coalesces 20 overlapping refreshes", async () => {
99 const state = setup();
100 // The hook probe does not navigate.
101 await using _screen = await render(<state.Probe />, { router: false });
102 await state.settle(0);
103 await act(async () => {
104 for (let index = 0; index < 20; index++) state.view.refresh();
105 });
106 for (let index = 1; index < state.calls.length; index++) await state.settle(index);
107 expect(state.calls).toHaveLength(3); // Initial load, active refresh, one final rescan.
108 expect(state.view.isPending).toBe(false);
109});
110​
111it("rescans mutations during both the initial listing and the follow-up", async () => {
112 const state = setup();
113 // The hook probe does not navigate.
114 await using _screen = await render(<state.Probe />, { router: false });
115 await act(async () => state.view.refresh());
116 await state.settle(0);
117 expect(state.calls).toHaveLength(2);
118 await act(async () => {
119 state.view.refresh();
120 state.view.refresh();
121 });
122 await state.settle(1);
123 expect(state.calls).toHaveLength(3);
124 const entry = { path: "latest.txt", kind: "file" } as const;
125 await state.settle(2, [entry]);
126 expect(state.view.entries).toEqual([entry]);
127 expect(state.view.isPending).toBe(false);
128});
129​
130it("shares the follow-up promise and does not resolve callers before the final listing", async () => {
131 const state = setup();
132 // The hook probe does not navigate.
133 await using _screen = await render(<state.Probe />, { router: false });
134 let completed = false;
135 const first = state.view.load("", true);
136 const second = state.view.load("", true);
137 expect(second).toBe(first);
138 void first.then(() => {
139 completed = true;
140 });
141 await state.settle(0);
142 expect(completed).toBe(false);
143 await state.settle(1);
144 expect(completed).toBe(true);
145});
146​
147it("retains the four-query concurrency bound across directories and refreshes", async () => {
148 const folders = Array.from({ length: 8 }, (_, index) => ({
149 path: `folder-${index}`,
150 kind: "directory" as const,
151 }));
152 const state = setup();
153 // The hook probe does not navigate.
154 await using _screen = await render(<state.Probe />, { router: false });
155 await state.settle(0, folders);
156 await act(async () => {
157 for (const folder of folders) void state.view.load(folder.path);
158 });
159 for (let index = 1; index < state.calls.length; index++) {
160 await state.settle(index);
161 }
162 expect(state.activeQueries).toBe(0);
163 const refreshStart = state.calls.length;
164 state.resetPeakQueries();
165 await act(async () => state.view.refresh());
166 expect(state.peakQueries).toBe(4);
167 expect(state.activeQueries).toBe(4);
168 await act(async () => {
169 for (let index = 0; index < 20; index++) state.view.refresh();
170 });
171 for (let index = refreshStart; index < state.calls.length; index++) {
172 await state.settle(index, state.calls[index]!.path === "" ? folders : []);
173 expect(state.activeQueries).toBeLessThanOrEqual(4);
174 }
175 expect(state.peakQueries).toBe(4);
176 expect(new Set(state.calls.map((call) => call.path)).size).toBe(9);
177 for (const path of ["", ...folders.map((folder) => folder.path)]) {
178 expect(state.calls.filter((call) => call.path === path).length).toBeLessThanOrEqual(3);
179 }
180 expect(state.view.isPending).toBe(false);
181});
182​
183it("does not start a queued follow-up after unmount", async () => {
184 const state = setup();
185 // The hook probe does not navigate.
186 await using screen = await render(<state.Probe />, { router: false });
187 await act(async () => state.view.refresh());
188 await screen.unmount();
189 await state.settle(0);
190 expect(state.calls).toHaveLength(1);
191});
192​
193function ReconnectProbe() {
194 const directory = useDirectoryEntries(EnvironmentId.make("env-files"), "/repo");
195 return (
196 <output>{directory.error ?? directory.entries.map((entry) => entry.path).join(",")}</output>
197 );
199​
200it("reads a folder that failed while unreachable again once a new session is up", async () => {
201 setup();
202 mocks.reconnect = true;
203 await using screen = await render(
204 <RegistryContext.Provider value={appAtomRegistry}>
205 <ReconnectProbe />
206 </RegistryContext.Provider>,
207 // The hook probe does not navigate.
208 { router: false },
209 );
210 await expect.element(screen.getByText("Environment is reconnecting.")).toBeInTheDocument();
211 await act(() => {
212 appAtomRegistry.set(mocks.reachable, true);
213 appAtomRegistry.set(mocks.connectedSession, 2);
214 });
215 await expect.element(screen.getByText("README.md")).toBeInTheDocument();
216});