@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,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
+ });