@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,798 @@
1
+ /**
2
+ * Integration tests for the on_deploy hook
3
+ *
4
+ * Uses @symbiosis-lab/moss-api/testing to mock Tauri IPC commands
5
+ * and test the full deployment flow with various scenarios.
6
+ *
7
+ * The deploy target is derived from .git origin (via getOriginOwnerRepo),
8
+ * not from a config file. No config.json, no validation.ts.
9
+ */
10
+
11
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
12
+ import {
13
+ setupMockTauri,
14
+ type MockTauriContext,
15
+ } from "@symbiosis-lab/moss-api/testing";
16
+ import type { DeployContext } from "../types";
17
+
18
+ // We need to mock the utils module to prevent actual IPC calls for logging
19
+ vi.mock("../utils", () => ({
20
+ reportProgress: vi.fn().mockResolvedValue(undefined),
21
+ reportError: vi.fn().mockResolvedValue(undefined),
22
+ reportComplete: vi.fn().mockResolvedValue(undefined),
23
+ setCurrentHookName: vi.fn(),
24
+ showToast: vi.fn().mockResolvedValue(undefined),
25
+ dismissToast: vi.fn().mockResolvedValue(undefined),
26
+ closeBrowser: vi.fn().mockResolvedValue(undefined),
27
+ sleep: vi.fn().mockResolvedValue(undefined),
28
+ }));
29
+
30
+ // Mock the github-deploy module
31
+ vi.mock("../github-deploy", () => ({
32
+ verifyRepoExists: vi.fn().mockResolvedValue(undefined),
33
+ getOriginOwnerRepo: vi.fn().mockResolvedValue({ owner: "test-user", repo: "test-repo" }),
34
+ deployViaGitPush: vi.fn(),
35
+ }));
36
+
37
+ // Mock the auth module
38
+ vi.mock("../auth", () => ({
39
+ promptLogin: vi.fn(),
40
+ checkAuthentication: vi.fn(),
41
+ validateToken: vi.fn(),
42
+ hasRequiredScopes: vi.fn(),
43
+ }));
44
+
45
+ // Mock the token module
46
+ vi.mock("../token", () => ({
47
+ getToken: vi.fn(),
48
+ getTokenFromGit: vi.fn(),
49
+ storeToken: vi.fn(),
50
+ clearToken: vi.fn(),
51
+ }));
52
+
53
+ // Mock the git module
54
+ vi.mock("../git", () => ({
55
+ buildPagesUrl: vi.fn().mockImplementation((owner: string, repo: string) => {
56
+ if (repo.toLowerCase() === `${owner.toLowerCase()}.github.io`) {
57
+ return `https://${owner}.github.io`;
58
+ }
59
+ return `https://${owner}.github.io/${repo}`;
60
+ }),
61
+ parseGitHubUrl: vi.fn().mockImplementation((remoteUrl: string) => {
62
+ const m = remoteUrl.match(/github\.com[:/]([^/]+)\/([^/.]+)/);
63
+ if (m) return { owner: m[1], repo: m[2] };
64
+ return null;
65
+ }),
66
+ }));
67
+
68
+ // Mock the repo-setup module
69
+ vi.mock("../repo-setup", () => ({
70
+ ensureGitHubRepo: vi.fn(),
71
+ }));
72
+
73
+ // Mock @symbiosis-lab/moss-api
74
+ vi.mock("@symbiosis-lab/moss-api", () => ({
75
+ readPluginFile: vi.fn(),
76
+ writePluginFile: vi.fn(),
77
+ pluginFileExists: vi.fn(),
78
+ listSiteFilesWithSizes: vi.fn().mockResolvedValue([]),
79
+ getTauriCore: vi.fn().mockReturnValue({
80
+ invoke: vi.fn().mockResolvedValue("git"),
81
+ }),
82
+ fetchUrl: vi.fn().mockResolvedValue({ ok: true, status: 200 }),
83
+ }));
84
+
85
+ // Mock the github-api module (checkPagesStatus used by waitForPagesLive)
86
+ vi.mock("../github-api", () => ({
87
+ checkPagesStatus: vi.fn().mockResolvedValue({ status: "built", url: "", commit: "orphan-sha-abc1234def5678" }),
88
+ requestPagesBuild: vi.fn().mockResolvedValue(true),
89
+ getAuthenticatedUser: vi.fn(),
90
+ checkRepoExists: vi.fn(),
91
+ createRepository: vi.fn(),
92
+ setCustomDomain: vi.fn(),
93
+ ensurePagesSource: vi.fn().mockResolvedValue({ configured: true, wasCreated: false }),
94
+ getPages: vi.fn().mockResolvedValue(null),
95
+ }));
96
+
97
+ // Import after mocking
98
+ import { on_deploy } from "../main";
99
+ import { reportProgress, showToast } from "../utils";
100
+ import { verifyRepoExists, getOriginOwnerRepo, deployViaGitPush } from "../github-deploy";
101
+ import { promptLogin, validateToken, hasRequiredScopes } from "../auth";
102
+ import { getToken, getTokenFromGit, storeToken } from "../token";
103
+ import { buildPagesUrl, parseGitHubUrl } from "../git";
104
+ import { checkPagesStatus, ensurePagesSource, getPages } from "../github-api";
105
+ import { ensureGitHubRepo } from "../repo-setup";
106
+
107
+ /**
108
+ * Create a mock DeployContext for testing
109
+ */
110
+ function createMockContext(overrides?: Partial<DeployContext>): DeployContext {
111
+ return {
112
+ project_path: "/test/project",
113
+ moss_dir: "/test/project/.moss",
114
+ output_dir: "/test/project/.moss/build/site",
115
+ site_files: ["index.html", "style.css"],
116
+ project_info: {
117
+ project_type: "markdown",
118
+ content_folders: ["posts"],
119
+ total_files: 10,
120
+ homepage_file: "index.md",
121
+ },
122
+ config: {},
123
+ ...overrides,
124
+ };
125
+ }
126
+
127
+ /**
128
+ * Set up common mocks for a successful deployment flow.
129
+ *
130
+ * The deploy target comes from getOriginOwnerRepo() (reads .git origin).
131
+ * When needsSetup=true, getOriginOwnerRepo returns null and ensureGitHubRepo runs.
132
+ */
133
+ function setupDeployMocks(
134
+ _ctx: MockTauriContext,
135
+ options?: {
136
+ hasChanges?: boolean;
137
+ commitSha?: string;
138
+ token?: string;
139
+ owner?: string;
140
+ repo?: string;
141
+ needsSetup?: boolean;
142
+ }
143
+ ) {
144
+ const {
145
+ hasChanges = true,
146
+ commitSha = "abc1234def5678",
147
+ token = "test-token",
148
+ owner = "test-user",
149
+ repo = "test-repo",
150
+ needsSetup = false,
151
+ } = options ?? {};
152
+
153
+ // Token is available
154
+ vi.mocked(getToken).mockResolvedValue(token);
155
+ vi.mocked(getTokenFromGit).mockResolvedValue(null);
156
+
157
+ if (needsSetup) {
158
+ // No .git origin — setup flow runs
159
+ vi.mocked(getOriginOwnerRepo).mockResolvedValue(null);
160
+ vi.mocked(ensureGitHubRepo).mockResolvedValue({
161
+ name: repo,
162
+ fullName: `${owner}/${repo}`,
163
+ sshUrl: `git@github.com:${owner}/${repo}.git`,
164
+ });
165
+ } else {
166
+ // .git origin exists — use it directly
167
+ vi.mocked(getOriginOwnerRepo).mockResolvedValue({ owner, repo });
168
+ }
169
+
170
+ // Deploy result (now returns DeployResult object)
171
+ vi.mocked(deployViaGitPush).mockResolvedValue(
172
+ hasChanges
173
+ ? { commitSha: commitSha, orphanSha: "orphan-sha-" + commitSha, treeChanged: true }
174
+ : { commitSha: "", orphanSha: "", treeChanged: false }
175
+ );
176
+
177
+ // Pages status check
178
+ vi.mocked(checkPagesStatus).mockResolvedValue({ status: "built", url: "", commit: "orphan-sha-" + commitSha });
179
+ }
180
+
181
+ describe("on_deploy integration", () => {
182
+ let ctx: MockTauriContext;
183
+
184
+ beforeEach(async () => {
185
+ ctx = setupMockTauri();
186
+ vi.clearAllMocks();
187
+
188
+ // Restore default implementations after clearAllMocks
189
+ vi.mocked(parseGitHubUrl).mockImplementation((remoteUrl: string) => {
190
+ const m = remoteUrl.match(/github\.com[:/]([^/]+)\/([^/.]+)/);
191
+ if (m) return { owner: m[1], repo: m[2] };
192
+ return null;
193
+ });
194
+ vi.mocked(buildPagesUrl).mockImplementation((owner: string, repo: string) => {
195
+ if (repo.toLowerCase() === `${owner.toLowerCase()}.github.io`) {
196
+ return `https://${owner}.github.io`;
197
+ }
198
+ return `https://${owner}.github.io/${repo}`;
199
+ });
200
+ vi.mocked(getOriginOwnerRepo).mockResolvedValue({ owner: "test-user", repo: "test-repo" });
201
+ vi.mocked(checkPagesStatus).mockResolvedValue({ status: "built", url: "", commit: "orphan-sha-abc1234def5678" });
202
+ vi.mocked(getPages).mockResolvedValue(null);
203
+
204
+ // Reset fetchUrl to default (site reachable) — individual tests override as needed
205
+ const mossApi = await import("@symbiosis-lab/moss-api");
206
+ vi.mocked(mossApi.fetchUrl).mockResolvedValue({ ok: true, status: 200, body: new Uint8Array(), contentType: null, text: () => "" });
207
+ });
208
+
209
+ afterEach(() => {
210
+ ctx.cleanup();
211
+ });
212
+
213
+ describe("Deploy Target Resolution", () => {
214
+ it("reads deploy target from git origin", async () => {
215
+ setupDeployMocks(ctx, { owner: "myuser", repo: "mysite" });
216
+
217
+ const result = await on_deploy(createMockContext());
218
+
219
+ expect(result.success).toBe(true);
220
+ expect(vi.mocked(getOriginOwnerRepo)).toHaveBeenCalled();
221
+ expect(vi.mocked(deployViaGitPush)).toHaveBeenCalledWith(
222
+ expect.objectContaining({ owner: "myuser", repo: "mysite" })
223
+ );
224
+ });
225
+
226
+ it("runs setup when no git origin exists", async () => {
227
+ vi.mocked(getOriginOwnerRepo).mockResolvedValue(null);
228
+ vi.mocked(ensureGitHubRepo).mockResolvedValue(null);
229
+
230
+ const result = await on_deploy(createMockContext());
231
+
232
+ expect(result.success).toBe(false);
233
+ expect(result.message).toContain("cancelled");
234
+ expect(vi.mocked(ensureGitHubRepo)).toHaveBeenCalled();
235
+ });
236
+
237
+ it("uses setup result for deploy when no git origin", async () => {
238
+ setupDeployMocks(ctx, {
239
+ needsSetup: true,
240
+ owner: "newuser",
241
+ repo: "newsite",
242
+ hasChanges: true,
243
+ });
244
+
245
+ const result = await on_deploy(createMockContext());
246
+
247
+ expect(result.success).toBe(true);
248
+ expect(result.deployment?.metadata?.was_first_setup).toBe("true");
249
+ expect(result.deployment?.metadata?.repo_url).toBe("https://github.com/newuser/newsite");
250
+ expect(vi.mocked(deployViaGitPush)).toHaveBeenCalledWith(
251
+ expect.objectContaining({ owner: "newuser", repo: "newsite" })
252
+ );
253
+ });
254
+ });
255
+
256
+ describe("Site Compilation Validation", () => {
257
+ it("fails when context.site_files is empty", async () => {
258
+ const result = await on_deploy(
259
+ createMockContext({ site_files: [] })
260
+ );
261
+
262
+ expect(result.success).toBe(false);
263
+ expect(result.message).toMatch(/site.*empty|build.*first/i);
264
+ });
265
+
266
+ it("passes validation when context.site_files has files", async () => {
267
+ setupDeployMocks(ctx, { hasChanges: true });
268
+
269
+ const result = await on_deploy(
270
+ createMockContext({ site_files: ["index.html", "style.css", "app.js"] })
271
+ );
272
+
273
+ expect(result.success).toBe(true);
274
+ expect(result.message).not.toContain("Site directory is empty");
275
+ });
276
+ });
277
+
278
+ describe("Successful Deployment", () => {
279
+ it("returns success with deployment info", async () => {
280
+ setupDeployMocks(ctx, {
281
+ owner: "testuser",
282
+ repo: "testrepo",
283
+ hasChanges: true,
284
+ commitSha: "abc123def",
285
+ });
286
+
287
+ const result = await on_deploy(createMockContext());
288
+
289
+ expect(result.success).toBe(true);
290
+ expect(result.deployment).toBeDefined();
291
+ expect(result.deployment?.method).toBe("github-pages");
292
+ expect(result.deployment?.url).toBe("https://testuser.github.io/testrepo");
293
+ expect(result.deployment?.metadata?.repo_url).toBe("https://github.com/testuser/testrepo");
294
+ });
295
+
296
+ it("indicates first-time setup in message", async () => {
297
+ setupDeployMocks(ctx, {
298
+ needsSetup: true,
299
+ hasChanges: true,
300
+ commitSha: "abc123",
301
+ });
302
+
303
+ const result = await on_deploy(createMockContext());
304
+
305
+ expect(result.success).toBe(true);
306
+ expect(result.message).toContain("deployed");
307
+ expect(result.deployment?.metadata?.was_first_setup).toBe("true");
308
+ });
309
+
310
+ it("was_first_setup is false for subsequent deploys", async () => {
311
+ setupDeployMocks(ctx, { hasChanges: true });
312
+
313
+ const result = await on_deploy(createMockContext());
314
+
315
+ expect(result.success).toBe(true);
316
+ expect(result.deployment?.metadata?.was_first_setup).toBe("false");
317
+ });
318
+ });
319
+
320
+ describe("No Changes Detection", () => {
321
+ it("reports no changes when deployViaGitPush returns empty string", async () => {
322
+ setupDeployMocks(ctx, { hasChanges: false });
323
+
324
+ const result = await on_deploy(createMockContext());
325
+
326
+ expect(result.success).toBe(true);
327
+ expect(result.message).toContain("No changes to deploy");
328
+ expect(result.deployment?.metadata?.commit_sha).toBe("");
329
+ });
330
+ });
331
+
332
+ describe("Deployment via git push", () => {
333
+ it("calls deployViaGitPush with correct owner/repo", async () => {
334
+ setupDeployMocks(ctx, {
335
+ owner: "user",
336
+ repo: "repo",
337
+ hasChanges: true,
338
+ commitSha: "new-commit-sha",
339
+ });
340
+
341
+ const result = await on_deploy(createMockContext());
342
+
343
+ expect(result.success).toBe(true);
344
+ expect(vi.mocked(deployViaGitPush)).toHaveBeenCalledTimes(1);
345
+ const deployCall = vi.mocked(deployViaGitPush).mock.calls[0][0];
346
+ expect(deployCall.owner).toBe("user");
347
+ expect(deployCall.repo).toBe("repo");
348
+ });
349
+
350
+ it("handles git push errors gracefully", async () => {
351
+ setupDeployMocks(ctx, { hasChanges: true });
352
+ vi.mocked(deployViaGitPush).mockRejectedValue(new Error("git push failed: remote rejected"));
353
+
354
+ const result = await on_deploy(createMockContext());
355
+
356
+ expect(result.success).toBe(false);
357
+ expect(result.message).toContain("remote rejected");
358
+ });
359
+ });
360
+
361
+ describe("Authentication", () => {
362
+ it("prompts OAuth when no token available", async () => {
363
+ vi.mocked(getOriginOwnerRepo).mockResolvedValue({ owner: "user", repo: "repo" });
364
+ vi.mocked(getToken).mockResolvedValue(null);
365
+ vi.mocked(getTokenFromGit).mockResolvedValue(null);
366
+
367
+ vi.mocked(promptLogin).mockResolvedValue(true);
368
+ vi.mocked(getToken)
369
+ .mockResolvedValueOnce(null) // Phase 1 check
370
+ .mockResolvedValueOnce("new-token"); // after promptLogin
371
+
372
+ vi.mocked(deployViaGitPush).mockResolvedValue({ commitSha: "", orphanSha: "", treeChanged: false });
373
+
374
+ const result = await on_deploy(createMockContext());
375
+
376
+ expect(vi.mocked(promptLogin)).toHaveBeenCalled();
377
+ });
378
+
379
+ it("uses git credential token when valid", async () => {
380
+ vi.mocked(getOriginOwnerRepo).mockResolvedValue({ owner: "test-user", repo: "test-repo" });
381
+ vi.mocked(getToken).mockResolvedValue(null);
382
+ vi.mocked(getTokenFromGit).mockResolvedValue("git-credential-token");
383
+
384
+ vi.mocked(validateToken).mockResolvedValue({
385
+ valid: true,
386
+ user: { login: "test-user", id: 1, avatar_url: "", html_url: "" },
387
+ scopes: ["repo"],
388
+ });
389
+ vi.mocked(hasRequiredScopes).mockReturnValue(true);
390
+
391
+ vi.mocked(deployViaGitPush).mockResolvedValue({ commitSha: "commit-sha", orphanSha: "orphan-commit-sha", treeChanged: true });
392
+ vi.mocked(checkPagesStatus).mockResolvedValue({ status: "built", url: "", commit: "orphan-commit-sha" });
393
+
394
+ const result = await on_deploy(createMockContext());
395
+
396
+ expect(vi.mocked(validateToken)).toHaveBeenCalledWith("git-credential-token");
397
+ expect(vi.mocked(storeToken)).toHaveBeenCalledWith("git-credential-token");
398
+ expect(result.success).toBe(true);
399
+ });
400
+
401
+ it("falls through to OAuth when git credential token lacks scopes", async () => {
402
+ vi.mocked(getOriginOwnerRepo).mockResolvedValue({ owner: "test-user", repo: "test-repo" });
403
+ vi.mocked(getToken).mockResolvedValue(null);
404
+ vi.mocked(getTokenFromGit).mockResolvedValue("weak-git-token");
405
+
406
+ vi.mocked(validateToken).mockResolvedValue({
407
+ valid: true,
408
+ user: { login: "test-user", id: 1, avatar_url: "", html_url: "" },
409
+ scopes: ["gist"],
410
+ });
411
+ vi.mocked(hasRequiredScopes).mockReturnValue(false);
412
+
413
+ vi.mocked(promptLogin).mockResolvedValue(false);
414
+
415
+ const result = await on_deploy(createMockContext());
416
+
417
+ expect(vi.mocked(storeToken)).not.toHaveBeenCalledWith("weak-git-token");
418
+ expect(vi.mocked(promptLogin)).toHaveBeenCalled();
419
+ expect(result.success).toBe(false);
420
+ });
421
+
422
+ it("auth error returns helpful message", async () => {
423
+ setupDeployMocks(ctx, { hasChanges: true });
424
+ vi.mocked(deployViaGitPush).mockRejectedValue(
425
+ new Error("Bad credentials - authentication failed")
426
+ );
427
+
428
+ const result = await on_deploy(createMockContext());
429
+
430
+ expect(result.success).toBe(false);
431
+ const toastCalls = vi.mocked(showToast).mock.calls;
432
+ const errorToasts = toastCalls.filter((call) => call[0]?.variant === "error");
433
+ expect(errorToasts.length).toBeGreaterThan(0);
434
+ expect(errorToasts[0][0].message).toContain("Authentication failed");
435
+ });
436
+ });
437
+
438
+ describe("Error Categorization", () => {
439
+ it("shows helpful message for timeout errors", async () => {
440
+ setupDeployMocks(ctx, { hasChanges: true });
441
+ vi.mocked(deployViaGitPush).mockRejectedValue(
442
+ new Error("Request timed out after 300000 ms")
443
+ );
444
+
445
+ const result = await on_deploy(createMockContext());
446
+
447
+ expect(result.success).toBe(false);
448
+ const toastCalls = vi.mocked(showToast).mock.calls;
449
+ const errorToasts = toastCalls.filter((call) => call[0]?.variant === "error");
450
+ expect(errorToasts.length).toBeGreaterThan(0);
451
+ expect(errorToasts[0][0].message).toContain("may still be running");
452
+ });
453
+
454
+ it("shows network error message for connection failures", async () => {
455
+ setupDeployMocks(ctx, { hasChanges: true });
456
+ vi.mocked(deployViaGitPush).mockRejectedValue(
457
+ new Error("Network connection failed")
458
+ );
459
+
460
+ const result = await on_deploy(createMockContext());
461
+
462
+ expect(result.success).toBe(false);
463
+ const toastCalls = vi.mocked(showToast).mock.calls;
464
+ const errorToasts = toastCalls.filter((call) => call[0]?.variant === "error");
465
+ expect(errorToasts.length).toBeGreaterThan(0);
466
+ expect(errorToasts[0][0].message).toContain("Network error");
467
+ });
468
+ });
469
+
470
+ describe("Progress Visibility", () => {
471
+ it("reports progress during deployment", async () => {
472
+ setupDeployMocks(ctx, { hasChanges: true });
473
+
474
+ const result = await on_deploy(createMockContext());
475
+
476
+ expect(result.success).toBe(true);
477
+ const progressCalls = vi.mocked(reportProgress).mock.calls;
478
+ expect(progressCalls.length).toBeGreaterThan(0);
479
+ for (const call of progressCalls) {
480
+ expect(call[2]).toBe(10); // total=10
481
+ }
482
+ });
483
+
484
+ it("progress is monotonically increasing", async () => {
485
+ setupDeployMocks(ctx, { hasChanges: true });
486
+
487
+ const result = await on_deploy(createMockContext());
488
+
489
+ expect(result.success).toBe(true);
490
+ const progressCalls = vi.mocked(reportProgress).mock.calls;
491
+ const currentValues = progressCalls.map((call) => call[1]);
492
+
493
+ for (let i = 1; i < currentValues.length; i++) {
494
+ expect(currentValues[i]).toBeGreaterThanOrEqual(currentValues[i - 1]);
495
+ }
496
+ });
497
+ });
498
+
499
+ describe("Pages Source Configuration", () => {
500
+ it("calls ensurePagesSource after successful deploy", async () => {
501
+ setupDeployMocks(ctx, { owner: "user", repo: "repo", hasChanges: true, token: "test-token" });
502
+
503
+ const result = await on_deploy(createMockContext());
504
+
505
+ expect(result.success).toBe(true);
506
+ expect(vi.mocked(ensurePagesSource)).toHaveBeenCalledWith("user", "repo", "test-token", "gh-pages");
507
+ });
508
+
509
+ it("does not fail deploy when ensurePagesSource fails", async () => {
510
+ setupDeployMocks(ctx, { owner: "user", repo: "repo", hasChanges: true });
511
+ vi.mocked(ensurePagesSource).mockRejectedValue(new Error("API error"));
512
+
513
+ const result = await on_deploy(createMockContext());
514
+
515
+ expect(result.success).toBe(true);
516
+ });
517
+
518
+ it("calls ensurePagesSource even when no changes to push", async () => {
519
+ setupDeployMocks(ctx, { owner: "user", repo: "repo", hasChanges: false, token: "test-token" });
520
+
521
+ const result = await on_deploy(createMockContext());
522
+
523
+ expect(result.success).toBe(true);
524
+ expect(vi.mocked(ensurePagesSource)).toHaveBeenCalledWith("user", "repo", "test-token", "gh-pages");
525
+ });
526
+ });
527
+
528
+ describe("Verify Repo Exists", () => {
529
+ it("calls verifyRepoExists before deploying", async () => {
530
+ setupDeployMocks(ctx, { owner: "user", repo: "repo", hasChanges: true });
531
+
532
+ const result = await on_deploy(createMockContext());
533
+
534
+ expect(result.success).toBe(true);
535
+ expect(vi.mocked(verifyRepoExists)).toHaveBeenCalledWith("user", "repo", "test-token");
536
+ });
537
+
538
+ it("fails with clear error when repo does not exist", async () => {
539
+ setupDeployMocks(ctx, { hasChanges: true });
540
+ vi.mocked(verifyRepoExists).mockRejectedValue(
541
+ new Error('Repository "test-user/test-repo" not found on GitHub.')
542
+ );
543
+
544
+ const result = await on_deploy(createMockContext());
545
+
546
+ expect(result.success).toBe(false);
547
+ expect(result.message).toContain("not found");
548
+ });
549
+ });
550
+
551
+ // ==========================================================================
552
+ // Bug 2: Stale build detection via orphanSha
553
+ // ==========================================================================
554
+ describe("Stale Build Detection", () => {
555
+ it("detects stale build when commit SHA does not match and site not reachable", async () => {
556
+ setupDeployMocks(ctx, { owner: "user", repo: "repo", hasChanges: true, commitSha: "new-commit" });
557
+ vi.mocked(verifyRepoExists).mockResolvedValue(undefined);
558
+
559
+ // checkPagesStatus returns "built" but with a DIFFERENT commit SHA (stale)
560
+ vi.mocked(checkPagesStatus)
561
+ .mockResolvedValueOnce({ status: "built", url: "", commit: "old-stale-commit" })
562
+ .mockResolvedValueOnce({ status: "built", url: "", commit: "old-stale-commit" })
563
+ .mockResolvedValueOnce({ status: "built", url: "", commit: "old-stale-commit" })
564
+ .mockResolvedValueOnce({ status: "built", url: "", commit: "old-stale-commit" })
565
+ .mockResolvedValueOnce({ status: "built", url: "", commit: "old-stale-commit" })
566
+ .mockResolvedValueOnce({ status: "built", url: "", commit: "old-stale-commit" });
567
+
568
+ // Site not yet reachable (stale content still propagating)
569
+ const { fetchUrl } = await import("@symbiosis-lab/moss-api");
570
+ vi.mocked(fetchUrl).mockResolvedValue({ ok: false, status: 404, body: new Uint8Array(), contentType: null, text: () => "" });
571
+
572
+ const result = await on_deploy(createMockContext());
573
+
574
+ expect(result.success).toBe(true);
575
+ // isLive should be false: stale build + HTTP check fails
576
+ expect(result.deployment?.metadata?.is_live).toBe("false");
577
+ });
578
+
579
+ it("recognizes fresh build when commit SHA matches orphanSha", async () => {
580
+ setupDeployMocks(ctx, { owner: "user", repo: "repo", hasChanges: true, commitSha: "fresh-commit" });
581
+ vi.mocked(verifyRepoExists).mockResolvedValue(undefined);
582
+
583
+ // checkPagesStatus returns "built" with the MATCHING orphan SHA
584
+ vi.mocked(checkPagesStatus).mockResolvedValue({
585
+ status: "built",
586
+ url: "",
587
+ commit: "orphan-sha-fresh-commit",
588
+ });
589
+
590
+ // Site is reachable via HTTP
591
+ const { fetchUrl } = await import("@symbiosis-lab/moss-api");
592
+ vi.mocked(fetchUrl).mockResolvedValue({ ok: true, status: 200, body: new Uint8Array(), contentType: null, text: () => "" });
593
+
594
+ const result = await on_deploy(createMockContext());
595
+
596
+ expect(result.success).toBe(true);
597
+ expect(result.deployment?.metadata?.is_live).toBe("true");
598
+ });
599
+ });
600
+
601
+ // ==========================================================================
602
+ // Bug 3: Error/unknown status toast messaging
603
+ // ==========================================================================
604
+ describe("Error and Unknown Status Toast Messaging", () => {
605
+ it("shows warning toast with error message when build errors", async () => {
606
+ setupDeployMocks(ctx, { owner: "user", repo: "my-repo", hasChanges: true, commitSha: "errored-commit" });
607
+ vi.mocked(verifyRepoExists).mockResolvedValue(undefined);
608
+
609
+ // Build errors on GitHub
610
+ vi.mocked(checkPagesStatus).mockResolvedValue({
611
+ status: "errored",
612
+ url: "",
613
+ error: "Build failed: invalid config",
614
+ });
615
+
616
+ const result = await on_deploy(createMockContext());
617
+
618
+ expect(result.success).toBe(true);
619
+
620
+ // Find the toast call for deploy status (the last showToast call)
621
+ const toastCalls = vi.mocked(showToast).mock.calls;
622
+ const lastToast = toastCalls[toastCalls.length - 1][0];
623
+ expect(lastToast).toMatchObject({
624
+ variant: "warning",
625
+ });
626
+ // Should have "View on GitHub" action pointing to settings/pages
627
+ expect(lastToast.actions).toBeDefined();
628
+ expect(lastToast.actions[0].url).toContain("settings/pages");
629
+ });
630
+
631
+ it("shows info toast when build status is unknown/timeout and site not reachable", async () => {
632
+ setupDeployMocks(ctx, { owner: "user", repo: "my-repo", hasChanges: true, commitSha: "timeout-commit" });
633
+ vi.mocked(verifyRepoExists).mockResolvedValue(undefined);
634
+
635
+ // Pages returns "building" every time (simulates timeout)
636
+ vi.mocked(checkPagesStatus).mockResolvedValue({
637
+ status: "building",
638
+ url: "",
639
+ });
640
+
641
+ // HTTP check also fails (site not yet available)
642
+ const { fetchUrl } = await import("@symbiosis-lab/moss-api");
643
+ vi.mocked(fetchUrl).mockResolvedValue({ ok: false, status: 404, body: new Uint8Array(), contentType: null, text: () => "" });
644
+
645
+ const result = await on_deploy(createMockContext());
646
+
647
+ expect(result.success).toBe(true);
648
+
649
+ // Should show an info toast with "View site" action (not GitHub Actions link)
650
+ const toastCalls = vi.mocked(showToast).mock.calls;
651
+ const lastToast = toastCalls[toastCalls.length - 1][0];
652
+ expect(lastToast.variant).toBe("info");
653
+ expect(lastToast.actions).toBeDefined();
654
+ expect(lastToast.actions[0].url).toContain("user.github.io");
655
+ });
656
+ });
657
+
658
+ // ==========================================================================
659
+ // Custom Domain Display URLs
660
+ // ==========================================================================
661
+ describe("Custom domain display URLs", () => {
662
+ it("uses custom domain in deployment.url when context.domain is set", async () => {
663
+ setupDeployMocks(ctx, {
664
+ owner: "testuser",
665
+ repo: "testrepo",
666
+ hasChanges: true,
667
+ commitSha: "abc123def",
668
+ });
669
+
670
+ const result = await on_deploy(createMockContext({ domain: "example.com" }));
671
+
672
+ expect(result.success).toBe(true);
673
+ expect(result.deployment?.url).toBe("https://example.com");
674
+ });
675
+
676
+ it("uses github.io URL when context.domain is not set", async () => {
677
+ setupDeployMocks(ctx, {
678
+ owner: "testuser",
679
+ repo: "testrepo",
680
+ hasChanges: true,
681
+ commitSha: "abc123def",
682
+ });
683
+
684
+ const result = await on_deploy(createMockContext());
685
+
686
+ expect(result.success).toBe(true);
687
+ expect(result.deployment?.url).toBe("https://testuser.github.io/testrepo");
688
+ });
689
+
690
+ it("uses custom domain in toast actions", async () => {
691
+ setupDeployMocks(ctx, {
692
+ owner: "testuser",
693
+ repo: "testrepo",
694
+ hasChanges: true,
695
+ commitSha: "abc123def",
696
+ });
697
+
698
+ await on_deploy(createMockContext({ domain: "example.com" }));
699
+
700
+ const toastCalls = vi.mocked(showToast).mock.calls;
701
+ const lastToast = toastCalls[toastCalls.length - 1][0];
702
+ expect(lastToast.actions[0].url).toBe("https://example.com");
703
+ });
704
+
705
+ it("falls back to GitHub Pages CNAME when no context.domain", async () => {
706
+ setupDeployMocks(ctx, {
707
+ owner: "testuser",
708
+ repo: "testrepo",
709
+ hasChanges: true,
710
+ commitSha: "abc123def",
711
+ });
712
+ vi.mocked(getPages).mockResolvedValue({ cname: "example.com", https_enforced: true });
713
+
714
+ const result = await on_deploy(createMockContext());
715
+
716
+ expect(result.success).toBe(true);
717
+ expect(result.deployment?.url).toBe("https://example.com");
718
+ });
719
+ });
720
+
721
+ // ==========================================================================
722
+ // HTTP Reachability Verification
723
+ // ==========================================================================
724
+ describe("HTTP reachability verification", () => {
725
+ it("reports isLive=true when site is reachable via HTTP", async () => {
726
+ setupDeployMocks(ctx, {
727
+ owner: "testuser",
728
+ repo: "testrepo",
729
+ hasChanges: true,
730
+ commitSha: "abc123def",
731
+ });
732
+ // fetchUrl returns ok: true (site is reachable)
733
+ const { fetchUrl } = await import("@symbiosis-lab/moss-api");
734
+ vi.mocked(fetchUrl).mockResolvedValue({ ok: true, status: 200, body: new Uint8Array(), contentType: null, text: () => "" });
735
+
736
+ const result = await on_deploy(createMockContext());
737
+
738
+ expect(result.success).toBe(true);
739
+ expect(result.deployment?.metadata?.is_live).toBe("true");
740
+ });
741
+
742
+ it("reports isLive=false when site is not reachable", async () => {
743
+ setupDeployMocks(ctx, {
744
+ owner: "testuser",
745
+ repo: "testrepo",
746
+ hasChanges: true,
747
+ commitSha: "abc123def",
748
+ });
749
+ // GitHub API says built, but HTTP check fails
750
+ const { fetchUrl } = await import("@symbiosis-lab/moss-api");
751
+ vi.mocked(fetchUrl).mockResolvedValue({ ok: false, status: 404, body: new Uint8Array(), contentType: null, text: () => "" });
752
+
753
+ const result = await on_deploy(createMockContext());
754
+
755
+ expect(result.success).toBe(true);
756
+ expect(result.deployment?.metadata?.is_live).toBe("false");
757
+ });
758
+
759
+ it("shows info toast when site not reachable after deploy", async () => {
760
+ setupDeployMocks(ctx, {
761
+ owner: "testuser",
762
+ repo: "testrepo",
763
+ hasChanges: true,
764
+ commitSha: "abc123def",
765
+ });
766
+ const { fetchUrl } = await import("@symbiosis-lab/moss-api");
767
+ vi.mocked(fetchUrl).mockResolvedValue({ ok: false, status: 404, body: new Uint8Array(), contentType: null, text: () => "" });
768
+
769
+ await on_deploy(createMockContext());
770
+
771
+ const toastCalls = vi.mocked(showToast).mock.calls;
772
+ const lastToast = toastCalls[toastCalls.length - 1][0];
773
+ // Should show info variant (not success) since site isn't reachable yet
774
+ expect(lastToast.variant).not.toBe("success");
775
+ });
776
+ });
777
+
778
+ // ==========================================================================
779
+ // Bug 3: ensurePagesSource logging
780
+ // ==========================================================================
781
+ describe("ensurePagesSource logging", () => {
782
+ it("logs warning when ensurePagesSource returns configured: false", async () => {
783
+ setupDeployMocks(ctx, { owner: "user", repo: "repo", hasChanges: true });
784
+ vi.mocked(verifyRepoExists).mockResolvedValue(undefined);
785
+ vi.mocked(ensurePagesSource).mockResolvedValue({ configured: false, wasCreated: false });
786
+
787
+ const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
788
+
789
+ const result = await on_deploy(createMockContext());
790
+
791
+ expect(result.success).toBe(true);
792
+ expect(consoleSpy).toHaveBeenCalledWith(
793
+ expect.stringContaining("Failed to configure GitHub Pages source")
794
+ );
795
+ consoleSpy.mockRestore();
796
+ });
797
+ });
798
+ });