@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,370 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for Repository Setup Module
|
|
3
|
+
*
|
|
4
|
+
* Feature 20: Smart Repo Setup
|
|
5
|
+
* - Auto-creates {username}.github.io when available (no UI)
|
|
6
|
+
* - Shows UI only when root is taken
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, it, expect, beforeEach, vi, type Mock } from "vitest";
|
|
10
|
+
|
|
11
|
+
// Mock moss-api
|
|
12
|
+
const mockOpenBrowserWithHtml = vi.fn().mockResolvedValue(undefined);
|
|
13
|
+
const mockCloseBrowser = vi.fn().mockResolvedValue(undefined);
|
|
14
|
+
const mockOnEvent = vi.fn();
|
|
15
|
+
|
|
16
|
+
vi.mock("@symbiosis-lab/moss-api", () => ({
|
|
17
|
+
openBrowserWithHtml: (...args: unknown[]) => mockOpenBrowserWithHtml(...args),
|
|
18
|
+
closeBrowser: () => mockCloseBrowser(),
|
|
19
|
+
onEvent: (...args: unknown[]) => mockOnEvent(...args),
|
|
20
|
+
executeBinary: vi.fn().mockResolvedValue({ success: true, stdout: "", stderr: "" }),
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
// Mock utils
|
|
24
|
+
vi.mock("../utils", () => ({
|
|
25
|
+
reportProgress: vi.fn().mockResolvedValue(undefined),
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
// Mock token module
|
|
29
|
+
const mockGetToken = vi.fn();
|
|
30
|
+
const mockGetTokenFromGit = vi.fn();
|
|
31
|
+
const mockStoreToken = vi.fn();
|
|
32
|
+
|
|
33
|
+
vi.mock("../token", () => ({
|
|
34
|
+
getToken: () => mockGetToken(),
|
|
35
|
+
getTokenFromGit: () => mockGetTokenFromGit(),
|
|
36
|
+
storeToken: (token: string) => mockStoreToken(token),
|
|
37
|
+
}));
|
|
38
|
+
|
|
39
|
+
// Mock auth module
|
|
40
|
+
const mockPromptLogin = vi.fn();
|
|
41
|
+
const mockValidateToken = vi.fn();
|
|
42
|
+
const mockHasRequiredScopes = vi.fn();
|
|
43
|
+
|
|
44
|
+
vi.mock("../auth", () => ({
|
|
45
|
+
promptLogin: () => mockPromptLogin(),
|
|
46
|
+
validateToken: (token: string) => mockValidateToken(token),
|
|
47
|
+
hasRequiredScopes: (scopes: string[]) => mockHasRequiredScopes(scopes),
|
|
48
|
+
}));
|
|
49
|
+
|
|
50
|
+
// Mock github-api module
|
|
51
|
+
const mockGetAuthenticatedUser = vi.fn();
|
|
52
|
+
const mockCheckRepoExists = vi.fn();
|
|
53
|
+
const mockCreateRepository = vi.fn();
|
|
54
|
+
const mockGetRepoSshUrl = vi.fn();
|
|
55
|
+
|
|
56
|
+
vi.mock("../github-api", () => ({
|
|
57
|
+
getAuthenticatedUser: (token: string) => mockGetAuthenticatedUser(token),
|
|
58
|
+
checkRepoExists: (owner: string, name: string, token: string) => mockCheckRepoExists(owner, name, token),
|
|
59
|
+
createRepository: (name: string, token: string, description?: string) => mockCreateRepository(name, token, description),
|
|
60
|
+
getRepoSshUrl: (owner: string, repo: string, token: string) => mockGetRepoSshUrl(owner, repo, token),
|
|
61
|
+
}));
|
|
62
|
+
|
|
63
|
+
describe("ensureGitHubRepo", () => {
|
|
64
|
+
// Import will fail until we implement the function
|
|
65
|
+
let ensureGitHubRepo: () => Promise<{
|
|
66
|
+
name: string;
|
|
67
|
+
sshUrl: string;
|
|
68
|
+
fullName: string;
|
|
69
|
+
} | null>;
|
|
70
|
+
|
|
71
|
+
beforeEach(async () => {
|
|
72
|
+
vi.clearAllMocks();
|
|
73
|
+
|
|
74
|
+
// Dynamic import to get the function
|
|
75
|
+
const module = await import("../repo-setup");
|
|
76
|
+
ensureGitHubRepo = module.ensureGitHubRepo;
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe("authentication", () => {
|
|
80
|
+
it("returns null when no token available and login fails", async () => {
|
|
81
|
+
// No cached token
|
|
82
|
+
mockGetToken.mockResolvedValue(null);
|
|
83
|
+
mockGetTokenFromGit.mockResolvedValue(null);
|
|
84
|
+
// Login fails
|
|
85
|
+
mockPromptLogin.mockResolvedValue(false);
|
|
86
|
+
|
|
87
|
+
const result = await ensureGitHubRepo();
|
|
88
|
+
|
|
89
|
+
expect(result).toBeNull();
|
|
90
|
+
expect(mockPromptLogin).toHaveBeenCalled();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("uses cached token when available", async () => {
|
|
94
|
+
// Cached token exists
|
|
95
|
+
mockGetToken.mockResolvedValue("cached-token");
|
|
96
|
+
mockGetAuthenticatedUser.mockResolvedValue({ login: "testuser" });
|
|
97
|
+
// Root is available - auto create
|
|
98
|
+
mockCheckRepoExists.mockResolvedValue(false);
|
|
99
|
+
mockCreateRepository.mockResolvedValue({
|
|
100
|
+
name: "testuser.github.io",
|
|
101
|
+
fullName: "testuser/testuser.github.io",
|
|
102
|
+
sshUrl: "git@github.com:testuser/testuser.github.io.git",
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
const result = await ensureGitHubRepo();
|
|
106
|
+
|
|
107
|
+
expect(result).not.toBeNull();
|
|
108
|
+
expect(mockPromptLogin).not.toHaveBeenCalled();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("tries git credentials when no cached token", async () => {
|
|
112
|
+
// No cached token
|
|
113
|
+
mockGetToken.mockResolvedValue(null);
|
|
114
|
+
// Git credentials available
|
|
115
|
+
mockGetTokenFromGit.mockResolvedValue("git-token");
|
|
116
|
+
mockValidateToken.mockResolvedValue({ valid: true, scopes: ["repo", "workflow"], user: { login: "testuser" } });
|
|
117
|
+
mockHasRequiredScopes.mockReturnValue(true);
|
|
118
|
+
mockGetAuthenticatedUser.mockResolvedValue({ login: "testuser" });
|
|
119
|
+
// Root is available - auto create
|
|
120
|
+
mockCheckRepoExists.mockResolvedValue(false);
|
|
121
|
+
mockCreateRepository.mockResolvedValue({
|
|
122
|
+
name: "testuser.github.io",
|
|
123
|
+
fullName: "testuser/testuser.github.io",
|
|
124
|
+
sshUrl: "git@github.com:testuser/testuser.github.io.git",
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
const result = await ensureGitHubRepo();
|
|
128
|
+
|
|
129
|
+
expect(result).not.toBeNull();
|
|
130
|
+
expect(mockGetTokenFromGit).toHaveBeenCalled();
|
|
131
|
+
expect(mockStoreToken).toHaveBeenCalledWith("git-token");
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
describe("auto-create root repo when available", () => {
|
|
136
|
+
beforeEach(() => {
|
|
137
|
+
// Setup: Authenticated user
|
|
138
|
+
mockGetToken.mockResolvedValue("test-token");
|
|
139
|
+
mockGetAuthenticatedUser.mockResolvedValue({ login: "testuser" });
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("auto-creates {username}.github.io when available (no UI)", async () => {
|
|
143
|
+
// Root repo doesn't exist
|
|
144
|
+
mockCheckRepoExists.mockResolvedValue(false);
|
|
145
|
+
mockCreateRepository.mockResolvedValue({
|
|
146
|
+
name: "testuser.github.io",
|
|
147
|
+
fullName: "testuser/testuser.github.io",
|
|
148
|
+
htmlUrl: "https://github.com/testuser/testuser.github.io",
|
|
149
|
+
sshUrl: "git@github.com:testuser/testuser.github.io.git",
|
|
150
|
+
cloneUrl: "https://github.com/testuser/testuser.github.io.git",
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
const result = await ensureGitHubRepo();
|
|
154
|
+
|
|
155
|
+
// Should NOT show any UI
|
|
156
|
+
expect(mockOpenBrowserWithHtml).not.toHaveBeenCalled();
|
|
157
|
+
|
|
158
|
+
// Should create the root repo
|
|
159
|
+
expect(mockCheckRepoExists).toHaveBeenCalledWith("testuser", "testuser.github.io", "test-token");
|
|
160
|
+
expect(mockCreateRepository).toHaveBeenCalledWith("testuser.github.io", "test-token", expect.any(String));
|
|
161
|
+
|
|
162
|
+
// Should return correct result
|
|
163
|
+
expect(result).toEqual({
|
|
164
|
+
name: "testuser.github.io",
|
|
165
|
+
sshUrl: "git@github.com:testuser/testuser.github.io.git",
|
|
166
|
+
fullName: "testuser/testuser.github.io",
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("returns created repo info for root URL deployment", async () => {
|
|
171
|
+
mockCheckRepoExists.mockResolvedValue(false);
|
|
172
|
+
mockCreateRepository.mockResolvedValue({
|
|
173
|
+
name: "myuser.github.io",
|
|
174
|
+
fullName: "myuser/myuser.github.io",
|
|
175
|
+
sshUrl: "git@github.com:myuser/myuser.github.io.git",
|
|
176
|
+
});
|
|
177
|
+
mockGetAuthenticatedUser.mockResolvedValue({ login: "myuser" });
|
|
178
|
+
|
|
179
|
+
const result = await ensureGitHubRepo();
|
|
180
|
+
|
|
181
|
+
expect(result?.name).toBe("myuser.github.io");
|
|
182
|
+
expect(result?.fullName).toBe("myuser/myuser.github.io");
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
describe("show deploy choice UI when root is taken", () => {
|
|
187
|
+
beforeEach(() => {
|
|
188
|
+
// Setup: Authenticated user
|
|
189
|
+
mockGetToken.mockResolvedValue("test-token");
|
|
190
|
+
mockGetAuthenticatedUser.mockResolvedValue({ login: "testuser" });
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("shows deploy choice UI when {username}.github.io already exists", async () => {
|
|
194
|
+
// Root repo EXISTS
|
|
195
|
+
mockCheckRepoExists.mockResolvedValue(true);
|
|
196
|
+
|
|
197
|
+
// Simulate user choosing "replace-root"
|
|
198
|
+
mockOnEvent.mockImplementation(async (eventName: string, handler: (payload: unknown) => void) => {
|
|
199
|
+
if (eventName === "github:deploy-choice") {
|
|
200
|
+
setTimeout(() => {
|
|
201
|
+
handler({ action: "replace-root" });
|
|
202
|
+
}, 10);
|
|
203
|
+
}
|
|
204
|
+
return vi.fn();
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
mockGetRepoSshUrl.mockResolvedValue("git@github.com:testuser/testuser.github.io.git");
|
|
208
|
+
|
|
209
|
+
const result = await ensureGitHubRepo();
|
|
210
|
+
|
|
211
|
+
// Should use openBrowserWithHtml
|
|
212
|
+
expect(mockOpenBrowserWithHtml).toHaveBeenCalledWith(expect.any(String));
|
|
213
|
+
|
|
214
|
+
// Should listen for github:deploy-choice event (not github:repo-created)
|
|
215
|
+
expect(mockOnEvent).toHaveBeenCalledWith("github:deploy-choice", expect.any(Function));
|
|
216
|
+
|
|
217
|
+
// HTML should contain deploy choice elements
|
|
218
|
+
const html = mockOpenBrowserWithHtml.mock.calls[0][0] as string;
|
|
219
|
+
expect(html).toContain("already");
|
|
220
|
+
expect(html).toContain("replace-root");
|
|
221
|
+
expect(html).toContain("custom-domain");
|
|
222
|
+
expect(html).toContain("mossApi.emit('github:deploy-choice'");
|
|
223
|
+
expect(html).toContain("mossApi.close()");
|
|
224
|
+
expect(html).not.toContain("mossApi.submit");
|
|
225
|
+
expect(html).not.toContain("__TAURI__");
|
|
226
|
+
|
|
227
|
+
// Should NOT create a new repo — reuse existing root
|
|
228
|
+
expect(mockCreateRepository).not.toHaveBeenCalled();
|
|
229
|
+
expect(mockGetRepoSshUrl).toHaveBeenCalledWith("testuser", "testuser.github.io", "test-token");
|
|
230
|
+
|
|
231
|
+
// Should close browser after decision
|
|
232
|
+
expect(mockCloseBrowser).toHaveBeenCalled();
|
|
233
|
+
|
|
234
|
+
expect(result).toEqual({
|
|
235
|
+
name: "testuser.github.io",
|
|
236
|
+
sshUrl: "git@github.com:testuser/testuser.github.io.git",
|
|
237
|
+
fullName: "testuser/testuser.github.io",
|
|
238
|
+
});
|
|
239
|
+
}, 10000);
|
|
240
|
+
|
|
241
|
+
it("creates custom repo when user chooses 'custom-domain'", async () => {
|
|
242
|
+
// Root repo EXISTS
|
|
243
|
+
mockCheckRepoExists.mockResolvedValue(true);
|
|
244
|
+
|
|
245
|
+
// Simulate user choosing "custom-domain" with a repo name
|
|
246
|
+
mockOnEvent.mockImplementation(async (eventName: string, handler: (payload: unknown) => void) => {
|
|
247
|
+
if (eventName === "github:deploy-choice") {
|
|
248
|
+
setTimeout(() => {
|
|
249
|
+
handler({ action: "custom-domain", repoName: "my-website" });
|
|
250
|
+
}, 10);
|
|
251
|
+
}
|
|
252
|
+
return vi.fn();
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
mockCreateRepository.mockResolvedValue({
|
|
256
|
+
name: "my-website",
|
|
257
|
+
fullName: "testuser/my-website",
|
|
258
|
+
sshUrl: "git@github.com:testuser/my-website.git",
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
const result = await ensureGitHubRepo();
|
|
262
|
+
|
|
263
|
+
// Should create custom repo
|
|
264
|
+
expect(mockCreateRepository).toHaveBeenCalledWith("my-website", "test-token", "Created with moss");
|
|
265
|
+
// Should NOT fetch root repo SSH URL
|
|
266
|
+
expect(mockGetRepoSshUrl).not.toHaveBeenCalled();
|
|
267
|
+
|
|
268
|
+
// Should close browser after repo creation
|
|
269
|
+
expect(mockCloseBrowser).toHaveBeenCalled();
|
|
270
|
+
|
|
271
|
+
expect(result).toEqual({
|
|
272
|
+
name: "my-website",
|
|
273
|
+
sshUrl: "git@github.com:testuser/my-website.git",
|
|
274
|
+
fullName: "testuser/my-website",
|
|
275
|
+
});
|
|
276
|
+
}, 10000);
|
|
277
|
+
|
|
278
|
+
it("returns null when user cancels UI (null choice)", async () => {
|
|
279
|
+
// Root repo EXISTS
|
|
280
|
+
mockCheckRepoExists.mockResolvedValue(true);
|
|
281
|
+
|
|
282
|
+
// Event listener never fires — simulates timeout/cancel
|
|
283
|
+
mockOnEvent.mockImplementation(async () => {
|
|
284
|
+
return vi.fn();
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
// We just verify the structure is correct
|
|
288
|
+
expect(mockOnEvent).toBeDefined();
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it("includes repo name input with availability check in custom-domain card", async () => {
|
|
292
|
+
// Root repo EXISTS
|
|
293
|
+
mockCheckRepoExists.mockResolvedValue(true);
|
|
294
|
+
|
|
295
|
+
mockOnEvent.mockImplementation(async () => {
|
|
296
|
+
return vi.fn();
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
// Start the flow (will timeout, but we inspect HTML)
|
|
300
|
+
const resultPromise = ensureGitHubRepo();
|
|
301
|
+
await new Promise(resolve => setTimeout(resolve, 10));
|
|
302
|
+
|
|
303
|
+
const html = mockOpenBrowserWithHtml.mock.calls[0][0] as string;
|
|
304
|
+
|
|
305
|
+
// Should have repo name input
|
|
306
|
+
expect(html).toContain('id="repo-name"');
|
|
307
|
+
expect(html).toContain('autocomplete="off"');
|
|
308
|
+
expect(html).toContain('autocorrect="off"');
|
|
309
|
+
expect(html).toContain('spellcheck="false"');
|
|
310
|
+
|
|
311
|
+
// Should have availability check logic
|
|
312
|
+
expect(html).toContain("api.github.com/repos");
|
|
313
|
+
});
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
describe("error handling", () => {
|
|
317
|
+
beforeEach(() => {
|
|
318
|
+
mockGetToken.mockResolvedValue("test-token");
|
|
319
|
+
mockGetAuthenticatedUser.mockResolvedValue({ login: "testuser" });
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
it("returns null when repo creation fails", async () => {
|
|
323
|
+
mockCheckRepoExists.mockResolvedValue(false);
|
|
324
|
+
mockCreateRepository.mockRejectedValue(new Error("API rate limit exceeded"));
|
|
325
|
+
|
|
326
|
+
const result = await ensureGitHubRepo();
|
|
327
|
+
|
|
328
|
+
expect(result).toBeNull();
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it("returns null when getting user info fails", async () => {
|
|
332
|
+
mockGetAuthenticatedUser.mockRejectedValue(new Error("Token expired"));
|
|
333
|
+
|
|
334
|
+
const result = await ensureGitHubRepo();
|
|
335
|
+
|
|
336
|
+
expect(result).toBeNull();
|
|
337
|
+
});
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
describe("deploy choice UI input field attributes", () => {
|
|
341
|
+
beforeEach(() => {
|
|
342
|
+
mockGetToken.mockResolvedValue("test-token");
|
|
343
|
+
mockGetAuthenticatedUser.mockResolvedValue({ login: "testuser" });
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it("includes autocomplete, autocorrect, and spellcheck attributes on repo name input", async () => {
|
|
347
|
+
// Root repo EXISTS - triggers deploy choice UI
|
|
348
|
+
mockCheckRepoExists.mockResolvedValue(true);
|
|
349
|
+
|
|
350
|
+
mockOnEvent.mockImplementation(async () => {
|
|
351
|
+
return vi.fn();
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
const resultPromise = ensureGitHubRepo();
|
|
355
|
+
await new Promise(resolve => setTimeout(resolve, 10));
|
|
356
|
+
|
|
357
|
+
expect(mockOpenBrowserWithHtml).toHaveBeenCalled();
|
|
358
|
+
const html = mockOpenBrowserWithHtml.mock.calls[0][0] as string;
|
|
359
|
+
|
|
360
|
+
expect(html).toMatch(/<input[^>]*id="repo-name"[^>]*>/);
|
|
361
|
+
const inputMatch = html.match(/<input[^>]*id="repo-name"[^>]*>/);
|
|
362
|
+
expect(inputMatch).not.toBeNull();
|
|
363
|
+
|
|
364
|
+
const inputTag = inputMatch![0];
|
|
365
|
+
expect(inputTag).toContain('autocomplete="off"');
|
|
366
|
+
expect(inputTag).toContain('autocorrect="off"');
|
|
367
|
+
expect(inputTag).toContain('spellcheck="false"');
|
|
368
|
+
});
|
|
369
|
+
});
|
|
370
|
+
});
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for token storage module
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
6
|
+
import {
|
|
7
|
+
setupMockTauri,
|
|
8
|
+
type MockTauriContext,
|
|
9
|
+
} from "@symbiosis-lab/moss-api/testing";
|
|
10
|
+
import {
|
|
11
|
+
formatCredentialInput,
|
|
12
|
+
parseCredentialOutput,
|
|
13
|
+
getTokenFromGit,
|
|
14
|
+
} from "../token";
|
|
15
|
+
|
|
16
|
+
// token.ts no longer imports from utils — no mock needed
|
|
17
|
+
|
|
18
|
+
describe("token", () => {
|
|
19
|
+
describe("formatCredentialInput", () => {
|
|
20
|
+
it("formats basic protocol and host", () => {
|
|
21
|
+
const result = formatCredentialInput("github.com", "https");
|
|
22
|
+
expect(result).toBe("protocol=https\nhost=github.com\n");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("includes username when provided", () => {
|
|
26
|
+
const result = formatCredentialInput("github.com", "https", "x-access-token");
|
|
27
|
+
expect(result).toBe("protocol=https\nhost=github.com\nusername=x-access-token\n");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("includes password when provided", () => {
|
|
31
|
+
const result = formatCredentialInput("github.com", "https", "x-access-token", "ghp_abc123");
|
|
32
|
+
expect(result).toBe(
|
|
33
|
+
"protocol=https\nhost=github.com\nusername=x-access-token\npassword=ghp_abc123\n"
|
|
34
|
+
);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("handles password without username", () => {
|
|
38
|
+
const result = formatCredentialInput("github.com", "https", undefined, "ghp_abc123");
|
|
39
|
+
expect(result).toBe("protocol=https\nhost=github.com\npassword=ghp_abc123\n");
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe("parseCredentialOutput", () => {
|
|
44
|
+
it("parses username from output", () => {
|
|
45
|
+
const output = "protocol=https\nhost=github.com\nusername=x-access-token\n";
|
|
46
|
+
const result = parseCredentialOutput(output);
|
|
47
|
+
expect(result.username).toBe("x-access-token");
|
|
48
|
+
expect(result.password).toBeUndefined();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("parses password from output", () => {
|
|
52
|
+
const output = "protocol=https\nhost=github.com\npassword=ghp_abc123\n";
|
|
53
|
+
const result = parseCredentialOutput(output);
|
|
54
|
+
expect(result.password).toBe("ghp_abc123");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("parses both username and password", () => {
|
|
58
|
+
const output =
|
|
59
|
+
"protocol=https\nhost=github.com\nusername=x-access-token\npassword=ghp_abc123\n";
|
|
60
|
+
const result = parseCredentialOutput(output);
|
|
61
|
+
expect(result.username).toBe("x-access-token");
|
|
62
|
+
expect(result.password).toBe("ghp_abc123");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("handles empty output", () => {
|
|
66
|
+
const result = parseCredentialOutput("");
|
|
67
|
+
expect(result.username).toBeUndefined();
|
|
68
|
+
expect(result.password).toBeUndefined();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("handles passwords with equals signs", () => {
|
|
72
|
+
const output = "password=ghp_abc=123=def\n";
|
|
73
|
+
const result = parseCredentialOutput(output);
|
|
74
|
+
expect(result.password).toBe("ghp_abc=123=def");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("handles extra fields gracefully", () => {
|
|
78
|
+
const output = "protocol=https\nhost=github.com\npath=repo\nusername=user\npassword=pass\n";
|
|
79
|
+
const result = parseCredentialOutput(output);
|
|
80
|
+
expect(result.username).toBe("user");
|
|
81
|
+
expect(result.password).toBe("pass");
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// =========================================================================
|
|
86
|
+
// getTokenFromGit tests (Bug 8: Git credential helper integration)
|
|
87
|
+
// =========================================================================
|
|
88
|
+
describe("getTokenFromGit", () => {
|
|
89
|
+
let ctx: MockTauriContext;
|
|
90
|
+
|
|
91
|
+
beforeEach(() => {
|
|
92
|
+
ctx = setupMockTauri();
|
|
93
|
+
vi.clearAllMocks();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
afterEach(() => {
|
|
97
|
+
ctx.cleanup();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("returns token from git credential helper when available", async () => {
|
|
101
|
+
// Mock git credential fill returning a valid token
|
|
102
|
+
ctx.binaryConfig.setResult("git credential fill", {
|
|
103
|
+
success: true,
|
|
104
|
+
exitCode: 0,
|
|
105
|
+
stdout: "protocol=https\nhost=github.com\nusername=x-access-token\npassword=ghp_xxxxx\n",
|
|
106
|
+
stderr: "",
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const token = await getTokenFromGit();
|
|
110
|
+
expect(token).toBe("ghp_xxxxx");
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("returns null when no credentials in git", async () => {
|
|
114
|
+
// Mock git credential fill failing (no credentials stored)
|
|
115
|
+
ctx.binaryConfig.setResult("git credential fill", {
|
|
116
|
+
success: false,
|
|
117
|
+
exitCode: 1,
|
|
118
|
+
stdout: "",
|
|
119
|
+
stderr: "credential helper quit",
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
const token = await getTokenFromGit();
|
|
123
|
+
expect(token).toBeNull();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("returns null when git credential helper returns empty password", async () => {
|
|
127
|
+
// Mock git credential fill returning no password
|
|
128
|
+
ctx.binaryConfig.setResult("git credential fill", {
|
|
129
|
+
success: true,
|
|
130
|
+
exitCode: 0,
|
|
131
|
+
stdout: "protocol=https\nhost=github.com\n",
|
|
132
|
+
stderr: "",
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
const token = await getTokenFromGit();
|
|
136
|
+
expect(token).toBeNull();
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("handles git command not found gracefully", async () => {
|
|
140
|
+
// Mock git not being available
|
|
141
|
+
ctx.binaryConfig.setResult("git credential fill", {
|
|
142
|
+
success: false,
|
|
143
|
+
exitCode: 127,
|
|
144
|
+
stdout: "",
|
|
145
|
+
stderr: "git: command not found",
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
const token = await getTokenFromGit();
|
|
149
|
+
expect(token).toBeNull();
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
});
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for utils.ts
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
6
|
+
import * as mossApi from "@symbiosis-lab/moss-api";
|
|
7
|
+
|
|
8
|
+
// Mock the moss-api module
|
|
9
|
+
vi.mock("@symbiosis-lab/moss-api", () => ({
|
|
10
|
+
setMessageContext: vi.fn(),
|
|
11
|
+
sendMessage: vi.fn().mockResolvedValue(undefined),
|
|
12
|
+
reportProgress: vi.fn().mockResolvedValue(undefined),
|
|
13
|
+
reportError: vi.fn().mockResolvedValue(undefined),
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
// Import after mocking
|
|
17
|
+
import {
|
|
18
|
+
setCurrentHookName,
|
|
19
|
+
reportProgress,
|
|
20
|
+
reportError,
|
|
21
|
+
sleep,
|
|
22
|
+
} from "../utils";
|
|
23
|
+
|
|
24
|
+
describe("utils", () => {
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
vi.clearAllMocks();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe("setCurrentHookName", () => {
|
|
30
|
+
it("calls setMessageContext with plugin name and hook name", () => {
|
|
31
|
+
setCurrentHookName("testHook");
|
|
32
|
+
|
|
33
|
+
expect(mossApi.setMessageContext).toHaveBeenCalledWith("github", "testHook");
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe("reportProgress", () => {
|
|
38
|
+
it("forwards to SDK reportProgress", async () => {
|
|
39
|
+
await reportProgress("building", 5, 10, "Processing files...");
|
|
40
|
+
|
|
41
|
+
expect(mossApi.reportProgress).toHaveBeenCalledWith(
|
|
42
|
+
"building",
|
|
43
|
+
5,
|
|
44
|
+
10,
|
|
45
|
+
"Processing files..."
|
|
46
|
+
);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("works without optional message", async () => {
|
|
50
|
+
await reportProgress("uploading", 3, 5);
|
|
51
|
+
|
|
52
|
+
expect(mossApi.reportProgress).toHaveBeenCalledWith(
|
|
53
|
+
"uploading",
|
|
54
|
+
3,
|
|
55
|
+
5,
|
|
56
|
+
undefined
|
|
57
|
+
);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe("reportError", () => {
|
|
62
|
+
it("forwards to SDK reportError", async () => {
|
|
63
|
+
await reportError("Something went wrong", "deployment");
|
|
64
|
+
|
|
65
|
+
expect(mossApi.reportError).toHaveBeenCalledWith(
|
|
66
|
+
"Something went wrong",
|
|
67
|
+
"deployment",
|
|
68
|
+
false
|
|
69
|
+
);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("handles fatal errors", async () => {
|
|
73
|
+
await reportError("Critical failure", "authentication", true);
|
|
74
|
+
|
|
75
|
+
expect(mossApi.reportError).toHaveBeenCalledWith(
|
|
76
|
+
"Critical failure",
|
|
77
|
+
"authentication",
|
|
78
|
+
true
|
|
79
|
+
);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("works without optional context", async () => {
|
|
83
|
+
await reportError("Error occurred");
|
|
84
|
+
|
|
85
|
+
expect(mossApi.reportError).toHaveBeenCalledWith(
|
|
86
|
+
"Error occurred",
|
|
87
|
+
undefined,
|
|
88
|
+
false
|
|
89
|
+
);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe("sleep", () => {
|
|
94
|
+
it("resolves after specified time", async () => {
|
|
95
|
+
vi.useFakeTimers();
|
|
96
|
+
|
|
97
|
+
const promise = sleep(100);
|
|
98
|
+
|
|
99
|
+
// Fast-forward time
|
|
100
|
+
vi.advanceTimersByTime(100);
|
|
101
|
+
|
|
102
|
+
await expect(promise).resolves.toBeUndefined();
|
|
103
|
+
|
|
104
|
+
vi.useRealTimers();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("delays execution for the correct duration", async () => {
|
|
108
|
+
vi.useFakeTimers();
|
|
109
|
+
|
|
110
|
+
let resolved = false;
|
|
111
|
+
sleep(50).then(() => { resolved = true; });
|
|
112
|
+
|
|
113
|
+
// Not resolved yet
|
|
114
|
+
expect(resolved).toBe(false);
|
|
115
|
+
|
|
116
|
+
// Advance 49ms - still not resolved
|
|
117
|
+
vi.advanceTimersByTime(49);
|
|
118
|
+
await Promise.resolve(); // flush microtasks
|
|
119
|
+
expect(resolved).toBe(false);
|
|
120
|
+
|
|
121
|
+
// Advance 1 more ms - now resolved
|
|
122
|
+
vi.advanceTimersByTime(1);
|
|
123
|
+
await Promise.resolve(); // flush microtasks
|
|
124
|
+
expect(resolved).toBe(true);
|
|
125
|
+
|
|
126
|
+
vi.useRealTimers();
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
});
|