Open History to see the last commit
1import { RegistryContext } from "@effect/atom-react";
2import {
3 EnvironmentId,
4 ProjectId,
5 ThreadId,
6 WS_METHODS,
7 type PullRequestRef,
8} from "@t3tools/contracts";
9import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
10import {
11 EnvironmentRegistry,
12 EnvironmentSupervisor,
13 AVAILABLE_CONNECTION_STATE,
14 PrimaryConnectionTarget,
15 type SupervisorConnectionState,
16 type PreparedConnection,
17 type ConnectionCatalogEntry,
18} from "@t3tools/client-runtime/connection";
19import type { RpcSession } from "@t3tools/client-runtime/rpc";
20import * as Effect from "effect/Effect";
21import * as Latch from "effect/Latch";
22import * as Option from "effect/Option";
23import * as Stream from "effect/Stream";
24import * as SubscriptionRef from "effect/SubscriptionRef";
25import { AtomRegistry } from "effect/reactivity";
26import { Activity, act, useState } from "react";
27import { createRoot } from "react-dom/client";
28import { expect, it } from "@effect/vitest";
29import { vi } from "vite-plus/test";
30
31const fixture = vi.hoisted(() => ({
32 environment: undefined as EnvironmentRegistry.EnvironmentRegistry["Service"] | undefined,
33 active: "active",
34 listeners: new Set<() => void>(),
35}));
36
37vi.mock("react-native", () => ({
38 AppState: {
39 get currentState() {
40 return fixture.active;
41 },
42 addEventListener: (_event: string, listener: () => void) => {
43 fixture.listeners.add(listener);
44 return { remove: () => fixture.listeners.delete(listener) };
45 },
46 },
47}));
48vi.mock("../connection/runtime", async () => {
49 const { Atom } = await import("effect/reactivity");
50 const Layer = await import("effect/Layer");
51 const { EnvironmentRegistry } = await import("@t3tools/client-runtime/connection");
52 return {
53 connectionAtomRuntime: Atom.runtime(
54 Layer.suspend(() =>
55 Layer.succeed(EnvironmentRegistry.EnvironmentRegistry, fixture.environment!),
56 ),
57 ),
58 };
59});
60vi.mock("./server", async () => {
61 const { Atom } = await import("effect/reactivity");
62 const config = Atom.make({ environment: { capabilities: { threadPullRequests: true } } });
63 return { serverEnvironment: { configValueAtom: () => config } };
64});
65
66import { ThreadPrDemandProvider } from "./thread-pr-demand";
67import { useThreadPr } from "./use-thread-pr";
68
69const environmentId = EnvironmentId.make("sidebar-environment");
70const reference = {
71 projectId: ProjectId.make("project"),
72 repository: "acme/app",
73 number: 12,
74 url: "https://github.com/acme/app/pull/12",
75};
76function thread(overrides: Partial<EnvironmentThreadShell> = {}): EnvironmentThreadShell {
77 return {
78 environmentId,
79 id: ThreadId.make("thread"),
80 pullRequests: [],
81 linkedPullRequest: null,
82 branchPullRequest: reference,
83 ...overrides,
84 } as EnvironmentThreadShell;
85}
86
87const setup = Effect.fn("TestThreadPr.setup")(function* () {
88 (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
89 vi.useFakeTimers({
90 toFake: ["Date", "setTimeout", "clearTimeout", "setInterval", "clearInterval"],
91 });
92 vi.setSystemTime(0);
93 fixture.active = "active";
94 fixture.listeners.clear();
95 const requests: Array<{ environmentId: EnvironmentId; input: PullRequestRef }> = [];
96 let state = "open";
97 let replyGate: Latch.Latch | null = null;
98 const target = new PrimaryConnectionTarget({
99 environmentId,
100 label: "Sidebar environment",
101 httpBaseUrl: "https://sidebar.test",
102 wsBaseUrl: "wss://sidebar.test",
103 });
104 const supervisor = yield* Effect.gen(function* () {
105 return EnvironmentSupervisor.EnvironmentSupervisor.of({
106 target,
107 state: yield* SubscriptionRef.make<SupervisorConnectionState>({
108 ...AVAILABLE_CONNECTION_STATE,
109 phase: "connected" as const,
110 desired: true,
111 network: "online" as const,
112 attempt: 1,
113 generation: 1,
114 }),
115 session: yield* SubscriptionRef.make(
116 Option.some({
117 client: {
118 [WS_METHODS.pullRequestsSummary]: (input: PullRequestRef) =>
119 Effect.gen(function* () {
120 requests.push({ environmentId: currentEnvironment, input });
121 if (replyGate !== null) yield* replyGate.await;
122 return {
123 ...input,
124 provider: "github",
125 state,
126 title: `Change ${input.number}`,
127 url: `https://github.com/${input.repository}/pull/${input.number}`,
128 baseBranch: "main",
129 headBranch: "feature",
130 isDraft: false,
131 updatedAt: null,
132 };
133 }),
134 [WS_METHODS.pullRequestsSubscribeRefreshes]: () => Stream.never,
135 },
136 ready: Effect.void,
137 } as unknown as RpcSession),
138 ),
139 prepared: yield* SubscriptionRef.make(Option.none<PreparedConnection>()),
140 connect: Effect.void,
141 disconnect: Effect.void,
142 retryNow: Effect.void,
143 });
144 });
145 let currentEnvironment = environmentId;
146 const environment = {
147 entries: yield* SubscriptionRef.make<ReadonlyMap<EnvironmentId, ConnectionCatalogEntry>>(
148 new Map(),
149 ),
150 run: (id, effect) => {
151 currentEnvironment = id;
152 return Effect.provideService(effect, EnvironmentSupervisor.EnvironmentSupervisor, supervisor);
153 },
154 followStream: (_id, stream) =>
155 Stream.provideService(stream, EnvironmentSupervisor.EnvironmentSupervisor, supervisor),
156 stateChanges: () => SubscriptionRef.changes(supervisor.state),
157 } satisfies Pick<
158 EnvironmentRegistry.EnvironmentRegistry["Service"],
159 "entries" | "run" | "followStream" | "stateChanges"
160 >;
161 fixture.environment =
162 environment as unknown as EnvironmentRegistry.EnvironmentRegistry["Service"];
163 const registry = AtomRegistry.make();
164 const container = document.createElement("div");
165 const root = createRoot(container);
166 let mounts = 0;
167 function Row({ value, name }: { value: EnvironmentThreadShell; name: string }) {
168 const [passive] = useState(() => ++mounts);
169 const pr = useThreadPr(value);
170 return (
171 <output data-name={name} data-passive={passive}>
172 {pr?.accessibilityLabel ?? "none"}
173 </output>
174 );
175 }
176 return {
177 requests,
178 get mounts() {
179 return mounts;
180 },
181 setState(next: string) {
182 state = next;
183 },
184 holdReplies() {
185 replyGate = Latch.makeUnsafe();
186 },
187 async releaseReplies() {
188 await act(async () => {
189 replyGate?.openUnsafe();
190 replyGate = null;
191 });
192 },
193 badge(name = "sidebar") {
194 return container.querySelector(`output[data-name="${name}"]`)?.textContent;
195 },
196 passive() {
197 return container.querySelector("output")?.getAttribute("data-passive");
198 },
199 async render({
200 visible = true,
201 value = thread(),
202 shared = false,
203 activity = "visible",
204 }: {
205 visible?: boolean;
206 value?: EnvironmentThreadShell;
207 shared?: boolean;
208 activity?: "visible" | "hidden";
209 } = {}) {
210 await act(async () =>
211 root.render(
212 <RegistryContext value={registry}>
213 <Activity mode={activity}>
214 <div hidden={!visible} style={{ width: 320 }}>
215 <ThreadPrDemandProvider visible={visible}>
216 <Row value={value} name="sidebar" />
217 </ThreadPrDemandProvider>
218 </div>
219 </Activity>
220 {shared ? <Row value={thread({ id: ThreadId.make("shared") })} name="shared" /> : null}
221 </RegistryContext>,
222 ),
223 );
224 },
225 async advance(ms: number) {
226 await act(async () => vi.advanceTimersByTimeAsync(ms));
227 },
228 async appState(next: string) {
229 await act(async () => {
230 fixture.active = next;
231 for (const listener of fixture.listeners) listener();
232 });
233 },
234 [Symbol.dispose]() {
235 act(() => root.unmount());
236 registry.dispose();
237 expect(fixture.listeners.size).toBe(0);
238 vi.useRealTimers();
239 },
240 };
241});
242
243it.effect(
244 "releases retained hidden sidebar demand and keeps the badge while reveal revalidates",
245 () =>
246 Effect.gen(function* () {
247 using h = yield* setup();
248 yield* Effect.promise(async () => {
249 await h.render();
250 expect(h.badge()).toBe("#12 pull request open");
251 const passive = h.passive();
252 expect(h.requests).toHaveLength(1);
253 await h.render({ visible: false });
254 await h.advance(10_000);
255 const afterGrace = h.requests.length;
256 await h.advance(180_000);
257 expect(h.requests.length - afterGrace).toBe(0);
258 expect(h.passive()).toBe(passive);
259 h.holdReplies();
260 h.setState("closed");
261 await h.render();
262 expect(h.badge()).toBe("#12 pull request open");
263 expect(h.requests.length).toBe(afterGrace + 1);
264 expect(h.mounts).toBe(1);
265 await h.releaseReplies();
266 expect(h.badge()).toBe("#12 pull request closed");
267 });
268 }),
269);
270
271it.effect("does not start a fallback query for a sidebar first mounted hidden", () =>
272 Effect.gen(function* () {
273 using h = yield* setup();
274 yield* Effect.promise(async () => {
275 await h.render({ visible: false });
276 await h.advance(180_000);
277 expect(h.requests).toHaveLength(0);
278 expect(h.badge()).toBe("none");
279 await h.render();
280 expect(h.requests).toHaveLength(1);
281 expect(h.badge()).toBe("#12 pull request open");
282 expect(h.mounts).toBe(1);
283 });
284 }),
285);
286
287it.effect("keeps a shared visible reader polling while its sidebar reader is hidden", () =>
288 Effect.gen(function* () {
289 using h = yield* setup();
290 yield* Effect.promise(async () => {
291 await h.render({ shared: true });
292 expect(h.requests).toHaveLength(1);
293 await h.render({ visible: false, shared: true });
294 await h.advance(180_000);
295 expect(h.requests).toHaveLength(4);
296 expect(h.badge("shared")).toBe("#12 pull request open");
297 h.setState("closed");
298 await h.advance(60_000);
299 expect(h.requests).toHaveLength(5);
300 expect(h.badge("shared")).toBe("#12 pull request closed");
301 await h.render({ shared: true });
302 expect(h.badge()).toBe("#12 pull request closed");
303 });
304 }),
305);
306
307it.effect.each(["inactive", "background"])("releases sidebar demand while the OS is %s", (state) =>
308 Effect.gen(function* () {
309 using h = yield* setup();
310 yield* Effect.promise(async () => {
311 await h.render();
312 await h.appState(state);
313 await h.advance(10_000);
314 const before = h.requests.length;
315 await h.advance(180_000);
316 expect(h.requests.length - before).toBe(0);
317 h.holdReplies();
318 await h.appState("active");
319 expect(h.badge()).toBe("#12 pull request open");
320 expect(h.requests.length - before).toBe(1);
321 expect(h.mounts).toBe(1);
322 });
323 }),
324);
325
326it.effect("keeps mounted passive state through an Android-style Activity tab switch", () =>
327 Effect.gen(function* () {
328 using h = yield* setup();
329 yield* Effect.promise(async () => {
330 await h.render();
331 const passive = h.passive();
332 await h.render({ activity: "hidden" });
333 await h.advance(10_000);
334 const before = h.requests.length;
335 await h.advance(180_000);
336 expect(h.requests.length - before).toBe(0);
337 h.holdReplies();
338 await h.render();
339 expect(h.badge()).toBe("#12 pull request open");
340 expect(h.passive()).toBe(passive);
341 expect(h.mounts).toBe(1);
342 expect(h.requests.length - before).toBe(1);
343 });
344 }),
345);