1import { AuthFilesystemReadScope, EnvironmentId, type AuthSessionState } from "@t3tools/contracts";
2import { beforeEach, expect, it, vi } from "vite-plus/test";
3
4const state = vi.hoisted(() => ({
5 session: null as Pick<AuthSessionState, "authenticated" | "scopes"> | null,
6 sessionError: null as string | null,
7 sessionWaiting: false,
8 phase: "connected" as "connected" | "offline",
9 sessionAtom: {},
10 searchAtom: {},
11 contentAtom: {},
12 contentRequests: vi.fn(),
13 contentError: null as string | null,
14 contentData: {
15 matches: [{ path: "src/index.ts", lineNumber: 3, lineContent: "a match", matchRanges: [] }],
16 truncated: false,
17 },
18}));
19
20vi.mock("react", async (importOriginal) => ({
21 ...(await importOriginal<typeof import("react")>()),
22 useCallback: <A>(callback: A) => callback,
23 useEffect: () => {},
24 useMemo: <A>(factory: () => A) => factory(),
25 useState: <A>(value: A) => [value, vi.fn()],
26}));
27vi.mock("./session", () => ({
28 environmentSession: { sessionStateAtom: () => state.sessionAtom },
29}));
30vi.mock("./presentation", () => ({
31 useEnvironmentPresentation: () => ({
32 isReady: true,
33 presentation: { connection: { phase: state.phase, error: null } },
34 }),
35}));
36vi.mock("./projects", () => ({
37 projectEnvironment: { searchEntries: () => state.searchAtom },
38 projectContentSearch: (target: unknown) => {
39 state.contentRequests(target);
40 return state.contentAtom;
41 },
42}));
43vi.mock("../rpc/atomRegistry", () => ({ appAtomRegistry: {} }));
44vi.mock("./orchestration", () => ({ orchestrationEnvironment: {} }));
45vi.mock("./threads", () => ({ useEnvironmentThread: vi.fn() }));
46vi.mock("./vcs", () => ({ vcsEnvironment: {} }));
47vi.mock("./query", () => ({
48 useEnvironmentQuery: (atom: unknown) => ({
49 data:
50 atom === state.sessionAtom
51 ? state.session
52 : atom === state.searchAtom
53 ? { entries: [{ path: "src/index.ts", kind: "file" }] }
54 : atom === state.contentAtom
55 ? state.contentData
56 : null,
57 error:
58 atom === state.sessionAtom
59 ? state.sessionError
60 : atom === state.contentAtom
61 ? state.contentError
62 : null,
63 isPending:
64 atom === state.sessionAtom &&
65 (state.sessionWaiting || (state.session === null && state.sessionError === null)),
66 refresh: vi.fn(),
67 }),
68}));
69
70import { useProjectContentSearch, useProjectPathSearch } from "./queries";
71
72const useComposerPathSearch = (input: Parameters<typeof useProjectPathSearch>[0]) =>
73 useProjectPathSearch(input, 20);
74
75const target = {
76 environmentId: EnvironmentId.make("test-environment"),
77 cwd: "/repo",
78 query: "src",
79};
80
81beforeEach(() => {
82 state.session = null;
83 state.sessionError = null;
84 state.sessionWaiting = false;
85 state.phase = "connected";
86 state.contentRequests.mockClear();
87 state.contentError = null;
88});
89
90it("keeps a file search pending until its grant loads, then shows matches", () => {
91 expect(useComposerPathSearch(target)).toMatchObject({
92 entries: [],
93 error: null,
94 isPending: true,
95 });
96 state.session = { authenticated: true, scopes: [AuthFilesystemReadScope] };
97 expect(useComposerPathSearch(target)).toMatchObject({
98 entries: [{ path: "src/index.ts", kind: "file" }],
99 error: null,
100 isPending: false,
101 });
102});
103
104it("shows a confirmed denial and a connection failure separately", () => {
105 state.session = { authenticated: true, scopes: [] };
106 expect(useComposerPathSearch(target)).toMatchObject({
107 entries: [],
108 error: "This connection cannot search host files.",
109 isPending: false,
110 });
111 state.session = null;
112 state.phase = "offline";
113 expect(useComposerPathSearch(target)).toMatchObject({
114 entries: [],
115 error: "This environment is not connected.",
116 isPending: false,
117 });
118});
119
120it("leaves an inactive search idle while its grant loads", () => {
121 expect(useComposerPathSearch({ ...target, cwd: null, query: null })).toMatchObject({
122 entries: [],
123 error: null,
124 isPending: false,
125 });
126});
127
128const contentTarget = {
129 ...target,
130 query: " a match ",
131 caseSensitive: true,
132 wholeWord: true,
133 useRegex: false,
134};
135
136it("waits for content-search access before issuing a request, including an empty search", () => {
137 for (const query of ["", contentTarget.query]) {
138 const result = useProjectContentSearch({ ...contentTarget, query });
139 expect(state.contentRequests).not.toHaveBeenCalled();
140 expect(result).toMatchObject({
141 canReadFiles: false,
142 isCheckingAccess: true,
143 matches: [],
144 error: null,
145 isPending: true,
146 });
147 }
148 expect(state.contentRequests).not.toHaveBeenCalled();
149});
150
151it("shows content-search denial before typing and never issues an unauthorized request", () => {
152 state.session = { authenticated: true, scopes: [] };
153 for (const query of ["", contentTarget.query]) {
154 const result = useProjectContentSearch({ ...contentTarget, query });
155 expect(state.contentRequests).not.toHaveBeenCalled();
156 expect(result).toMatchObject({
157 canReadFiles: false,
158 isCheckingAccess: false,
159 matches: [],
160 error: "This connection cannot search host files.",
161 isPending: false,
162 });
163 }
164 expect(state.contentRequests).not.toHaveBeenCalled();
165});
166
167it("preserves content query whitespace and options when access is granted", () => {
168 state.session = { authenticated: true, scopes: [AuthFilesystemReadScope] };
169 expect(useProjectContentSearch(contentTarget)).toMatchObject({
170 matches: state.contentData.matches,
171 error: null,
172 isPending: false,
173 });
174 expect(state.contentRequests).toHaveBeenCalledWith({
175 environmentId: contentTarget.environmentId,
176 input: {
177 cwd: contentTarget.cwd,
178 query: contentTarget.query,
179 limit: 500,
180 caseSensitive: true,
181 wholeWord: true,
182 useRegex: false,
183 },
184 });
185});
186
187it("keeps confirmed content access during revalidation and drops results when it is revoked", () => {
188 state.session = { authenticated: true, scopes: [AuthFilesystemReadScope] };
189 state.sessionWaiting = true;
190 expect(useProjectContentSearch(contentTarget)).toMatchObject({
191 matches: state.contentData.matches,
192 isPending: false,
193 });
194
195 state.contentRequests.mockClear();
196 state.sessionWaiting = false;
197 state.session = { authenticated: true, scopes: [] };
198 const result = useProjectContentSearch(contentTarget);
199 expect(state.contentRequests).not.toHaveBeenCalled();
200 expect(result).toMatchObject({
201 canReadFiles: false,
202 matches: [],
203 error: "This connection cannot search host files.",
204 isPending: false,
205 });
206 expect(state.contentRequests).not.toHaveBeenCalled();
207});
208
209it("fails closed after a session check fails, even with a cached grant and matches", () => {
210 state.session = { authenticated: true, scopes: [AuthFilesystemReadScope] };
211 state.sessionError = "The session has expired.";
212 const result = useProjectContentSearch(contentTarget);
213 expect(state.contentRequests).not.toHaveBeenCalled();
214 expect(result).toMatchObject({
215 canReadFiles: false,
216 matches: [],
217 error: state.sessionError,
218 isPending: false,
219 });
220 expect(state.contentRequests).not.toHaveBeenCalled();
221});
222
223it("keeps inactive content search idle and reports a disconnected target without querying", () => {
224 expect(
225 useProjectContentSearch({ ...contentTarget, environmentId: null, cwd: null }),
226 ).toMatchObject({
227 matches: [],
228 error: null,
229 isPending: false,
230 });
231 state.phase = "offline";
232 const result = useProjectContentSearch(contentTarget);
233 expect(state.contentRequests).not.toHaveBeenCalled();
234 expect(result).toMatchObject({
235 canReadFiles: false,
236 matches: [],
237 error: "This environment is not connected.",
238 isPending: false,
239 });
240 expect(state.contentRequests).not.toHaveBeenCalled();
241});