1import { isValidElement, type ReactElement, type ReactNode } from "react";
2import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
3import type { DesktopUpdateState } from "@t3tools/contracts";
4
5const testState = vi.hoisted(() => ({
6 addToast: vi.fn(),
7}));
8
9vi.mock("./ui/toast", () => ({
10 toastManager: { add: testState.addToast },
11}));
12
13import { showDesktopUpdateDownloadedToast } from "./desktopUpdate.toast";
14
15type ClickableElement = ReactElement<{ readonly onClick?: () => void }>;
16
17/** Walks the rendered description, invoking function components, to find the link button. */
18function findReleaseNotesLink(node: ReactNode): ClickableElement | null {
19 if (Array.isArray(node)) {
20 for (const child of node) {
21 const found = findReleaseNotesLink(child);
22 if (found) return found;
23 }
24 return null;
25 }
26 if (!isValidElement(node)) return null;
27 const element = node as ReactElement<{ readonly children?: ReactNode }>;
28 if (element.type === "button") return element as ClickableElement;
29 if (typeof element.type === "function") {
30 const render = element.type as (props: unknown) => ReactNode;
31 return findReleaseNotesLink(render(element.props));
32 }
33 return findReleaseNotesLink(element.props.children);
34}
35
36function getDescription(): ReactNode {
37 const toast = testState.addToast.mock.calls[0]?.[0] as { description?: ReactNode } | undefined;
38 return toast?.description ?? null;
39}
40
41function downloadedState(overrides: Partial<DesktopUpdateState> = {}): DesktopUpdateState {
42 return {
43 enabled: true,
44 status: "downloaded",
45 channel: "latest",
46 currentVersion: "0.0.29",
47 hostArch: "arm64",
48 appArch: "arm64",
49 runningUnderArm64Translation: false,
50 availableVersion: "0.0.30",
51 downloadedVersion: "0.0.30",
52 releaseNotes: [],
53 omittedReleaseCount: 0,
54 downloadPercent: 100,
55 checkedAt: null,
56 message: null,
57 errorContext: null,
58 canRetry: true,
59 ...overrides,
60 };
61}
62
63describe("showDesktopUpdateDownloadedToast", () => {
64 beforeEach(() => {
65 testState.addToast.mockReset();
66 });
67
68 it("opens the downloaded version's release notes", async () => {
69 const openExternal = vi.fn().mockResolvedValue(true);
70
71 showDesktopUpdateDownloadedToast({ openExternal }, downloadedState());
72 const link = findReleaseNotesLink(getDescription());
73 link?.props.onClick?.();
74 await vi.waitFor(() => {
75 expect(openExternal).toHaveBeenCalledWith(
76 "https://github.com/pingdotgg/t3code/releases/tag/v0.0.30",
77 );
78 });
79 expect(testState.addToast).toHaveBeenCalledTimes(1);
80 });
81
82 it("falls back to the version the download was started for", async () => {
83 const openExternal = vi.fn().mockResolvedValue(true);
84
85 // The `update-downloaded` event can land after the download RPC resolves.
86 showDesktopUpdateDownloadedToast(
87 { openExternal },
88 downloadedState({ downloadedVersion: null }),
89 );
90 findReleaseNotesLink(getDescription())?.props.onClick?.();
91
92 await vi.waitFor(() => {
93 expect(openExternal).toHaveBeenCalledWith(
94 "https://github.com/pingdotgg/t3code/releases/tag/v0.0.30",
95 );
96 });
97 });
98
99 it("omits the link when the updater reports no version at all", () => {
100 showDesktopUpdateDownloadedToast(
101 { openExternal: vi.fn() },
102 downloadedState({ availableVersion: null, downloadedVersion: null }),
103 );
104
105 expect(findReleaseNotesLink(getDescription())).toBeNull();
106 });
107
108 it.each([
109 ["returns false", vi.fn().mockResolvedValue(false)],
110 ["rejects", vi.fn().mockRejectedValue(new Error("open failed"))],
111 ])("shows an error when opening release notes %s", async (_description, openExternal) => {
112 showDesktopUpdateDownloadedToast({ openExternal }, downloadedState());
113 findReleaseNotesLink(getDescription())?.props.onClick?.();
114
115 await vi.waitFor(() => {
116 expect(testState.addToast).toHaveBeenLastCalledWith({
117 type: "error",
118 title: "Unable to open release notes",
119 });
120 });
121 });
122});