BlueSky & more on desktop
lazurite.stormlightlabs.org/
tauri
rust
typescript
bluesky
appview
atproto
solid
1import { AppTestProviders } from "$/test/providers";
2import { render, screen } from "@solidjs/testing-library";
3import type { Component, ParentProps } from "solid-js";
4import { beforeEach, describe, expect, it, vi } from "vitest";
5import { buildMessagesRoute } from "./lib/conversations";
6import { buildProfileRoute } from "./lib/profile";
7import { AppRouter } from "./router";
8
9const listenMock = vi.hoisted(() => vi.fn());
10
11vi.mock("@tauri-apps/api/event", () => ({ listen: listenMock }));
12vi.mock(
13 "$/components/saved/SavedPostsPanel",
14 () => ({ SavedPostsPanel: () => <div data-testid="saved-posts-view">saved</div> }),
15);
16vi.mock(
17 "$/components/search/HashtagPanel",
18 () => ({ HashtagPanel: () => <div data-testid="hashtag-view">hashtag</div> }),
19);
20vi.mock("$/components/search/SearchPanel", () => ({ SearchPanel: () => <div data-testid="search-view">search</div> }));
21vi.mock(
22 "$/components/search/SearchPreflightPanel",
23 () => ({ SearchPreflightPanel: () => <div data-testid="search-preflight-view">preflight</div> }),
24);
25
26const Shell: Component<ParentProps<{ fullWidth?: boolean }>> = (props) => (
27 <div data-testid="shell" data-full-width={props.fullWidth ? "true" : "false"}>{props.children}</div>
28);
29
30function renderRouter(hash: string, options: { preferences?: Record<string, unknown> } = {}) {
31 globalThis.location.hash = hash;
32 const renderComposer = vi.fn(() => <div data-testid="composer-view">composer</div>);
33 const renderNotifications = vi.fn(() => <div data-testid="notifications-view">notifications</div>);
34 const renderPostEngagement = vi.fn((props: { uri: string | null }) => (
35 <div data-testid="post-engagement-view">{props.uri ?? "none"}</div>
36 ));
37 const renderPost = vi.fn((props: { uri: string | null }) => <div data-testid="post-view">{props.uri ?? "none"}</div>);
38 const renderProfile = vi.fn((props: { actor: string | null }) => (
39 <div data-testid="profile-view">
40 <span>{props.actor ?? "self-profile"}</span>
41 </div>
42 ));
43 const renderTimeline = vi.fn(() => <div data-testid="timeline-view">timeline</div>);
44
45 const renderMessages = vi.fn((props: { memberDid: string | null }) => (
46 <div data-testid="messages-view">{props.memberDid ?? "messages"}</div>
47 ));
48
49 render(() => (
50 <AppTestProviders
51 preferences={options.preferences}
52 session={{
53 activeDid: "did:plc:alice",
54 activeHandle: "alice.test",
55 activeSession: { did: "did:plc:alice", handle: "alice.test" },
56 }}>
57 <AppRouter
58 renderAuth={() => <div>Auth</div>}
59 renderComposer={renderComposer}
60 renderMessages={renderMessages}
61 renderNotifications={renderNotifications}
62 renderPostEngagement={renderPostEngagement}
63 renderPost={renderPost}
64 renderProfile={renderProfile}
65 renderShell={Shell}
66 renderTimeline={renderTimeline} />
67 </AppTestProviders>
68 ));
69
70 return {
71 renderComposer,
72 renderMessages,
73 renderNotifications,
74 renderPost,
75 renderPostEngagement,
76 renderProfile,
77 renderTimeline,
78 };
79}
80
81describe("AppRouter", () => {
82 beforeEach(() => {
83 listenMock.mockReset();
84 listenMock.mockResolvedValue(() => {});
85 });
86
87 it("renders the timeline route", async () => {
88 const { renderTimeline } = renderRouter("#/timeline");
89
90 await screen.findByTestId("timeline-view");
91
92 expect(renderTimeline).toHaveBeenCalledOnce();
93 expect(screen.getByText("timeline")).toBeInTheDocument();
94 });
95
96 it("renders the timeline route with query params intact", async () => {
97 const { renderTimeline } = renderRouter(
98 "#/timeline?thread=at%3A%2F%2Fdid%3Aplc%3Aalice%2Fapp.bsky.feed.post%2Fxyz",
99 );
100
101 await screen.findByTestId("timeline-view");
102
103 expect(renderTimeline).toHaveBeenCalledOnce();
104 });
105
106 it("renders the standalone composer route", async () => {
107 const { renderComposer } = renderRouter("#/composer");
108
109 await screen.findByTestId("composer-view");
110
111 expect(renderComposer).toHaveBeenCalledOnce();
112 expect(screen.getByText("composer")).toBeInTheDocument();
113 });
114
115 it("renders the notifications route inside the protected shell", async () => {
116 const { renderNotifications } = renderRouter("#/notifications");
117
118 await screen.findByTestId("notifications-view");
119
120 expect(renderNotifications).toHaveBeenCalledOnce();
121 expect(screen.getByText("notifications")).toBeInTheDocument();
122 expect(screen.getByTestId("shell")).toHaveAttribute("data-full-width", "false");
123 });
124
125 it("renders decoded post routes inside the protected shell", async () => {
126 const uri = "at://did:plc:alice/app.bsky.feed.post/123";
127 const { renderPost } = renderRouter(`#/post/${encodeURIComponent(uri)}`);
128
129 await screen.findByTestId("post-view");
130
131 expect(renderPost.mock.lastCall?.[0].uri).toBe(uri);
132 expect(screen.getByTestId("shell")).toHaveAttribute("data-full-width", "false");
133 });
134
135 it("renders post engagement routes with the decoded post uri", async () => {
136 const uri = "at://did:plc:alice/app.bsky.feed.post/123";
137 const { renderPostEngagement } = renderRouter(`#/post/${encodeURIComponent(uri)}/engagement?tab=quotes`);
138
139 await screen.findByTestId("post-engagement-view");
140
141 expect(renderPostEngagement.mock.lastCall?.[0].uri).toBe(uri);
142 expect(screen.getByTestId("shell")).toHaveAttribute("data-full-width", "false");
143 });
144
145 it("renders the saved posts route inside the protected shell", async () => {
146 renderRouter("#/saved");
147
148 await screen.findByTestId("saved-posts-view");
149
150 expect(screen.getByText("saved")).toBeInTheDocument();
151 expect(screen.getByTestId("shell")).toHaveAttribute("data-full-width", "false");
152 });
153
154 it("passes the decoded member did on targeted message routes", async () => {
155 const memberDid = "did:plc:bob";
156 const { renderMessages } = renderRouter(`#${buildMessagesRoute(memberDid)}`);
157
158 await screen.findByTestId("messages-view");
159
160 expect(renderMessages.mock.lastCall?.[0].memberDid).toBe(memberDid);
161 expect(screen.getByText(memberDid)).toBeInTheDocument();
162 });
163
164 it("renders the explorer route inside the full-width shell", async () => {
165 renderRouter("#/explorer");
166
167 await screen.findByTestId("shell");
168
169 expect(screen.getByTestId("shell")).toHaveAttribute("data-full-width", "true");
170 });
171
172 it("renders the logged-in profile route", async () => {
173 const { renderProfile } = renderRouter("#/profile");
174
175 await screen.findByTestId("profile-view");
176
177 expect(renderProfile.mock.lastCall?.[0].actor).toBeNull();
178 expect(screen.getByText("self-profile")).toBeInTheDocument();
179 });
180
181 it("passes the decoded actor on other profile routes", async () => {
182 const actor = "alice.bsky.social";
183 const { renderProfile } = renderRouter(`#${buildProfileRoute(actor)}`);
184
185 await screen.findByTestId("profile-view");
186
187 expect(renderProfile.mock.lastCall?.[0].actor).toBe(actor);
188 expect(screen.getByText(actor)).toBeInTheDocument();
189 });
190
191 it("renders hashtag routes inside the protected shell", async () => {
192 renderRouter("#/hashtag/solid");
193
194 await screen.findByTestId("hashtag-view");
195
196 expect(screen.getByText("hashtag")).toBeInTheDocument();
197 expect(screen.getByTestId("shell")).toHaveAttribute("data-full-width", "false");
198 });
199
200 it("renders encoded hashtag routes", async () => {
201 renderRouter("#/hashtag/%23solid");
202
203 await screen.findByTestId("hashtag-view");
204
205 expect(screen.getByText("hashtag")).toBeInTheDocument();
206 });
207
208 it("redirects first search visits to the embeddings preflight", async () => {
209 renderRouter("#/search");
210
211 await screen.findByTestId("search-preflight-view");
212
213 expect(screen.getByText("preflight")).toBeInTheDocument();
214 });
215
216 it("renders the search route once the preflight was already seen", async () => {
217 renderRouter("#/search", {
218 preferences: {
219 embeddingsConfig: {
220 enabled: false,
221 preflightSeen: true,
222 modelName: "nomic-embed-text-v1.5",
223 dimensions: 768,
224 downloaded: false,
225 downloadActive: false,
226 },
227 },
228 });
229
230 await screen.findByTestId("search-view");
231
232 expect(screen.getByText("search")).toBeInTheDocument();
233 });
234
235 it("does not redirect profile tab search visits to the embeddings preflight", async () => {
236 renderRouter("#/search?tab=profiles");
237
238 await screen.findByTestId("search-view");
239
240 expect(screen.getByText("search")).toBeInTheDocument();
241 });
242});