1import { RegistryContext } from "@effect/atom-react";
2import { EnvironmentId, type MergebotRepoQueue } from "@t3tools/contracts";
3import * as Cause from "effect/Cause";
4import * as Option from "effect/Option";
5import { AsyncResult, AtomRegistry } from "effect/reactivity";
6import { act, useLayoutEffect } from "react";
7import { create, type ReactTestRenderer } from "react-test-renderer";
8import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test";
9
10import { useMergebotQueue } from "./mergebot";
11
12const state = await vi.hoisted(async () => {
13 const { AsyncResult, Atom } = await import("effect/reactivity");
14 const snapshot: MergebotRepoQueue = {
15 repo: "work/app",
16 url: "https://mergebot.example/work/app",
17 status: "ready",
18 processing: true,
19 pullRequests: [
20 {
21 number: 123,
22 headSha: "head",
23 lifecycle: "open",
24 queueStatus: "waiting_ci",
25 position: 1,
26 mergeEtaAt: null,
27 url: "https://mergebot.example/work/app/pull/123",
28 },
29 ],
30 };
31 return {
32 snapshot,
33 queue: Atom.make<AsyncResult.AsyncResult<MergebotRepoQueue | null, Error>>(
34 AsyncResult.success(snapshot),
35 ),
36 connection: Atom.make(AsyncResult.success({ phase: "connected" })),
37 };
38});
39
40vi.mock("@t3tools/client-runtime/state/pull-requests", () => ({
41 createMergebotQueueAtomFamily: () => () => state.queue,
42}));
43vi.mock("../connection/runtime", () => ({ connectionAtomRuntime: undefined }));
44vi.mock("../connection/catalog", () => ({
45 environmentCatalog: { stateAtom: () => state.connection },
46}));
47
48let registry: AtomRegistry.AtomRegistry;
49let renderer: ReactTestRenderer;
50let observed: MergebotRepoQueue | null;
51
52function Probe() {
53 const value = useMergebotQueue(EnvironmentId.make("local"), "work/app");
54 useLayoutEffect(() => {
55 observed = value;
56 });
57 return null;
58}
59
60beforeEach(() => {
61 registry = AtomRegistry.make();
62 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
63 act(() => {
64 renderer = create(
65 <RegistryContext.Provider value={registry}>
66 <Probe />
67 </RegistryContext.Provider>,
68 );
69 });
70});
71
72afterEach(() => {
73 act(() => renderer.unmount());
74 registry.dispose();
75 vi.unstubAllGlobals();
76});
77
78it("keeps a same-query success visible during a routine refresh", () => {
79 expect(observed).toEqual(state.snapshot);
80 act(() => registry.set(state.queue, AsyncResult.success(state.snapshot, { waiting: true })));
81 expect(observed).toEqual(state.snapshot);
82});
83
84it("drops a failed refresh even when AsyncResult retains the prior successful snapshot", () => {
85 const failure = AsyncResult.failureWithPrevious(Cause.fail(new Error("offline")), {
86 previous: Option.some(AsyncResult.success(state.snapshot)),
87 });
88 expect(Option.isSome(AsyncResult.value(failure))).toBe(true);
89 act(() => registry.set(state.queue, failure));
90 expect(observed).toBeNull();
91});
92
93it("clears on disconnection and on a completed unavailable response", () => {
94 act(() => registry.set(state.queue, AsyncResult.success(state.snapshot, { waiting: true })));
95 act(() => registry.set(state.connection, AsyncResult.success({ phase: "backoff" })));
96 expect(observed).toBeNull();
97 act(() => {
98 registry.set(state.connection, AsyncResult.success({ phase: "connected" }));
99 registry.set(state.queue, AsyncResult.success(null));
100 });
101 expect(observed).toBeNull();
102});