@symbiosis-lab/moss-plugin-github 1.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/README.md +18 -0
  3. package/assets/icon.svg +3 -0
  4. package/assets/manifest.json +19 -0
  5. package/e2e/deploy-api.test.ts +1129 -0
  6. package/e2e/moss-cli.test.ts +478 -0
  7. package/features/auth/device-flow.feature +41 -0
  8. package/features/deploy/validation.feature +50 -0
  9. package/features/steps/auth.steps.ts +285 -0
  10. package/features/steps/deploy.steps.ts +354 -0
  11. package/package.json +51 -0
  12. package/src/__tests__/auth-flow.integration.test.ts +738 -0
  13. package/src/__tests__/auth.test.ts +147 -0
  14. package/src/__tests__/configure-domain.test.ts +263 -0
  15. package/src/__tests__/deploy.integration.test.ts +798 -0
  16. package/src/__tests__/git.test.ts +190 -0
  17. package/src/__tests__/github-api.test.ts +761 -0
  18. package/src/__tests__/github-deploy.test.ts +2411 -0
  19. package/src/__tests__/progress-timeout.test.ts +209 -0
  20. package/src/__tests__/repo-setup-progress.test.ts +367 -0
  21. package/src/__tests__/repo-setup.test.ts +370 -0
  22. package/src/__tests__/token.test.ts +152 -0
  23. package/src/__tests__/utils.test.ts +129 -0
  24. package/src/__tests__/workflow.test.ts +146 -0
  25. package/src/auth.ts +588 -0
  26. package/src/constants.ts +7 -0
  27. package/src/git.ts +60 -0
  28. package/src/github-api.ts +601 -0
  29. package/src/github-deploy.ts +593 -0
  30. package/src/main.ts +646 -0
  31. package/src/repo-setup.ts +685 -0
  32. package/src/token.ts +202 -0
  33. package/src/types.ts +91 -0
  34. package/src/utils.ts +108 -0
  35. package/src/workflow.ts +79 -0
  36. package/test-helpers/mock-github-api.ts +217 -0
  37. package/tsconfig.json +20 -0
  38. package/vitest.config.ts +50 -0
