1import { act, create, type ReactTestRenderer } from "react-test-renderer";
2import { beforeEach, afterEach, expect, it, vi } from "vite-plus/test";
3
4const state = vi.hoisted(() => ({
5 id: 0,
6 saved: false,
7 session: {
8 _tag: "Success",
9 waiting: false,
10 value: { authenticated: true, permissions: ["orchestration:operate"] },
11 },
12 add: vi.fn((_notice: unknown) => "notice"),
13 close: vi.fn(),
14 save: vi.fn(),
15 navigate: vi.fn(),
16}));
17vi.mock("@effect/atom-react", () => ({ useAtomValue: () => state.session }));
18vi.mock("@tanstack/react-router", () => ({ useNavigate: () => state.navigate }));
19vi.mock("../state/environments", () => ({
20 useEnvironments: () => ({
21 environments: [{ environmentId: `env-${state.id}`, label: "Work laptop" }],
22 }),
23}));
24vi.mock("../state/session", () => ({ environmentSession: { sessionStateAtom: () => null } }));
25vi.mock("../hooks/useLocalStorage", () => ({
26 getLocalStorageItem: () => state.saved,
27 setLocalStorageItem: (...args: unknown[]) => state.save(...args),
28}));
29vi.mock("./ui/toast", () => ({ toastManager: { add: state.add, close: state.close } }));
30import { PermissionUpdateNotice } from "./PermissionUpdateNotice";
31
32let renderer: ReactTestRenderer;
33beforeEach(() => {
34 state.id++;
35 state.saved = false;
36 state.session = {
37 _tag: "Success",
38 waiting: false,
39 value: { authenticated: true, permissions: ["orchestration:operate"] },
40 };
41 vi.clearAllMocks();
42});
43afterEach(async () => {
44 if (renderer) await act(async () => renderer.unmount());
45});
46async function render() {
47 await act(async () => {
48 renderer = create(<PermissionUpdateNotice />);
49 });
50}
51
52it("keeps the notice visible and persists dismissal, with a route to pairing settings", async () => {
53 await render();
54 const notice = state.add.mock.calls[0]![0] as unknown as {
55 timeout: number;
56 onClose: () => void;
57 actionProps: { onClick: () => void };
58 };
59 expect(notice.timeout).toBe(0);
60 expect(state.save).not.toHaveBeenCalled();
61 notice.actionProps.onClick();
62 expect(state.navigate).toHaveBeenCalledWith({ to: "/settings/connections" });
63 expect(state.close).toHaveBeenCalledWith("notice");
64 expect(state.save).toHaveBeenCalledWith(
65 `t3code:permission-update:v1:env-${state.id}`,
66 true,
67 expect.anything(),
68 );
69});
70it("does not repeat on session refresh or remount", async () => {
71 await render();
72 await act(async () => renderer.unmount());
73 state.session = { ...state.session };
74 await render();
75 expect(state.add).toHaveBeenCalledTimes(1);
76});
77it("skips a notice dismissed on an earlier launch", async () => {
78 state.saved = true;
79 await render();
80 expect(state.add).not.toHaveBeenCalled();
81});
82it("waits for a successful, settled session check", async () => {
83 state.session._tag = "Failure";
84 await render();
85 expect(state.add).not.toHaveBeenCalled();
86 state.session = { ...state.session, _tag: "Success", waiting: true };
87 await act(async () => renderer.update(<PermissionUpdateNotice />));
88 expect(state.add).not.toHaveBeenCalled();
89 state.session = { ...state.session, waiting: false };
90 await act(async () => renderer.update(<PermissionUpdateNotice />));
91 expect(state.add).toHaveBeenCalledTimes(1);
92});
93it("does not warn for a granular grant", async () => {
94 state.session.value.permissions.push("filesystem:read");
95 await render();
96 expect(state.add).not.toHaveBeenCalled();
97});
98
99it("persists the secondary dismissal without relying on the toast close callback", async () => {
100 await render();
101 const notice = state.add.mock.calls[0]![0] as {
102 data: { secondaryActionProps: { onClick: () => void } };
103 };
104 notice.data.secondaryActionProps.onClick();
105 expect(state.save).toHaveBeenCalledWith(
106 `t3code:permission-update:v1:env-${state.id}`,
107 true,
108 expect.anything(),
109 );
110 expect(state.close).toHaveBeenCalledWith("notice");
111});