@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.
- package/CHANGELOG.md +30 -0
- package/README.md +18 -0
- package/assets/icon.svg +3 -0
- package/assets/manifest.json +19 -0
- package/e2e/deploy-api.test.ts +1129 -0
- package/e2e/moss-cli.test.ts +478 -0
- package/features/auth/device-flow.feature +41 -0
- package/features/deploy/validation.feature +50 -0
- package/features/steps/auth.steps.ts +285 -0
- package/features/steps/deploy.steps.ts +354 -0
- package/package.json +51 -0
- package/src/__tests__/auth-flow.integration.test.ts +738 -0
- package/src/__tests__/auth.test.ts +147 -0
- package/src/__tests__/configure-domain.test.ts +263 -0
- package/src/__tests__/deploy.integration.test.ts +798 -0
- package/src/__tests__/git.test.ts +190 -0
- package/src/__tests__/github-api.test.ts +761 -0
- package/src/__tests__/github-deploy.test.ts +2411 -0
- package/src/__tests__/progress-timeout.test.ts +209 -0
- package/src/__tests__/repo-setup-progress.test.ts +367 -0
- package/src/__tests__/repo-setup.test.ts +370 -0
- package/src/__tests__/token.test.ts +152 -0
- package/src/__tests__/utils.test.ts +129 -0
- package/src/__tests__/workflow.test.ts +146 -0
- package/src/auth.ts +588 -0
- package/src/constants.ts +7 -0
- package/src/git.ts +60 -0
- package/src/github-api.ts +601 -0
- package/src/github-deploy.ts +593 -0
- package/src/main.ts +646 -0
- package/src/repo-setup.ts +685 -0
- package/src/token.ts +202 -0
- package/src/types.ts +91 -0
- package/src/utils.ts +108 -0
- package/src/workflow.ts +79 -0
- package/test-helpers/mock-github-api.ts +217 -0
- package/tsconfig.json +20 -0
- 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
|
+
});
|