Skip to content
Source
Sign inSign up

GitHubStarPrompt.test.ts

199 lines7.3 KB

1import { EnvironmentId } from "@t3tools/contracts";
2import { describe, expect, it, vi } from "vite-plus/test";
3​
4import type { toastManager } from "./ui/toast";
5​
6const { add, update, openExternal, commands, starred, page } = vi.hoisted(() => ({
7 add: vi.fn<typeof toastManager.add>(),
8 update: vi.fn<typeof toastManager.update>(),
9 openExternal: vi.fn(async (_url: string) => {}),
10 commands: { checkStarred: {}, star: {} },
11 starred: { value: false, failed: false, starWorks: true, duringCheck: () => {} },
12 page: {
13 focused: true,
14 activeElement: null as { matches: (selector: string) => boolean } | null,
15 events: new EventTarget(),
16 },
17}));
18vi.mock("./ui/toast", () => ({ toastManager: { add, update, close: vi.fn() } }));
19vi.mock("../localApi", () => ({ readLocalApi: () => ({ shell: { openExternal } }) }));
20vi.mock("../rpc/atomRegistry", () => ({ appAtomRegistry: {} }));
21vi.mock("../state/sourceControl", () => ({ sourceControlEnvironment: commands }));
22vi.mock("@t3tools/client-runtime/state/runtime", () => ({
23 runAtomCommand: async (_registry: unknown, command: unknown) => {
24 if (command === commands.star) return { _tag: "Success", value: starred.starWorks };
25 starred.duringCheck();
26 return starred.failed ? { _tag: "Failure" } : { _tag: "Success", value: starred.value };
27 },
28}));
29​
30const environmentId = EnvironmentId.make("environment-1");
31const IDLE_MS = 30_000;
32​
33function setup({ sent = 0, writable = true, alreadyStarred = false } = {}) {
34 add.mockReset();
35 update.mockReset();
36 openExternal.mockClear();
37 starred.value = alreadyStarred;
38 starred.failed = false;
39 starred.starWorks = true;
40 starred.duringCheck = () => {};
41 page.focused = true;
42 page.activeElement = null;
43 page.events = new EventTarget();
44 const store = new Map([["t3code:github-star-prompt:sent", String(sent)]]);
45 vi.stubGlobal("document", {
46 hasFocus: () => page.focused,
47 get activeElement() {
48 return page.activeElement;
49 },
50 });
51 vi.stubGlobal("window", {
52 addEventListener: page.events.addEventListener.bind(page.events),
53 removeEventListener: page.events.removeEventListener.bind(page.events),
54 localStorage: {
55 getItem: (key: string) => store.get(key) ?? null,
56 setItem: (key: string, value: string) => {
57 if (!writable) throw new Error("QuotaExceededError");
58 store.set(key, value);
59 },
60 },
61 });
62 return store;
64​
65// A fresh module import stands in for an app reload; storage survives it.
66async function startSession() {
67 vi.resetModules();
68 const session = await import("./GitHubStarPrompt");
69 return {
70 // Sends a prompt, then leaves the page idle long enough to ask.
71 send: async () => {
72 const sent = session.recordPromptSent(environmentId);
73 await vi.advanceTimersByTimeAsync(IDLE_MS);
74 await sent;
75 },
76 sendWithoutWaiting: () => void session.recordPromptSent(environmentId),
77 };
79​
80// The toast's handlers ignore their click event.
81const click = (props: unknown) => (props as { onClick: () => unknown }).onClick();
82​
83describe("recordPromptSent", () => {
84 vi.useFakeTimers();
85​
86 it("asks only after the focused page is idle", async () => {
87 setup({ sent: 5 });
88 (await startSession()).sendWithoutWaiting();
89 await vi.advanceTimersByTimeAsync(IDLE_MS - 1);
90 page.events.dispatchEvent(new Event("keydown"));
91 await vi.advanceTimersByTimeAsync(IDLE_MS - 1);
92 expect(add).not.toHaveBeenCalled();
93​
94 page.focused = false;
95 await vi.advanceTimersByTimeAsync(IDLE_MS);
96 expect(add).not.toHaveBeenCalled();
97​
98 page.focused = true;
99 page.activeElement = { matches: (selector) => selector.includes("iframe") };
100 page.events.dispatchEvent(new Event("focus"));
101 await vi.advanceTimersByTimeAsync(IDLE_MS);
102 expect(add).not.toHaveBeenCalled();
103​
104 page.activeElement = null;
105 page.events.dispatchEvent(new Event("pointerdown"));
106 await vi.advanceTimersByTimeAsync(IDLE_MS);
107 expect(add).toHaveBeenCalledOnce();
108 vi.unstubAllGlobals();
109 });
110​
111 it("asks after five prompts, once per session, and never again once closed", async () => {
112 setup();
113 let session = await startSession();
114 for (let sent = 0; sent < 5; sent++) await session.send();
115 expect(add).not.toHaveBeenCalled();
116​
117 await session.send();
118 await session.send();
119 expect(add).toHaveBeenCalledOnce();
120​
121 session = await startSession();
122 await session.send();
123 expect(add).toHaveBeenCalledTimes(2);
124​
125 add.mock.lastCall?.[0].onClose?.();
126 session = await startSession();
127 for (let sent = 0; sent < 10; sent++) await session.send();
128 expect(add).toHaveBeenCalledTimes(2);
129 vi.unstubAllGlobals();
130 });
131​
132 it("never asks when a dismissal could not be saved", async () => {
133 setup({ sent: 5, writable: false });
134 await (await startSession()).send();
135 expect(add).not.toHaveBeenCalled();
136 vi.unstubAllGlobals();
137 });
138​
139 it("never asks once the environment's GitHub account starred the repo", async () => {
140 setup({ sent: 5, alreadyStarred: true });
141 await (await startSession()).send();
142 starred.value = false;
143 await (await startSession()).send();
144 expect(add).not.toHaveBeenCalled();
145 vi.unstubAllGlobals();
146 });
147​
148 it("skips an unavailable server check without permanently dismissing", async () => {
149 const store = setup({ sent: 5 });
150 starred.failed = true;
151 await (await startSession()).send();
152 expect(add).not.toHaveBeenCalled();
153 expect(store.has("t3code:github-star-prompt:dismissed")).toBe(false);
154 starred.failed = false;
155 await (await startSession()).send();
156 expect(add).toHaveBeenCalledOnce();
157 vi.unstubAllGlobals();
158 });
159​
160 it("never asks when another tab dismisses it during the star check", async () => {
161 const store = setup({ sent: 5 });
162 starred.duringCheck = () => store.set("t3code:github-star-prompt:dismissed", "true");
163 await (await startSession()).send();
164 expect(add).not.toHaveBeenCalled();
165 vi.unstubAllGlobals();
166 });
167 it("stars on click, opens GitHub when starring fails, and asks for a click once the click expired", async () => {
168 const store = setup({ sent: 5 });
169 await (await startSession()).send();
170 click(add.mock.lastCall?.[0].actionProps);
171 await vi.advanceTimersByTimeAsync(0);
172 expect(update.mock.lastCall?.[1]).toMatchObject({ title: "Thanks for the star!" });
173 expect(store.get("t3code:github-star-prompt:dismissed")).toBe("true");
174 expect(openExternal).not.toHaveBeenCalled();
175​
176 setup({ sent: 5 });
177 starred.starWorks = false;
178 await (await startSession()).send();
179 click(add.mock.lastCall?.[0].actionProps);
180 await vi.advanceTimersByTimeAsync(0);
181 expect(openExternal).toHaveBeenCalledWith("https://github.com/pingdotgg/t3code");
182 expect(update.mock.lastCall?.[1]).not.toMatchObject({
183 actionProps: { children: "Open GitHub" },
184 });
185​
186 setup({ sent: 5 });
187 starred.starWorks = false;
188 vi.stubGlobal("navigator", { userActivation: { isActive: false } });
189 await (await startSession()).send();
190 click(add.mock.lastCall?.[0].actionProps);
191 await vi.advanceTimersByTimeAsync(0);
192 const fallback = update.mock.lastCall?.[1];
193 expect(fallback).toMatchObject({ actionProps: { children: "Open GitHub" } });
194 expect(openExternal).not.toHaveBeenCalled();
195 click(typeof fallback === "object" ? fallback.actionProps : undefined);
196 expect(openExternal).toHaveBeenCalledOnce();
197 vi.unstubAllGlobals();
198 });
199});