@@ -0,0 +1,209 @@
1
+ /**
2
+ * Tests for progress reporting timeout fix
3
+ *
4
+ * Bug: GitHub Pages polling times out because reportProgress is called AFTER sleep,
5
+ * causing 60-second inactivity timeout (6 iterations × 5s sleep + API calls = >60s)
6
+ *
7
+ * Fix: Move reportProgress to START of each iteration (before sleep) to reset
8
+ * the inactivity timer every 5 seconds.
9
+ */
10
+
11
+ import { describe, it, expect, vi, beforeEach } from "vitest";
12
+
13
+ describe("Progress Timeout Fix", () => {
14
+ beforeEach(() => {
15
+ vi.clearAllMocks();
16
+ });
17
+
18
+ describe("waitForPagesLive polling loop", () => {
19
+ it("calls reportProgress BEFORE sleep to reset inactivity timer", async () => {
20
+ // This test simulates the polling loop structure
21
+ const reportProgress = vi.fn().mockResolvedValue(undefined);
22
+ const checkPagesStatus = vi.fn();
23
+
24
+ // Simulate 3 iterations where status is "building"
25
+ checkPagesStatus.mockResolvedValue({ status: "building" });
26
+
27
+ const maxAttempts = 3;
28
+ const pollInterval = 100; // Short interval for testing
29
+
30
+ // Track the order of operations
31
+ const operations: string[] = [];
32
+
33
+ // Simulate the CORRECT polling loop (reportProgress BEFORE sleep)
34
+ for (let i = 0; i < maxAttempts; i++) {
35
+ operations.push("check-status");
36
+ await checkPagesStatus();
37
+
38
+ // Still building - report progress THEN sleep
39
+ if (i < maxAttempts - 1) {
40
+ operations.push(`report-progress-${i + 1}`);
41
+ await reportProgress("verifying", 9, 10, `Waiting for GitHub Pages... (${i + 1}/${maxAttempts})`);
42
+
43
+ operations.push(`sleep-${i + 1}`);
44
+ await new Promise(resolve => setTimeout(resolve, pollInterval));
45
+ }
46
+ }
47
+
48
+ // Verify order: For each iteration, progress is reported BEFORE sleep
49
+ expect(operations).toEqual([
50
+ "check-status",
51
+ "report-progress-1",
52
+ "sleep-1",
53
+ "check-status",
54
+ "report-progress-2",
55
+ "sleep-2",
56
+ "check-status",
57
+ ]);
58
+
59
+ // Verify reportProgress was called exactly 2 times (not on last iteration)
60
+ expect(reportProgress).toHaveBeenCalledTimes(2);
61
+
62
+ // Verify each call has the correct iteration number (total=10 for REST API flow)
63
+ expect(reportProgress).toHaveBeenNthCalledWith(1, "verifying", 9, 10, "Waiting for GitHub Pages... (1/3)");
64
+ expect(reportProgress).toHaveBeenNthCalledWith(2, "verifying", 9, 10, "Waiting for GitHub Pages... (2/3)");
65
+ });
66
+
67
+ it("demonstrates WRONG pattern (reportProgress AFTER sleep)", async () => {
68
+ // This test shows the buggy pattern for comparison
69
+ const reportProgress = vi.fn().mockResolvedValue(undefined);
70
+ const checkPagesStatus = vi.fn();
71
+
72
+ checkPagesStatus.mockResolvedValue({ status: "building" });
73
+
74
+ const maxAttempts = 3;
75
+ const pollInterval = 100;
76
+
77
+ const operations: string[] = [];
78
+
79
+ // Simulate the BUGGY polling loop (sleep BEFORE reportProgress)
80
+ for (let i = 0; i < maxAttempts; i++) {
81
+ operations.push("check-status");
82
+ await checkPagesStatus();
83
+
84
+ // Still building - sleep THEN report progress (WRONG!)
85
+ if (i < maxAttempts - 1) {
86
+ operations.push(`sleep-${i + 1}`);
87
+ await new Promise(resolve => setTimeout(resolve, pollInterval));
88
+
89
+ operations.push(`report-progress-${i + 1}`);
90
+ await reportProgress("verifying", 9, 10, `Waiting for GitHub Pages... (${i + 1}/${maxAttempts})`);
91
+ }
92
+ }
93
+
94
+ // In buggy pattern: sleep happens BEFORE progress report
95
+ // This means the inactivity timer is NOT reset until after the sleep
96
+ expect(operations).toEqual([
97
+ "check-status",
98
+ "sleep-1",
99
+ "report-progress-1",
100
+ "check-status",
101
+ "sleep-2",
102
+ "report-progress-2",
103
+ "check-status",
104
+ ]);
105
+
106
+ // This demonstrates the problem: with 6 iterations × 5s sleep = 30s of sleeping
107
+ // Plus API call time, we exceed 60s before resetting the timer
108
+ });
109
+
110
+ it("resets inactivity timer every 5 seconds with correct ordering", async () => {
111
+ // Mock time to track when reportProgress is called relative to sleep
112
+ vi.useFakeTimers();
113
+ const startTime = Date.now();
114
+
115
+ const reportProgress = vi.fn().mockResolvedValue(undefined);
116
+ const checkPagesStatus = vi.fn().mockResolvedValue({ status: "building" });
117
+
118
+ const maxAttempts = 6;
119
+ const pollInterval = 5000; // Real interval: 5 seconds
120
+
121
+ const progressCallTimes: number[] = [];
122
+
123
+ // Wrap reportProgress to record timing
124
+ const timedReportProgress = async (...args: Parameters<typeof reportProgress>) => {
125
+ progressCallTimes.push(Date.now() - startTime);
126
+ await reportProgress(...args);
127
+ };
128
+
129
+ // Start polling loop
130
+ const pollingPromise = (async () => {
131
+ for (let i = 0; i < maxAttempts; i++) {
132
+ await checkPagesStatus();
133
+
134
+ if (i < maxAttempts - 1) {
135
+ // CORRECT: Report progress BEFORE sleep
136
+ await timedReportProgress("verifying", 9, 10, `Waiting for GitHub Pages... (${i + 1}/${maxAttempts})`);
137
+ await new Promise(resolve => setTimeout(resolve, pollInterval));
138
+ }
139
+ }
140
+ })();
141
+
142
+ // Advance time and flush promises for each iteration
143
+ for (let i = 0; i < maxAttempts - 1; i++) {
144
+ await vi.advanceTimersByTimeAsync(pollInterval);
145
+ }
146
+
147
+ await pollingPromise;
148
+
149
+ // Verify progress is reported at the START of each 5-second interval
150
+ // Intervals should be: 0ms, 5000ms, 10000ms, 15000ms, 20000ms
151
+ expect(progressCallTimes).toHaveLength(5); // 6 attempts - 1 (no progress on last)
152
+
153
+ // Each call should be ~5000ms apart
154
+ for (let i = 1; i < progressCallTimes.length; i++) {
155
+ const interval = progressCallTimes[i] - progressCallTimes[i - 1];
156
+ expect(interval).toBe(5000);
157
+ }
158
+
159
+ // First call should happen at time 0 (relative to start)
160
+ expect(progressCallTimes[0]).toBe(0);
161
+
162
+ vi.useRealTimers();
163
+ });
164
+
165
+ it("keeps total polling time under 60 seconds with proper progress reporting", async () => {
166
+ // With 6 attempts × 5s interval = 30s total sleep time
167
+ // Plus API call overhead (~1s per call) = ~36s total
168
+ // This is well under the 60s inactivity timeout when progress is reported correctly
169
+
170
+ const maxAttempts = 6;
171
+ const pollInterval = 5000;
172
+ const apiCallTime = 1000; // Mock API call taking 1s
173
+
174
+ let totalTime = 0;
175
+
176
+ // Simulate timing of correct approach
177
+ for (let i = 0; i < maxAttempts; i++) {
178
+ // API call
179
+ totalTime += apiCallTime;
180
+
181
+ if (i < maxAttempts - 1) {
182
+ // Progress report (resets timer) - negligible time
183
+ totalTime += 1;
184
+
185
+ // Sleep
186
+ totalTime += pollInterval;
187
+ }
188
+ }
189
+
190
+ // Total time should be under 60s (inactivity timeout)
191
+ expect(totalTime).toBeLessThan(60000);
192
+
193
+ // Actual calculation: 6 × 1000 (API) + 5 × 5000 (sleep) + 5 × 1 (report) = 31005ms
194
+ expect(totalTime).toBe(31005);
195
+ });
196
+ });
197
+
198
+ describe("deploy heartbeat interval", () => {
199
+ it("heartbeat interval must be shorter than progress panel stale timeout (15s)", async () => {
200
+ // The progress panel auto-removes tasks after STALE_TIMEOUT_MS (15s).
201
+ // If the deploy heartbeat fires less frequently than that, the progress
202
+ // bar disappears between heartbeats — making it invisible during long pushes.
203
+ const { DEPLOY_HEARTBEAT_INTERVAL_MS } = await import("../constants");
204
+ const STALE_TIMEOUT_MS = 15_000; // from src/preview/progress-panel.ts
205
+
206
+ expect(DEPLOY_HEARTBEAT_INTERVAL_MS).toBeLessThan(STALE_TIMEOUT_MS);
207
+ });
208
+ });
209
+ });
@@ -0,0 +1,367 @@
1
+ /**
2
+ * Tests for Phase 3 fix: Progress heartbeat during interactive form
3
+ *
4
+ * Problem: The 60-second inactivity timer expires while user fills out the
5
+ * "Choose repository name" form because no progress updates are sent during showBrowserForm().
6
+ *
7
+ * Solution: Wrap showBrowserForm with a progress heartbeat that sends updates
8
+ * every DEPLOY_HEARTBEAT_INTERVAL_MS (10s) to keep the inactivity timer alive
9
+ * and the progress panel visible (must be < STALE_TIMEOUT_MS of 15s).
10
+ */
11
+
12
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
13
+
14
+ // Mock moss-api
15
+ const mockOpenBrowserWithHtml = vi.fn().mockResolvedValue(undefined);
16
+ const mockCloseBrowser = vi.fn().mockResolvedValue(undefined);
17
+ const mockOnEvent = vi.fn();
18
+
19
+ vi.mock("@symbiosis-lab/moss-api", () => ({
20
+ openBrowserWithHtml: (...args: unknown[]) => mockOpenBrowserWithHtml(...args),
21
+ closeBrowser: () => mockCloseBrowser(),
22
+ onEvent: (...args: unknown[]) => mockOnEvent(...args),
23
+ executeBinary: vi.fn().mockResolvedValue({ success: true, stdout: "", stderr: "" }),
24
+ }));
25
+
26
+ // Mock utils
27
+ const mockReportProgress = vi.fn().mockResolvedValue(undefined);
28
+
29
+ vi.mock("../utils", () => ({
30
+ reportProgress: (...args: unknown[]) => mockReportProgress(...args),
31
+ }));
32
+
33
+ // Mock token module
34
+ const mockGetToken = vi.fn();
35
+ const mockGetTokenFromGit = vi.fn();
36
+ const mockStoreToken = vi.fn();
37
+
38
+ vi.mock("../token", () => ({
39
+ getToken: () => mockGetToken(),
40
+ getTokenFromGit: () => mockGetTokenFromGit(),
41
+ storeToken: (token: string) => mockStoreToken(token),
42
+ }));
43
+
44
+ // Mock auth module
45
+ const mockPromptLogin = vi.fn();
46
+ const mockValidateToken = vi.fn();
47
+ const mockHasRequiredScopes = vi.fn();
48
+
49
+ vi.mock("../auth", () => ({
50
+ promptLogin: () => mockPromptLogin(),
51
+ validateToken: (token: string) => mockValidateToken(token),
52
+ hasRequiredScopes: (scopes: string[]) => mockHasRequiredScopes(scopes),
53
+ }));
54
+
55
+ // Mock github-api module
56
+ const mockGetAuthenticatedUser = vi.fn();
57
+ const mockCheckRepoExists = vi.fn();
58
+ const mockCreateRepository = vi.fn();
59
+ const mockGetRepoSshUrl = vi.fn();
60
+
61
+ vi.mock("../github-api", () => ({
62
+ getAuthenticatedUser: (token: string) => mockGetAuthenticatedUser(token),
63
+ checkRepoExists: (owner: string, name: string, token: string) => mockCheckRepoExists(owner, name, token),
64
+ createRepository: (name: string, token: string, description?: string) => mockCreateRepository(name, token, description),
65
+ getRepoSshUrl: (owner: string, repo: string, token: string) => mockGetRepoSshUrl(owner, repo, token),
66
+ }));
67
+
68
+ describe("Phase 3: Progress Heartbeat During Interactive Form", () => {
69
+ let ensureGitHubRepo: () => Promise<{
70
+ name: string;
71
+ sshUrl: string;
72
+ fullName: string;
73
+ } | null>;
74
+
75
+ beforeEach(async () => {
76
+ vi.clearAllMocks();
77
+ vi.useRealTimers();
78
+
79
+ // Setup: Authenticated user with root repo taken (triggers UI)
80
+ mockGetToken.mockResolvedValue("test-token");
81
+ mockGetAuthenticatedUser.mockResolvedValue({ login: "testuser" });
82
+ mockCheckRepoExists.mockResolvedValue(true); // Root repo exists - triggers UI
83
+
84
+ // Dynamic import to get the function
85
+ const module = await import("../repo-setup");
86
+ ensureGitHubRepo = module.ensureGitHubRepo;
87
+ });
88
+
89
+ afterEach(() => {
90
+ vi.useRealTimers();
91
+ });
92
+
93
+ it("sends progress heartbeat every 30 seconds during form interaction", async () => {
94
+ vi.useFakeTimers();
95
+
96
+ // Simulate user taking 75 seconds to fill out form (should trigger 2 heartbeats)
97
+ let eventHandler: ((payload: unknown) => void) | null = null;
98
+ mockOnEvent.mockImplementation(async (eventName: string, handler: (payload: unknown) => void) => {
99
+ if (eventName === "github:deploy-choice") {
100
+ eventHandler = handler;
101
+ }
102
+ return vi.fn(); // Return unlisten function
103
+ });
104
+
105
+ mockCreateRepository.mockResolvedValue({
106
+ name: "my-website",
107
+ fullName: "testuser/my-website",
108
+ sshUrl: "git@github.com:testuser/my-website.git",
109
+ });
110
+
111
+ // Start the repo setup (will call openBrowserWithHtml and start heartbeat)
112
+ const resultPromise = ensureGitHubRepo();
113
+
114
+ // Fast-forward: 0s -> no heartbeat yet (first one starts immediately but we check calls)
115
+ await vi.advanceTimersByTimeAsync(0);
116
+ expect(mockReportProgress).not.toHaveBeenCalled(); // No progress yet
117
+
118
+ // Fast-forward: 10s -> first heartbeat
119
+ await vi.advanceTimersByTimeAsync(10000);
120
+ expect(mockReportProgress).toHaveBeenCalledTimes(1);
121
+ expect(mockReportProgress).toHaveBeenCalledWith("setup", 0, 6, "Setting up GitHub repository...");
122
+
123
+ // Fast-forward: 20s -> second heartbeat
124
+ await vi.advanceTimersByTimeAsync(10000);
125
+ expect(mockReportProgress).toHaveBeenCalledTimes(2);
126
+ expect(mockReportProgress).toHaveBeenNthCalledWith(2, "setup", 0, 6, "Setting up GitHub repository...");
127
+
128
+ // Fast-forward: 25s -> user submits form
129
+ await vi.advanceTimersByTimeAsync(5000);
130
+
131
+ // User submits form via event
132
+ eventHandler!({ action: "custom-domain", repoName: "my-website" });
133
+
134
+ // Wait for completion
135
+ await vi.runAllTimersAsync();
136
+ const result = await resultPromise;
137
+
138
+ // Total progress calls: 2 heartbeats during form
139
+ expect(mockReportProgress).toHaveBeenCalledTimes(2);
140
+
141
+ // Verify successful result
142
+ expect(result).toEqual({
143
+ name: "my-website",
144
+ sshUrl: "git@github.com:testuser/my-website.git",
145
+ fullName: "testuser/my-website",
146
+ });
147
+ });
148
+
149
+ it("clears heartbeat interval when form is submitted", async () => {
150
+ vi.useFakeTimers();
151
+
152
+ // User submits form after 15 seconds (should trigger 1 heartbeat, then clear)
153
+ let eventHandler: ((payload: unknown) => void) | null = null;
154
+ mockOnEvent.mockImplementation(async (eventName: string, handler: (payload: unknown) => void) => {
155
+ if (eventName === "github:deploy-choice") {
156
+ eventHandler = handler;
157
+ }
158
+ return vi.fn();
159
+ });
160
+
161
+ mockCreateRepository.mockResolvedValue({
162
+ name: "quick-submit",
163
+ fullName: "testuser/quick-submit",
164
+ sshUrl: "git@github.com:testuser/quick-submit.git",
165
+ });
166
+
167
+ // Start the repo setup
168
+ const resultPromise = ensureGitHubRepo();
169
+
170
+ // Fast-forward: 15s -> first heartbeat happens at 10s
171
+ await vi.advanceTimersByTimeAsync(15000);
172
+ expect(mockReportProgress).toHaveBeenCalledTimes(1);
173
+
174
+ // User submits form (should clear interval)
175
+ eventHandler!({ action: "custom-domain", repoName: "quick-submit" });
176
+
177
+ // Wait for completion
178
+ await vi.runAllTimersAsync();
179
+ await resultPromise;
180
+
181
+ // Reset mock to track future calls
182
+ mockReportProgress.mockClear();
183
+
184
+ // Fast-forward another 10s - NO more heartbeats should occur
185
+ await vi.advanceTimersByTimeAsync(10000);
186
+ expect(mockReportProgress).not.toHaveBeenCalled();
187
+ });
188
+
189
+ it("clears heartbeat interval when form is cancelled", async () => {
190
+ vi.useFakeTimers();
191
+
192
+ // User cancels form after 15 seconds (timeout without event)
193
+ mockOnEvent.mockImplementation(async () => {
194
+ return vi.fn(); // Don't trigger event handler (simulates cancellation)
195
+ });
196
+
197
+ // Start the repo setup (will timeout after 300s but we'll test before that)
198
+ const resultPromise = ensureGitHubRepo();
199
+
200
+ // Fast-forward: 15s -> first heartbeat happens at 10s
201
+ await vi.advanceTimersByTimeAsync(15000);
202
+ expect(mockReportProgress).toHaveBeenCalledTimes(1);
203
+
204
+ // Fast-forward to timeout (300s total)
205
+ await vi.advanceTimersByTimeAsync(285000);
206
+
207
+ // Wait for completion
208
+ await vi.runAllTimersAsync();
209
+ const result = await resultPromise;
210
+
211
+ expect(result).toBeNull();
212
+
213
+ // Reset mock to track future calls
214
+ mockReportProgress.mockClear();
215
+
216
+ // Fast-forward another 10s - NO more heartbeats should occur
217
+ await vi.advanceTimersByTimeAsync(10000);
218
+ expect(mockReportProgress).not.toHaveBeenCalled();
219
+ });
220
+
221
+ it("prevents timeout during long form interaction (>60s)", async () => {
222
+ vi.useFakeTimers();
223
+
224
+ // Simulate user taking 120 seconds (2 minutes) - should NOT timeout
225
+ let eventHandler: ((payload: unknown) => void) | null = null;
226
+ mockOnEvent.mockImplementation(async (eventName: string, handler: (payload: unknown) => void) => {
227
+ if (eventName === "github:deploy-choice") {
228
+ eventHandler = handler;
229
+ }
230
+ return vi.fn();
231
+ });
232
+
233
+ mockCreateRepository.mockResolvedValue({
234
+ name: "slow-user",
235
+ fullName: "testuser/slow-user",
236
+ sshUrl: "git@github.com:testuser/slow-user.git",
237
+ });
238
+
239
+ // Start the repo setup
240
+ const resultPromise = ensureGitHubRepo();
241
+
242
+ // Fast-forward: 120s in 10s increments to simulate heartbeats
243
+ await vi.advanceTimersByTimeAsync(10000); // 10s - 1st heartbeat
244
+ await vi.advanceTimersByTimeAsync(10000); // 20s - 2nd heartbeat
245
+ await vi.advanceTimersByTimeAsync(10000); // 30s - 3rd heartbeat
246
+ await vi.advanceTimersByTimeAsync(10000); // 40s - 4th heartbeat
247
+ await vi.advanceTimersByTimeAsync(80000); // 120s total
248
+
249
+ // Verify 12 heartbeats occurred (every 10s for 120s)
250
+ expect(mockReportProgress).toHaveBeenCalledTimes(12);
251
+
252
+ // User finally submits form
253
+ eventHandler!({ action: "custom-domain", repoName: "slow-user" });
254
+
255
+ // Wait for completion
256
+ await vi.runAllTimersAsync();
257
+ const result = await resultPromise;
258
+
259
+ // Should complete successfully - NO timeout
260
+ expect(result).toEqual({
261
+ name: "slow-user",
262
+ sshUrl: "git@github.com:testuser/slow-user.git",
263
+ fullName: "testuser/slow-user",
264
+ });
265
+ });
266
+
267
+ it("uses consistent progress message for all heartbeats", async () => {
268
+ vi.useFakeTimers();
269
+
270
+ let eventHandler: ((payload: unknown) => void) | null = null;
271
+ mockOnEvent.mockImplementation(async (eventName: string, handler: (payload: unknown) => void) => {
272
+ if (eventName === "github:deploy-choice") {
273
+ eventHandler = handler;
274
+ }
275
+ return vi.fn();
276
+ });
277
+
278
+ mockCreateRepository.mockResolvedValue({
279
+ name: "test",
280
+ fullName: "testuser/test",
281
+ sshUrl: "git@github.com:testuser/test.git",
282
+ });
283
+
284
+ // Start the repo setup
285
+ const resultPromise = ensureGitHubRepo();
286
+
287
+ // Trigger 3 heartbeats (10s each)
288
+ await vi.advanceTimersByTimeAsync(10000);
289
+ await vi.advanceTimersByTimeAsync(10000);
290
+ await vi.advanceTimersByTimeAsync(10000);
291
+
292
+ expect(mockReportProgress).toHaveBeenCalledTimes(3);
293
+
294
+ // All heartbeats should use the same message
295
+ expect(mockReportProgress).toHaveBeenNthCalledWith(1, "setup", 0, 6, "Setting up GitHub repository...");
296
+ expect(mockReportProgress).toHaveBeenNthCalledWith(2, "setup", 0, 6, "Setting up GitHub repository...");
297
+ expect(mockReportProgress).toHaveBeenNthCalledWith(3, "setup", 0, 6, "Setting up GitHub repository...");
298
+
299
+ // Complete
300
+ eventHandler!({ action: "custom-domain", repoName: "test" });
301
+ await vi.runAllTimersAsync();
302
+ await resultPromise;
303
+ });
304
+
305
+ it("handles openBrowserWithHtml rejection gracefully", async () => {
306
+ vi.useFakeTimers();
307
+
308
+ // Simulate error during browser opening
309
+ mockOpenBrowserWithHtml.mockRejectedValue(new Error("Form display error"));
310
+
311
+ // Start the repo setup (catch the error to prevent unhandled rejection)
312
+ let result: any;
313
+ let error: any;
314
+ try {
315
+ result = await ensureGitHubRepo();
316
+ } catch (e) {
317
+ error = e;
318
+ }
319
+
320
+ // Fast-forward to trigger any pending timers
321
+ await vi.runAllTimersAsync();
322
+
323
+ // Should return null on error (the wrapper catches and returns null)
324
+ // Or if it throws, that's also acceptable behavior
325
+ if (!error) {
326
+ expect(result).toBeNull();
327
+ }
328
+
329
+ // Reset mock to track future calls
330
+ mockReportProgress.mockClear();
331
+
332
+ // Interval should be cleared - no more heartbeats
333
+ await vi.advanceTimersByTimeAsync(30000);
334
+ expect(mockReportProgress).not.toHaveBeenCalled();
335
+ });
336
+
337
+ it("does not start heartbeat when auto-creating root repo (no UI)", async () => {
338
+ vi.useFakeTimers();
339
+
340
+ // Root repo doesn't exist - auto-create (no UI)
341
+ mockCheckRepoExists.mockResolvedValue(false);
342
+ mockCreateRepository.mockResolvedValue({
343
+ name: "testuser.github.io",
344
+ fullName: "testuser/testuser.github.io",
345
+ sshUrl: "git@github.com:testuser/testuser.github.io.git",
346
+ });
347
+
348
+ // Execute
349
+ const result = await ensureGitHubRepo();
350
+
351
+ // Fast-forward time - should be no heartbeats
352
+ await vi.advanceTimersByTimeAsync(60000);
353
+
354
+ // openBrowserWithHtml should NOT be called (auto-create path)
355
+ expect(mockOpenBrowserWithHtml).not.toHaveBeenCalled();
356
+
357
+ // reportProgress should NOT be called (no heartbeat)
358
+ expect(mockReportProgress).not.toHaveBeenCalled();
359
+
360
+ // Should succeed
361
+ expect(result).toEqual({
362
+ name: "testuser.github.io",
363
+ sshUrl: "git@github.com:testuser/testuser.github.io.git",
364
+ fullName: "testuser/testuser.github.io",
365
+ });
366
+ });
367
+ });