1import type { DesktopUpdateState } from "@t3tools/contracts";
2import { isValidElement, type MouseEvent, type ReactElement, type ReactNode } from "react";
3import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
4
5const testState = vi.hoisted(() => ({
6 addToast: vi.fn(),
7}));
8
9vi.mock("../ui/toast", () => ({
10 toastManager: { add: testState.addToast },
11}));
12
13import { SidebarUpdateReleaseNotes } from "./SidebarUpdateReleaseNotes";
14
15type AnchorElement = ReactElement<{
16 readonly children?: ReactNode;
17 readonly href?: string;
18 readonly onClick?: (event: MouseEvent<HTMLAnchorElement>) => void;
19}>;
20
21const baseState: DesktopUpdateState = {
22 enabled: true,
23 status: "available",
24 channel: "nightly",
25 currentVersion: "0.0.35",
26 hostArch: "arm64",
27 appArch: "arm64",
28 runningUnderArm64Translation: false,
29 availableVersion: "0.0.36-nightly.3",
30 downloadedVersion: null,
31 releaseNotes: [],
32 omittedReleaseCount: 0,
33 downloadPercent: null,
34 checkedAt: null,
35 message: null,
36 errorContext: null,
37 canRetry: false,
38};
39
40function collectAnchors(node: ReactNode, anchors: AnchorElement[] = []): AnchorElement[] {
41 if (Array.isArray(node)) {
42 for (const child of node) collectAnchors(child, anchors);
43 return anchors;
44 }
45 if (!isValidElement(node)) return anchors;
46
47 const element = node as ReactElement<{ readonly children?: ReactNode }>;
48 if (typeof element.type === "function") {
49 const render = element.type as (props: unknown) => ReactNode;
50 return collectAnchors(render(element.props), anchors);
51 }
52 if (element.type === "a") anchors.push(element as AnchorElement);
53 return collectAnchors(element.props.children, anchors);
54}
55
56function textContent(node: ReactNode): string {
57 if (typeof node === "string" || typeof node === "number") return String(node);
58 if (Array.isArray(node)) return node.map(textContent).join("");
59 if (!isValidElement(node)) return "";
60 const element = node as ReactElement<{ readonly children?: ReactNode }>;
61 return textContent(element.props.children);
62}
63
64function renderNotes(state: DesktopUpdateState, openExternal = vi.fn().mockResolvedValue(true)) {
65 return SidebarUpdateReleaseNotes({
66 shell: { openExternal },
67 state,
68 tooltip: "Update available",
69 });
70}
71
72describe("SidebarUpdateReleaseNotes", () => {
73 beforeEach(() => {
74 testState.addToast.mockReset();
75 });
76
77 it("links each preview to its exact release and labels hidden changes", () => {
78 const anchors = collectAnchors(
79 renderNotes({
80 ...baseState,
81 releaseNotes: [
82 { version: "0.0.36-nightly.3", items: ["Change 3"], totalItems: 1 },
83 { version: "0.0.36-nightly.2", items: ["Change 2"], totalItems: 2 },
84 { version: "0.0.36-nightly.1", items: ["Change 1", "Earlier"], totalItems: 4 },
85 ],
86 }),
87 );
88
89 expect(anchors.map(({ props }) => props.href)).toEqual([
90 "https://github.com/pingdotgg/t3code/releases/tag/v0.0.36-nightly.3",
91 "https://github.com/pingdotgg/t3code/releases/tag/v0.0.36-nightly.2",
92 "https://github.com/pingdotgg/t3code/releases/tag/v0.0.36-nightly.1",
93 ]);
94 expect(anchors.map(({ props }) => textContent(props.children))).toEqual([
95 "View release on GitHub",
96 "1 more change on GitHub",
97 "2 more changes on GitHub",
98 ]);
99 });
100
101 it("links omitted releases to release history", () => {
102 const anchors = collectAnchors(
103 renderNotes({
104 ...baseState,
105 releaseNotes: [{ version: "0.0.36-nightly.3", items: ["Change 3"], totalItems: 1 }],
106 omittedReleaseCount: 1,
107 }),
108 );
109
110 expect(anchors.at(-1)?.props.href).toBe("https://github.com/pingdotgg/t3code/releases");
111 expect(textContent(anchors.at(-1)?.props.children)).toBe("1 older release on GitHub");
112 });
113
114 it("shows plural history text for multiple omitted releases", () => {
115 const anchors = collectAnchors(
116 renderNotes({
117 ...baseState,
118 releaseNotes: [{ version: "0.0.36-nightly.3", items: ["Change 3"], totalItems: 1 }],
119 omittedReleaseCount: 3,
120 }),
121 );
122
123 expect(textContent(anchors.at(-1)?.props.children)).toBe("3 older releases on GitHub");
124 });
125
126 it("reports a release link that fails to open", async () => {
127 const openExternal = vi.fn().mockResolvedValue(false);
128 const [anchor] = collectAnchors(
129 renderNotes(
130 {
131 ...baseState,
132 releaseNotes: [{ version: "0.0.36-nightly.3", items: ["Change 3"], totalItems: 1 }],
133 },
134 openExternal,
135 ),
136 );
137 const preventDefault = vi.fn();
138
139 anchor?.props.onClick?.({ preventDefault } as unknown as MouseEvent<HTMLAnchorElement>);
140
141 expect(preventDefault).toHaveBeenCalledOnce();
142 await vi.waitFor(() => {
143 expect(openExternal).toHaveBeenCalledWith(
144 "https://github.com/pingdotgg/t3code/releases/tag/v0.0.36-nightly.3",
145 );
146 expect(testState.addToast).toHaveBeenCalledWith({
147 type: "error",
148 title: "Unable to open release notes",
149 });
150 });
151 });
152});