BlueSky & more on desktop
lazurite.stormlightlabs.org/
tauri
rust
typescript
bluesky
appview
atproto
solid
1import { fireEvent, render, screen, waitFor } from "@solidjs/testing-library";
2import { beforeEach, describe, expect, it, vi } from "vitest";
3import { ExplorerPanel } from "../ExplorerPanel";
4
5const describeRepoMock = vi.hoisted(() => vi.fn());
6const describeServerMock = vi.hoisted(() => vi.fn());
7const exportRepoCarMock = vi.hoisted(() => vi.fn());
8const clearLexiconFaviconCacheMock = vi.hoisted(() => vi.fn());
9const getLexiconFaviconsMock = vi.hoisted(() => vi.fn());
10const getRecordMock = vi.hoisted(() => vi.fn());
11const getRecordBacklinksMock = vi.hoisted(() => vi.fn());
12const getProfileMock = vi.hoisted(() => vi.fn());
13const listRecordsMock = vi.hoisted(() => vi.fn());
14const queryLabelsMock = vi.hoisted(() => vi.fn());
15const resolveInputMock = vi.hoisted(() => vi.fn());
16const listenMock = vi.hoisted(() => vi.fn());
17
18vi.mock(
19 "$/lib/api/explorer",
20 () => ({
21 ExplorerController: {
22 describeRepo: describeRepoMock,
23 describeServer: describeServerMock,
24 exportRepoCar: exportRepoCarMock,
25 clearLexiconFaviconCache: clearLexiconFaviconCacheMock,
26 getLexiconFavicons: getLexiconFaviconsMock,
27 getRecord: getRecordMock,
28 listRecords: listRecordsMock,
29 queryLabels: queryLabelsMock,
30 resolveInput: resolveInputMock,
31 },
32 }),
33);
34
35vi.mock("$/lib/api/profile", () => ({ ProfileController: { getProfile: getProfileMock } }));
36vi.mock("$/lib/api/diagnostics", () => ({ DiagnosticsController: { getRecordBacklinks: getRecordBacklinksMock } }));
37vi.mock("@tauri-apps/api/event", () => ({ listen: listenMock }));
38
39function renderPanel() {
40 return render(() => <ExplorerPanel />);
41}
42
43describe("ExplorerPanel", () => {
44 beforeEach(() => {
45 describeRepoMock.mockReset();
46 describeServerMock.mockReset();
47 exportRepoCarMock.mockReset();
48 clearLexiconFaviconCacheMock.mockReset();
49 getLexiconFaviconsMock.mockReset();
50 getRecordMock.mockReset();
51 getRecordBacklinksMock.mockReset();
52 getProfileMock.mockReset();
53 listRecordsMock.mockReset();
54 queryLabelsMock.mockReset();
55 resolveInputMock.mockReset();
56 listenMock.mockReset();
57
58 exportRepoCarMock.mockResolvedValue({ did: "did:plc:alice", path: "/tmp/alice.car", bytesWritten: 64 });
59 clearLexiconFaviconCacheMock.mockResolvedValue(void 0);
60 getLexiconFaviconsMock.mockResolvedValue({});
61 getProfileMock.mockResolvedValue({
62 status: "available",
63 profile: { did: "did:plc:alice", handle: "alice.test", followersCount: 28, followsCount: 14 },
64 });
65 getRecordBacklinksMock.mockResolvedValue({
66 likes: { cursor: null, records: [], total: 3 },
67 quotes: { cursor: null, records: [], total: 1 },
68 replies: { cursor: null, records: [], total: 2 },
69 reposts: { cursor: null, records: [], total: 4 },
70 });
71 listenMock.mockResolvedValue(() => {});
72 queryLabelsMock.mockResolvedValue({ labels: [] });
73 });
74
75 it("accepts raw handle input and renders repo collections from describeRepo", async () => {
76 resolveInputMock.mockResolvedValue({
77 input: "@alice.test",
78 inputKind: "handle",
79 targetKind: "repo",
80 normalizedInput: "did:plc:alice",
81 uri: "at://did:plc:alice",
82 did: "did:plc:alice",
83 handle: "alice.test",
84 pdsUrl: "https://pds.example.com",
85 collection: null,
86 rkey: null,
87 });
88 describeRepoMock.mockResolvedValue({ collections: ["app.bsky.feed.like", "app.bsky.feed.post"] });
89
90 renderPanel();
91
92 const input = screen.getByPlaceholderText(/at:\/\/did:\.\.\. or @handle or https:\/\/pds/u);
93 fireEvent.input(input, { target: { value: "@alice.test" } });
94 fireEvent.submit(input.closest("form")!);
95
96 expect(resolveInputMock).toHaveBeenCalledWith("@alice.test");
97 expect(await screen.findByRole("button", { name: /app\.bsky\.feed\.like/u })).toBeInTheDocument();
98 expect(screen.getByRole("button", { name: /app\.bsky\.feed\.post/u })).toBeInTheDocument();
99 expect(await screen.findByText("Followers")).toBeInTheDocument();
100 expect(screen.getByText("28")).toBeInTheDocument();
101 expect(screen.getByText("14")).toBeInTheDocument();
102 expect(screen.queryByText("0 records")).not.toBeInTheDocument();
103 expect(screen.queryByText("Count unavailable")).not.toBeInTheDocument();
104 });
105
106 it("renders the initial empty state and submits example chips", async () => {
107 resolveInputMock.mockRejectedValueOnce(new Error("network unavailable"));
108
109 renderPanel();
110
111 expect(screen.getByText("Start from a handle, DID, URI, or PDS.")).toBeInTheDocument();
112 fireEvent.click(screen.getByRole("button", { name: /@alice\.bsky\.social/u }));
113
114 await waitFor(() => expect(resolveInputMock).toHaveBeenCalledWith("@alice.bsky.social"));
115 });
116
117 it("loads additional collection pages", async () => {
118 resolveInputMock.mockResolvedValue({
119 input: "at://did:plc:alice/app.bsky.feed.post",
120 inputKind: "atUri",
121 targetKind: "collection",
122 normalizedInput: "at://did:plc:alice/app.bsky.feed.post",
123 uri: "at://did:plc:alice/app.bsky.feed.post",
124 did: "did:plc:alice",
125 handle: "alice.test",
126 pdsUrl: "https://pds.example.com",
127 collection: "app.bsky.feed.post",
128 rkey: null,
129 });
130 listRecordsMock.mockResolvedValueOnce({
131 cursor: "cursor-2",
132 records: [{
133 uri: "at://did:plc:alice/app.bsky.feed.post/first",
134 cid: "cid-first",
135 value: { text: "First page" },
136 }],
137 }).mockResolvedValueOnce({
138 cursor: null,
139 records: [{
140 uri: "at://did:plc:alice/app.bsky.feed.post/second",
141 cid: "cid-second",
142 value: { text: "Second page" },
143 }],
144 });
145
146 renderPanel();
147
148 const input = screen.getByPlaceholderText(/at:\/\/did:\.\.\. or @handle or https:\/\/pds/u);
149 fireEvent.input(input, { target: { value: "at://did:plc:alice/app.bsky.feed.post" } });
150 fireEvent.submit(input.closest("form")!);
151
152 expect(await screen.findByRole("button", { name: /first/u })).toBeInTheDocument();
153
154 fireEvent.click(screen.getByRole("button", { name: /load more\.\.\./iu }));
155
156 await screen.findByRole("button", { name: /second/u });
157 expect(listRecordsMock).toHaveBeenNthCalledWith(2, "did:plc:alice", "app.bsky.feed.post", "cursor-2");
158 });
159
160 it("handles deep-link navigation events for PDS targets", async () => {
161 let navigationHandler: ((event: { payload: { target: Record<string, unknown> } }) => void) | undefined;
162
163 listenMock.mockImplementation((_event: string, callback: typeof navigationHandler) => {
164 navigationHandler = callback;
165 return Promise.resolve(() => {});
166 });
167 resolveInputMock.mockResolvedValue({
168 input: "https://pds.example.com",
169 inputKind: "pdsUrl",
170 targetKind: "pds",
171 normalizedInput: "https://pds.example.com",
172 uri: null,
173 did: null,
174 handle: null,
175 pdsUrl: "https://pds.example.com",
176 collection: null,
177 rkey: null,
178 });
179 describeServerMock.mockResolvedValue({
180 pdsUrl: "https://pds.example.com",
181 server: { inviteCodeRequired: true, version: "0.4.0" },
182 repos: [{ did: "did:plc:hosted", head: "head", rev: "rev-1", active: true, status: null }],
183 cursor: null,
184 });
185
186 renderPanel();
187
188 await waitFor(() => expect(listenMock).toHaveBeenCalledOnce());
189
190 navigationHandler?.({
191 payload: {
192 target: {
193 input: "https://pds.example.com",
194 inputKind: "pdsUrl",
195 targetKind: "pds",
196 normalizedInput: "https://pds.example.com",
197 uri: null,
198 did: null,
199 handle: null,
200 pdsUrl: "https://pds.example.com",
201 collection: null,
202 rkey: null,
203 },
204 },
205 });
206
207 expect(resolveInputMock).toHaveBeenCalledWith("https://pds.example.com");
208 expect(await screen.findByText("Hosted Repositories")).toBeInTheDocument();
209 expect(screen.getByRole("button", { name: /did:plc:hosted/u })).toBeInTheDocument();
210 });
211
212 it("shows record backlinks as a supplementary explorer panel", async () => {
213 resolveInputMock.mockResolvedValue({
214 input: "at://did:plc:alice/app.bsky.feed.post/123",
215 inputKind: "atUri",
216 targetKind: "record",
217 normalizedInput: "at://did:plc:alice/app.bsky.feed.post/123",
218 uri: "at://did:plc:alice/app.bsky.feed.post/123",
219 did: "did:plc:alice",
220 handle: "alice.test",
221 pdsUrl: "https://pds.example.com",
222 collection: "app.bsky.feed.post",
223 rkey: "123",
224 });
225 getRecordMock.mockResolvedValue({
226 cid: "cid-123",
227 value: { $type: "app.bsky.feed.post", text: "Explorer record" },
228 });
229 getRecordBacklinksMock.mockResolvedValue({
230 likes: { cursor: null, records: [], total: 3 },
231 quotes: { cursor: null, records: [], total: 1 },
232 replies: { cursor: null, records: [], total: 2 },
233 reposts: { cursor: null, records: [], total: 4 },
234 });
235
236 renderPanel();
237
238 const input = screen.getByPlaceholderText(/at:\/\/did:\.\.\. or @handle or https:\/\/pds/u);
239 fireEvent.input(input, { target: { value: "at://did:plc:alice/app.bsky.feed.post/123" } });
240 fireEvent.submit(input.closest("form")!);
241
242 expect(await screen.findByText("Backlinks")).toBeInTheDocument();
243 expect(await screen.findByText("3 records")).toBeInTheDocument();
244 expect(screen.getByText("4 records")).toBeInTheDocument();
245 });
246
247 it("renders lexicon favicons in repo and collection views when available", async () => {
248 resolveInputMock.mockResolvedValueOnce({
249 input: "@alice.test",
250 inputKind: "handle",
251 targetKind: "repo",
252 normalizedInput: "did:plc:alice",
253 uri: "at://did:plc:alice",
254 did: "did:plc:alice",
255 handle: "alice.test",
256 pdsUrl: "https://pds.example.com",
257 collection: null,
258 rkey: null,
259 }).mockResolvedValueOnce({
260 input: "at://did:plc:alice/app.bsky.feed.post",
261 inputKind: "atUri",
262 targetKind: "collection",
263 normalizedInput: "at://did:plc:alice/app.bsky.feed.post",
264 uri: "at://did:plc:alice/app.bsky.feed.post",
265 did: "did:plc:alice",
266 handle: "alice.test",
267 pdsUrl: "https://pds.example.com",
268 collection: "app.bsky.feed.post",
269 rkey: null,
270 });
271 describeRepoMock.mockResolvedValue({ collections: ["app.bsky.feed.post"] });
272 listRecordsMock.mockResolvedValue({ cursor: null, records: [] });
273 getLexiconFaviconsMock.mockResolvedValue({ "app.bsky.feed.post": "data:image/png;base64,Zm9v" });
274
275 renderPanel();
276
277 const input = screen.getByPlaceholderText(/at:\/\/did:\.\.\. or @handle or https:\/\/pds/u);
278 fireEvent.input(input, { target: { value: "@alice.test" } });
279 fireEvent.submit(input.closest("form")!);
280
281 expect(await screen.findByAltText("app.bsky.feed.post favicon")).toBeInTheDocument();
282
283 fireEvent.click(screen.getByRole("button", { name: /app\.bsky\.feed\.post/u }));
284
285 expect(await screen.findAllByAltText("app.bsky.feed.post favicon")).not.toHaveLength(0);
286 });
287
288 it("clears and rehydrates the explorer icon cache for the current repo view", async () => {
289 resolveInputMock.mockResolvedValue({
290 input: "@alice.test",
291 inputKind: "handle",
292 targetKind: "repo",
293 normalizedInput: "did:plc:alice",
294 uri: "at://did:plc:alice",
295 did: "did:plc:alice",
296 handle: "alice.test",
297 pdsUrl: "https://pds.example.com",
298 collection: null,
299 rkey: null,
300 });
301 describeRepoMock.mockResolvedValue({ collections: ["sh.tangled.feed.star"] });
302 getLexiconFaviconsMock.mockResolvedValue({ "sh.tangled.feed.star": null });
303
304 renderPanel();
305
306 const input = screen.getByPlaceholderText(/at:\/\/did:\.\.\. or @handle or https:\/\/pds/u);
307 fireEvent.input(input, { target: { value: "@alice.test" } });
308 fireEvent.submit(input.closest("form")!);
309
310 expect(await screen.findByRole("button", { name: /sh\.tangled\.feed\.star/u })).toBeInTheDocument();
311 await waitFor(() => expect(getLexiconFaviconsMock).toHaveBeenCalledWith(["sh.tangled.feed.star"]));
312
313 fireEvent.click(screen.getByRole("button", { name: /clear icon cache/i }));
314
315 await waitFor(() => expect(clearLexiconFaviconCacheMock).toHaveBeenCalledOnce());
316 await waitFor(() => expect(getLexiconFaviconsMock).toHaveBeenCalledTimes(2));
317 expect(await screen.findByText("Cleared explorer icon cache.")).toBeInTheDocument();
318 });
319});