| 1 | import type { DesktopUpdateState } from "@t3tools/contracts"; |
| 2 | import { isValidElement, type MouseEvent, type ReactElement, type ReactNode } from "react"; |
| 3 | import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; |
| 4 | |
| 5 | const testState = vi.hoisted(() => ({ |
| 6 | addToast: vi.fn(), |
| 7 | })); |
| 8 | |
| 9 | vi.mock("../ui/toast", () => ({ |
| 10 | toastManager: { add: testState.addToast }, |
| 11 | })); |
| 12 | |
| 13 | import { SidebarUpdateReleaseNotes } from "./SidebarUpdateReleaseNotes"; |
| 14 | |
| 15 | type AnchorElement = ReactElement<{ |
| 16 | readonly children?: ReactNode; |
| 17 | readonly href?: string; |
| 18 | readonly onClick?: (event: MouseEvent<HTMLAnchorElement>) => void; |
| 19 | }>; |
| 20 | |
| 21 | const 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 | |
| 40 | function 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 | |
| 56 | function 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 | |
| 64 | function renderNotes(state: DesktopUpdateState, openExternal = vi.fn().mockResolvedValue(true)) { |
| 65 | return SidebarUpdateReleaseNotes({ |
| 66 | shell: { openExternal }, |
| 67 | state, |
| 68 | tooltip: "Update available", |
| 69 | }); |
| 70 | } |
| 71 | |
| 72 | describe("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 | }); |