@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,1129 @@
1
+ /**
2
+ * E2E Tests for GitHub REST API Deploy Functions
3
+ *
4
+ * Tests the Git Data API workflow (blob -> tree -> commit -> ref) against
5
+ * real GitHub repos. Uses ephemeral repos that are created and deleted
6
+ * per test suite.
7
+ *
8
+ * Prerequisites:
9
+ * - Set GITHUB_TOKEN environment variable (or have gh CLI authenticated)
10
+ * - Set GITHUB_E2E_TEST=1 to enable these tests
11
+ *
12
+ * These tests verify the same API endpoints used by github-deploy.ts
13
+ * without requiring the Tauri runtime.
14
+ */
15
+
16
+ import { describe, it, expect, beforeAll, afterAll } from "vitest";
17
+ import { execFileSync } from "child_process";
18
+
19
+ // Skip all tests unless explicitly enabled
20
+ const RUN_E2E = process.env.GITHUB_E2E_TEST === "1";
21
+
22
+ const GITHUB_API_BASE = "https://api.github.com";
23
+
24
+ // Get token from environment or gh CLI
25
+ function getToken(): string {
26
+ if (process.env.GITHUB_TOKEN) return process.env.GITHUB_TOKEN;
27
+ try {
28
+ return execFileSync("gh", ["auth", "token"], { encoding: "utf8" }).trim();
29
+ } catch {
30
+ throw new Error("No GITHUB_TOKEN env var and gh auth token failed");
31
+ }
32
+ }
33
+
34
+ function authHeaders(token: string): Record<string, string> {
35
+ return {
36
+ Accept: "application/vnd.github.v3+json",
37
+ "User-Agent": "moss-E2E-Tests",
38
+ Authorization: `Bearer ${token}`,
39
+ };
40
+ }
41
+
42
+ // Helper: get authenticated username
43
+ async function getUsername(token: string): Promise<string> {
44
+ const res = await fetch(`${GITHUB_API_BASE}/user`, {
45
+ headers: authHeaders(token),
46
+ });
47
+ if (!res.ok) throw new Error(`Failed to get user: ${res.status}`);
48
+ const data = await res.json();
49
+ return data.login;
50
+ }
51
+
52
+ // Helper: create ephemeral repo
53
+ async function createEphemeralRepo(
54
+ token: string,
55
+ name: string
56
+ ): Promise<void> {
57
+ const res = await fetch(`${GITHUB_API_BASE}/user/repos`, {
58
+ method: "POST",
59
+ headers: { ...authHeaders(token), "Content-Type": "application/json" },
60
+ body: JSON.stringify({ name, auto_init: true, private: false }),
61
+ });
62
+ if (!res.ok) {
63
+ const body = await res.json().catch(() => ({}));
64
+ throw new Error(
65
+ `Failed to create repo ${name}: ${res.status} ${body.message || ""}`
66
+ );
67
+ }
68
+ }
69
+
70
+ // Helper: delete repo
71
+ // Tries the API first (needs delete_repo scope), falls back to gh CLI.
72
+ // If both fail, prints manual cleanup instructions.
73
+ async function deleteRepo(
74
+ token: string,
75
+ owner: string,
76
+ name: string
77
+ ): Promise<void> {
78
+ // Try API first
79
+ const res = await fetch(`${GITHUB_API_BASE}/repos/${owner}/${name}`, {
80
+ method: "DELETE",
81
+ headers: authHeaders(token),
82
+ });
83
+ if (res.ok || res.status === 204 || res.status === 404) return;
84
+
85
+ // Fall back to gh CLI
86
+ try {
87
+ execFileSync("gh", ["repo", "delete", `${owner}/${name}`, "--yes"], {
88
+ encoding: "utf8",
89
+ stdio: "pipe",
90
+ });
91
+ return;
92
+ } catch {
93
+ // Both methods failed
94
+ }
95
+
96
+ console.warn(
97
+ `\nCould not delete ephemeral repo ${owner}/${name}.\n` +
98
+ `To grant delete_repo scope: gh auth refresh -h github.com -s delete_repo\n` +
99
+ `To delete manually: gh repo delete ${owner}/${name} --yes\n`
100
+ );
101
+ }
102
+
103
+ // Helper: get branch ref
104
+ // Note: GitHub's refs API can return an array (prefix match) or a single object.
105
+ async function getBranchRef(
106
+ token: string,
107
+ owner: string,
108
+ repo: string,
109
+ branch: string
110
+ ): Promise<{ exists: boolean; sha?: string }> {
111
+ const res = await fetch(
112
+ `${GITHUB_API_BASE}/repos/${owner}/${repo}/git/refs/heads/${branch}`,
113
+ { headers: authHeaders(token) }
114
+ );
115
+ if (res.status === 404) return { exists: false };
116
+ if (!res.ok) throw new Error(`getBranchRef failed: ${res.status}`);
117
+ const data = await res.json();
118
+
119
+ // GitHub may return an array if the branch name is a prefix of other refs
120
+ if (Array.isArray(data)) {
121
+ const exact = data.find(
122
+ (r: { ref: string }) => r.ref === `refs/heads/${branch}`
123
+ );
124
+ if (!exact) return { exists: false };
125
+ return { exists: true, sha: exact.object.sha };
126
+ }
127
+
128
+ return { exists: true, sha: data.object.sha };
129
+ }
130
+
131
+ // Helper: get tree (recursive)
132
+ async function getTree(
133
+ token: string,
134
+ owner: string,
135
+ repo: string,
136
+ treeSha: string
137
+ ): Promise<Array<{ path: string; sha: string; type: string }>> {
138
+ const res = await fetch(
139
+ `${GITHUB_API_BASE}/repos/${owner}/${repo}/git/trees/${treeSha}?recursive=1`,
140
+ { headers: authHeaders(token) }
141
+ );
142
+ if (!res.ok) throw new Error(`getTree failed: ${res.status}`);
143
+ const data = await res.json();
144
+ return data.tree;
145
+ }
146
+
147
+ // Helper: get commit
148
+ async function getCommit(
149
+ token: string,
150
+ owner: string,
151
+ repo: string,
152
+ sha: string
153
+ ): Promise<{ treeSha: string; parents: string[]; message: string }> {
154
+ const res = await fetch(
155
+ `${GITHUB_API_BASE}/repos/${owner}/${repo}/git/commits/${sha}`,
156
+ { headers: authHeaders(token) }
157
+ );
158
+ if (!res.ok) throw new Error(`getCommit failed: ${res.status}`);
159
+ const data = await res.json();
160
+ return {
161
+ treeSha: data.tree.sha,
162
+ parents: data.parents.map((p: { sha: string }) => p.sha),
163
+ message: data.message,
164
+ };
165
+ }
166
+
167
+ // Helper: upload a blob
168
+ async function uploadBlob(
169
+ token: string,
170
+ owner: string,
171
+ repo: string,
172
+ content: string,
173
+ encoding: string
174
+ ): Promise<string> {
175
+ const res = await fetch(
176
+ `${GITHUB_API_BASE}/repos/${owner}/${repo}/git/blobs`,
177
+ {
178
+ method: "POST",
179
+ headers: { ...authHeaders(token), "Content-Type": "application/json" },
180
+ body: JSON.stringify({ content, encoding }),
181
+ }
182
+ );
183
+ if (!res.ok) {
184
+ const body = await res.json().catch(() => ({}));
185
+ throw new Error(
186
+ `uploadBlob failed: ${res.status} ${body.message || ""}`
187
+ );
188
+ }
189
+ const data = await res.json();
190
+ return data.sha;
191
+ }
192
+
193
+ // Helper: create tree
194
+ async function createTree(
195
+ token: string,
196
+ owner: string,
197
+ repo: string,
198
+ entries: Array<{
199
+ path: string;
200
+ mode: string;
201
+ type: string;
202
+ sha: string | null;
203
+ }>,
204
+ baseTree: string | null
205
+ ): Promise<string> {
206
+ const body: Record<string, unknown> = { tree: entries };
207
+ if (baseTree) body.base_tree = baseTree;
208
+ const res = await fetch(
209
+ `${GITHUB_API_BASE}/repos/${owner}/${repo}/git/trees`,
210
+ {
211
+ method: "POST",
212
+ headers: { ...authHeaders(token), "Content-Type": "application/json" },
213
+ body: JSON.stringify(body),
214
+ }
215
+ );
216
+ if (!res.ok) {
217
+ const b = await res.json().catch(() => ({}));
218
+ throw new Error(`createTree failed: ${res.status} ${b.message || ""}`);
219
+ }
220
+ const data = await res.json();
221
+ return data.sha;
222
+ }
223
+
224
+ // Helper: create commit
225
+ async function createCommit(
226
+ token: string,
227
+ owner: string,
228
+ repo: string,
229
+ message: string,
230
+ treeSha: string,
231
+ parents: string[]
232
+ ): Promise<string> {
233
+ const res = await fetch(
234
+ `${GITHUB_API_BASE}/repos/${owner}/${repo}/git/commits`,
235
+ {
236
+ method: "POST",
237
+ headers: { ...authHeaders(token), "Content-Type": "application/json" },
238
+ body: JSON.stringify({ message, tree: treeSha, parents }),
239
+ }
240
+ );
241
+ if (!res.ok) {
242
+ const b = await res.json().catch(() => ({}));
243
+ throw new Error(
244
+ `createCommit failed: ${res.status} ${b.message || ""}`
245
+ );
246
+ }
247
+ const data = await res.json();
248
+ return data.sha;
249
+ }
250
+
251
+ // Helper: create or update ref
252
+ async function updateRef(
253
+ token: string,
254
+ owner: string,
255
+ repo: string,
256
+ branch: string,
257
+ sha: string,
258
+ exists: boolean
259
+ ): Promise<void> {
260
+ let res: Response;
261
+ if (exists) {
262
+ res = await fetch(
263
+ `${GITHUB_API_BASE}/repos/${owner}/${repo}/git/refs/heads/${branch}`,
264
+ {
265
+ method: "PATCH",
266
+ headers: {
267
+ ...authHeaders(token),
268
+ "Content-Type": "application/json",
269
+ },
270
+ body: JSON.stringify({ sha, force: true }),
271
+ }
272
+ );
273
+ } else {
274
+ res = await fetch(
275
+ `${GITHUB_API_BASE}/repos/${owner}/${repo}/git/refs`,
276
+ {
277
+ method: "POST",
278
+ headers: {
279
+ ...authHeaders(token),
280
+ "Content-Type": "application/json",
281
+ },
282
+ body: JSON.stringify({ ref: `refs/heads/${branch}`, sha }),
283
+ }
284
+ );
285
+ }
286
+ if (!res.ok) {
287
+ const b = await res.json().catch(() => ({}));
288
+ throw new Error(`updateRef failed: ${res.status} ${b.message || ""}`);
289
+ }
290
+ }
291
+
292
+ // Helper: text to base64
293
+ function textToBase64(text: string): string {
294
+ return Buffer.from(text, "utf-8").toString("base64");
295
+ }
296
+
297
+ // Helper: get blob content (decoded from base64)
298
+ async function getBlobContent(
299
+ token: string,
300
+ owner: string,
301
+ repo: string,
302
+ sha: string
303
+ ): Promise<string> {
304
+ const res = await fetch(
305
+ `${GITHUB_API_BASE}/repos/${owner}/${repo}/git/blobs/${sha}`,
306
+ { headers: authHeaders(token) }
307
+ );
308
+ if (!res.ok) throw new Error(`getBlobContent failed: ${res.status}`);
309
+ const data = await res.json();
310
+ const base64 = data.content.replace(/\n/g, "");
311
+ return Buffer.from(base64, "base64").toString("utf-8");
312
+ }
313
+
314
+ // ============================================================================
315
+ // Test Suites
316
+ // ============================================================================
317
+
318
+ describe.skipIf(!RUN_E2E)("GitHub REST API Deploy E2E", () => {
319
+ let token: string;
320
+ let owner: string;
321
+ const repoName = `moss-e2e-${Date.now()}`;
322
+
323
+ beforeAll(async () => {
324
+ token = getToken();
325
+ owner = await getUsername(token);
326
+ await createEphemeralRepo(token, repoName);
327
+ // Small delay for GitHub to propagate
328
+ await new Promise((r) => setTimeout(r, 2000));
329
+ }, 30000);
330
+
331
+ afterAll(async () => {
332
+ await deleteRepo(token, owner, repoName);
333
+ }, 15000);
334
+
335
+ // ========================================================================
336
+ // Scenario 1: First-time deploy (repo has main from auto_init, no gh-pages)
337
+ // ========================================================================
338
+
339
+ let scenario1CommitSha: string;
340
+ let scenario1TreeSha: string;
341
+
342
+ describe("Scenario 1: First-time deploy to gh-pages", () => {
343
+ it("uploads blobs and creates gh-pages branch", async () => {
344
+ // Repo was created with auto_init: main exists, but gh-pages does not
345
+ const ghPages = await getBranchRef(token, owner, repoName, "gh-pages");
346
+ expect(ghPages.exists).toBe(false);
347
+
348
+ // Upload 3 files as blobs
349
+ const files = [
350
+ { path: "index.html", content: "<h1>Hello World</h1>" },
351
+ { path: "about/index.html", content: "<h1>About</h1>" },
352
+ {
353
+ path: "articles/hello/index.html",
354
+ content: "<h1>Hello Article</h1>",
355
+ },
356
+ ];
357
+
358
+ const blobShas: Record<string, string> = {};
359
+ for (const file of files) {
360
+ blobShas[file.path] = await uploadBlob(
361
+ token,
362
+ owner,
363
+ repoName,
364
+ textToBase64(file.content),
365
+ "base64"
366
+ );
367
+ }
368
+
369
+ // Create tree (no base tree -- first deploy)
370
+ const treeEntries = files.map((f) => ({
371
+ path: f.path,
372
+ mode: "100644",
373
+ type: "blob",
374
+ sha: blobShas[f.path],
375
+ }));
376
+
377
+ scenario1TreeSha = await createTree(
378
+ token,
379
+ owner,
380
+ repoName,
381
+ treeEntries,
382
+ null
383
+ );
384
+
385
+ // Create orphan commit
386
+ scenario1CommitSha = await createCommit(
387
+ token,
388
+ owner,
389
+ repoName,
390
+ "Deploy site\n\nGenerated by moss",
391
+ scenario1TreeSha,
392
+ []
393
+ );
394
+
395
+ // Create gh-pages ref
396
+ await updateRef(
397
+ token,
398
+ owner,
399
+ repoName,
400
+ "gh-pages",
401
+ scenario1CommitSha,
402
+ false
403
+ );
404
+
405
+ // Verify: gh-pages branch exists
406
+ const ghPagesAfter = await getBranchRef(
407
+ token,
408
+ owner,
409
+ repoName,
410
+ "gh-pages"
411
+ );
412
+ expect(ghPagesAfter.exists).toBe(true);
413
+ expect(ghPagesAfter.sha).toBe(scenario1CommitSha);
414
+
415
+ // Verify: tree has 3 files
416
+ const tree = await getTree(token, owner, repoName, scenario1TreeSha);
417
+ const blobEntries = tree.filter((e) => e.type === "blob");
418
+ expect(blobEntries).toHaveLength(3);
419
+ expect(blobEntries.map((e) => e.path).sort()).toEqual([
420
+ "about/index.html",
421
+ "articles/hello/index.html",
422
+ "index.html",
423
+ ]);
424
+
425
+ // Verify: commit is orphan (no parents)
426
+ const commit = await getCommit(
427
+ token,
428
+ owner,
429
+ repoName,
430
+ scenario1CommitSha
431
+ );
432
+ expect(commit.parents).toHaveLength(0);
433
+ }, 30000);
434
+
435
+ it("pushes source files to a new branch (first-time backup)", async () => {
436
+ // The "source" branch should not exist yet (tests orphan branch creation)
437
+ // This mirrors pushSourceToMain which creates an orphan branch for source backup
438
+ const source = await getBranchRef(token, owner, repoName, "source");
439
+ expect(source.exists).toBe(false);
440
+
441
+ // Upload source files
442
+ const sourceFiles = [
443
+ {
444
+ path: "index.md",
445
+ content: "# Hello World\n\nWelcome to my site.",
446
+ },
447
+ { path: "about.md", content: "# About\n\nAbout me." },
448
+ {
449
+ path: "articles/hello.md",
450
+ content: "# Hello Article\n\nFirst post.",
451
+ },
452
+ {
453
+ path: "moss.toml",
454
+ content: '[site]\ntitle = "My Site"\n',
455
+ },
456
+ ];
457
+
458
+ const blobShas: Record<string, string> = {};
459
+ for (const file of sourceFiles) {
460
+ blobShas[file.path] = await uploadBlob(
461
+ token,
462
+ owner,
463
+ repoName,
464
+ textToBase64(file.content),
465
+ "base64"
466
+ );
467
+ }
468
+
469
+ // Create tree
470
+ const treeEntries = sourceFiles.map((f) => ({
471
+ path: f.path,
472
+ mode: "100644",
473
+ type: "blob",
474
+ sha: blobShas[f.path],
475
+ }));
476
+
477
+ const treeSha = await createTree(
478
+ token,
479
+ owner,
480
+ repoName,
481
+ treeEntries,
482
+ null
483
+ );
484
+
485
+ // Create orphan commit (independent of gh-pages)
486
+ const commitSha = await createCommit(
487
+ token,
488
+ owner,
489
+ repoName,
490
+ "Initial commit\n\nSource files uploaded by moss",
491
+ treeSha,
492
+ []
493
+ );
494
+
495
+ // Create source ref
496
+ await updateRef(token, owner, repoName, "source", commitSha, false);
497
+
498
+ // Verify: source branch exists
499
+ const sourceAfter = await getBranchRef(
500
+ token,
501
+ owner,
502
+ repoName,
503
+ "source"
504
+ );
505
+ expect(sourceAfter.exists).toBe(true);
506
+
507
+ // Verify: source has source files
508
+ const sourceTree = await getTree(token, owner, repoName, treeSha);
509
+ const sourceBlobs = sourceTree.filter((e) => e.type === "blob");
510
+ expect(sourceBlobs).toHaveLength(4);
511
+ expect(sourceBlobs.map((e) => e.path).sort()).toEqual([
512
+ "about.md",
513
+ "articles/hello.md",
514
+ "index.md",
515
+ "moss.toml",
516
+ ]);
517
+
518
+ // Verify: .moss/ and .git/ are NOT in source tree
519
+ const hasMossDir = sourceBlobs.some((e) =>
520
+ e.path.startsWith(".moss/")
521
+ );
522
+ const hasGitDir = sourceBlobs.some((e) =>
523
+ e.path.startsWith(".git/")
524
+ );
525
+ expect(hasMossDir).toBe(false);
526
+ expect(hasGitDir).toBe(false);
527
+
528
+ // Verify: commit is orphan (no parents -- independent of gh-pages and main)
529
+ const sourceCommit = await getCommit(
530
+ token,
531
+ owner,
532
+ repoName,
533
+ commitSha
534
+ );
535
+ expect(sourceCommit.parents).toHaveLength(0);
536
+ }, 30000);
537
+ });
538
+
539
+ // ========================================================================
540
+ // Scenario 2: Subsequent deploy with changes
541
+ // ========================================================================
542
+
543
+ let scenario2CommitSha: string;
544
+
545
+ describe("Scenario 2: Subsequent deploy with changes", () => {
546
+ it("uploads only changed file and creates new commit", async () => {
547
+ // Get current gh-pages state
548
+ const ghPages = await getBranchRef(
549
+ token,
550
+ owner,
551
+ repoName,
552
+ "gh-pages"
553
+ );
554
+ expect(ghPages.exists).toBe(true);
555
+
556
+ const currentCommit = await getCommit(
557
+ token,
558
+ owner,
559
+ repoName,
560
+ ghPages.sha!
561
+ );
562
+ const currentTreeSha = currentCommit.treeSha;
563
+
564
+ // Get current tree to identify unchanged files
565
+ const currentTree = await getTree(
566
+ token,
567
+ owner,
568
+ repoName,
569
+ currentTreeSha
570
+ );
571
+
572
+ // Upload ONLY the changed file (index.html with new content)
573
+ const newContent = "<h1>Hello World - Updated!</h1>";
574
+ const newBlobSha = await uploadBlob(
575
+ token,
576
+ owner,
577
+ repoName,
578
+ textToBase64(newContent),
579
+ "base64"
580
+ );
581
+
582
+ // Create tree with base_tree (only send changed entry)
583
+ const treeEntries = [
584
+ {
585
+ path: "index.html",
586
+ mode: "100644",
587
+ type: "blob",
588
+ sha: newBlobSha,
589
+ },
590
+ ];
591
+
592
+ const newTreeSha = await createTree(
593
+ token,
594
+ owner,
595
+ repoName,
596
+ treeEntries,
597
+ currentTreeSha
598
+ );
599
+
600
+ // Create commit with parent (not orphan this time)
601
+ scenario2CommitSha = await createCommit(
602
+ token,
603
+ owner,
604
+ repoName,
605
+ "Deploy site\n\nGenerated by moss",
606
+ newTreeSha,
607
+ [ghPages.sha!]
608
+ );
609
+
610
+ // Update gh-pages ref
611
+ await updateRef(
612
+ token,
613
+ owner,
614
+ repoName,
615
+ "gh-pages",
616
+ scenario2CommitSha,
617
+ true
618
+ );
619
+
620
+ // Verify: the commit we created has the right structure
621
+ // (We verify the commit directly rather than re-reading the ref,
622
+ // because GitHub Pages automation may update the ref after our push)
623
+ const newCommit = await getCommit(
624
+ token,
625
+ owner,
626
+ repoName,
627
+ scenario2CommitSha
628
+ );
629
+ expect(newCommit.parents).toHaveLength(1);
630
+ expect(newCommit.parents[0]).toBe(ghPages.sha!);
631
+
632
+ // Verify: tree still has all 3 files
633
+ const newTree = await getTree(token, owner, repoName, newTreeSha);
634
+ const blobs = newTree.filter((e) => e.type === "blob");
635
+ expect(blobs).toHaveLength(3);
636
+
637
+ // Verify: index.html has new SHA
638
+ const indexEntry = blobs.find((e) => e.path === "index.html");
639
+ expect(indexEntry?.sha).toBe(newBlobSha);
640
+
641
+ // Verify: other files are unchanged (same SHA as scenario 1)
642
+ const aboutEntry = blobs.find(
643
+ (e) => e.path === "about/index.html"
644
+ );
645
+ const scenario1About = currentTree.find(
646
+ (e: { path: string }) => e.path === "about/index.html"
647
+ );
648
+ expect(aboutEntry?.sha).toBe(scenario1About?.sha);
649
+
650
+ // Verify: main branch is unchanged
651
+ const mainRef = await getBranchRef(token, owner, repoName, "main");
652
+ expect(mainRef.exists).toBe(true);
653
+ }, 30000);
654
+ });
655
+
656
+ // ========================================================================
657
+ // Scenario 3: No-change deploy
658
+ // ========================================================================
659
+
660
+ describe("Scenario 3: No-change deploy", () => {
661
+ it("detects no changes via blob SHA idempotency", async () => {
662
+ // The core insight: uploading identical content produces identical blob SHAs.
663
+ // Our diff algorithm compares local blob SHAs against remote tree SHAs.
664
+ // If they match, there are no changes to deploy.
665
+
666
+ // Upload the same content twice and verify SHAs match
667
+ const content = "<h1>Idempotency Test</h1>";
668
+ const firstSha = await uploadBlob(
669
+ token,
670
+ owner,
671
+ repoName,
672
+ textToBase64(content),
673
+ "base64"
674
+ );
675
+
676
+ const secondSha = await uploadBlob(
677
+ token,
678
+ owner,
679
+ repoName,
680
+ textToBase64(content),
681
+ "base64"
682
+ );
683
+
684
+ // Same content = same SHA (content-addressed storage)
685
+ expect(firstSha).toBe(secondSha);
686
+ expect(firstSha).toHaveLength(40); // SHA-1 hex
687
+
688
+ // Now verify against a tree: create a tree with this blob, then
689
+ // confirm the tree entry's SHA matches what uploadBlob returned
690
+ const treeSha = await createTree(
691
+ token,
692
+ owner,
693
+ repoName,
694
+ [
695
+ {
696
+ path: "test.html",
697
+ mode: "100644",
698
+ type: "blob",
699
+ sha: firstSha,
700
+ },
701
+ ],
702
+ null
703
+ );
704
+
705
+ const tree = await getTree(token, owner, repoName, treeSha);
706
+ const entry = tree.find((e) => e.path === "test.html");
707
+ expect(entry?.sha).toBe(firstSha);
708
+
709
+ // This proves: if local hash === remote hash, we can skip the deploy
710
+ // (no new tree, commit, or ref update needed)
711
+ }, 30000);
712
+ });
713
+
714
+ // ========================================================================
715
+ // Scenario 4: Deploy with file deletions
716
+ // ========================================================================
717
+
718
+ describe("Scenario 4: Deploy with file deletions", () => {
719
+ it("removes about/index.html by setting sha to null", async () => {
720
+ // Get current gh-pages state
721
+ const ghPages = await getBranchRef(
722
+ token,
723
+ owner,
724
+ repoName,
725
+ "gh-pages"
726
+ );
727
+ const commit = await getCommit(
728
+ token,
729
+ owner,
730
+ repoName,
731
+ ghPages.sha!
732
+ );
733
+
734
+ // Create tree with deletion (sha: null removes the file)
735
+ const treeEntries = [
736
+ {
737
+ path: "about/index.html",
738
+ mode: "100644",
739
+ type: "blob",
740
+ sha: null,
741
+ },
742
+ ];
743
+
744
+ const newTreeSha = await createTree(
745
+ token,
746
+ owner,
747
+ repoName,
748
+ treeEntries,
749
+ commit.treeSha
750
+ );
751
+
752
+ // Create commit
753
+ const commitSha = await createCommit(
754
+ token,
755
+ owner,
756
+ repoName,
757
+ "Deploy site\n\nGenerated by moss",
758
+ newTreeSha,
759
+ [ghPages.sha!]
760
+ );
761
+
762
+ // Update ref
763
+ await updateRef(
764
+ token,
765
+ owner,
766
+ repoName,
767
+ "gh-pages",
768
+ commitSha,
769
+ true
770
+ );
771
+
772
+ // Verify: tree no longer has about/index.html
773
+ const newTree = await getTree(token, owner, repoName, newTreeSha);
774
+ const blobs = newTree.filter((e) => e.type === "blob");
775
+ expect(blobs).toHaveLength(2); // Was 3, now 2
776
+ expect(blobs.map((e) => e.path).sort()).toEqual([
777
+ "articles/hello/index.html",
778
+ "index.html",
779
+ ]);
780
+ // about/index.html should NOT be in the tree
781
+ expect(
782
+ blobs.find((e) => e.path === "about/index.html")
783
+ ).toBeUndefined();
784
+ }, 30000);
785
+ });
786
+
787
+ // ========================================================================
788
+ // Scenario 5: Binary files (images)
789
+ // ========================================================================
790
+
791
+ describe("Scenario 5: Binary files", () => {
792
+ it("uploads a PNG file as base64 blob", async () => {
793
+ // Create a minimal valid 1x1 red PNG (base64)
794
+ const pngBase64 =
795
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==";
796
+
797
+ const blobSha = await uploadBlob(
798
+ token,
799
+ owner,
800
+ repoName,
801
+ pngBase64,
802
+ "base64"
803
+ );
804
+
805
+ expect(blobSha).toBeTruthy();
806
+ expect(blobSha).toHaveLength(40); // SHA-1 hex
807
+
808
+ // Add the image to gh-pages tree
809
+ const ghPages = await getBranchRef(
810
+ token,
811
+ owner,
812
+ repoName,
813
+ "gh-pages"
814
+ );
815
+ const commit = await getCommit(
816
+ token,
817
+ owner,
818
+ repoName,
819
+ ghPages.sha!
820
+ );
821
+
822
+ const treeEntries = [
823
+ {
824
+ path: "assets/photo.png",
825
+ mode: "100644",
826
+ type: "blob",
827
+ sha: blobSha,
828
+ },
829
+ ];
830
+
831
+ const newTreeSha = await createTree(
832
+ token,
833
+ owner,
834
+ repoName,
835
+ treeEntries,
836
+ commit.treeSha
837
+ );
838
+
839
+ const commitSha = await createCommit(
840
+ token,
841
+ owner,
842
+ repoName,
843
+ "Deploy site\n\nGenerated by moss",
844
+ newTreeSha,
845
+ [ghPages.sha!]
846
+ );
847
+
848
+ await updateRef(
849
+ token,
850
+ owner,
851
+ repoName,
852
+ "gh-pages",
853
+ commitSha,
854
+ true
855
+ );
856
+
857
+ // Verify: tree now includes the PNG
858
+ const newTree = await getTree(token, owner, repoName, newTreeSha);
859
+ const blobs = newTree.filter((e) => e.type === "blob");
860
+ const pngEntry = blobs.find(
861
+ (e) => e.path === "assets/photo.png"
862
+ );
863
+ expect(pngEntry).toBeDefined();
864
+ expect(pngEntry?.sha).toBe(blobSha);
865
+
866
+ // Verify blob content can be retrieved
867
+ const retrievedBlob = await fetch(
868
+ `${GITHUB_API_BASE}/repos/${owner}/${repoName}/git/blobs/${blobSha}`,
869
+ { headers: authHeaders(token) }
870
+ );
871
+ expect(retrievedBlob.ok).toBe(true);
872
+ const blobData = await retrievedBlob.json();
873
+ expect(blobData.encoding).toBe("base64");
874
+ // The content should be the same PNG data (may have newlines added by GitHub)
875
+ expect(blobData.content.replace(/\n/g, "")).toBe(pngBase64);
876
+ }, 30000);
877
+ });
878
+ });
879
+
880
+ // ============================================================================
881
+ // Scenario 6: Unicode filenames (independent repo)
882
+ // ============================================================================
883
+
884
+ describe.skipIf(!RUN_E2E)("Scenario 6: Unicode filenames", () => {
885
+ let token: string;
886
+ let owner: string;
887
+ const repoName = `moss-e2e-unicode-${Date.now()}`;
888
+
889
+ beforeAll(async () => {
890
+ token = getToken();
891
+ owner = await getUsername(token);
892
+ await createEphemeralRepo(token, repoName);
893
+ await new Promise((r) => setTimeout(r, 2000));
894
+ }, 30000);
895
+
896
+ afterAll(async () => {
897
+ await deleteRepo(token, owner, repoName);
898
+ }, 15000);
899
+
900
+ it("handles Chinese and Japanese filenames", async () => {
901
+ // Upload files with unicode paths
902
+ const files = [
903
+ { path: "文章/hello.html", content: "<h1>你好世界</h1>" },
904
+ { path: "游记/旅行.html", content: "<h1>旅行日记</h1>" },
905
+ { path: "日本語/テスト.html", content: "<h1>テスト</h1>" },
906
+ { path: "café/résumé.html", content: "<h1>Résumé</h1>" },
907
+ ];
908
+
909
+ const blobShas: Record<string, string> = {};
910
+ for (const file of files) {
911
+ blobShas[file.path] = await uploadBlob(
912
+ token,
913
+ owner,
914
+ repoName,
915
+ textToBase64(file.content),
916
+ "base64"
917
+ );
918
+ }
919
+
920
+ // Create tree
921
+ const treeEntries = files.map((f) => ({
922
+ path: f.path,
923
+ mode: "100644",
924
+ type: "blob",
925
+ sha: blobShas[f.path],
926
+ }));
927
+
928
+ const treeSha = await createTree(
929
+ token,
930
+ owner,
931
+ repoName,
932
+ treeEntries,
933
+ null
934
+ );
935
+ const commitSha = await createCommit(
936
+ token,
937
+ owner,
938
+ repoName,
939
+ "Deploy site",
940
+ treeSha,
941
+ []
942
+ );
943
+ await updateRef(token, owner, repoName, "gh-pages", commitSha, false);
944
+
945
+ // Verify: tree has all 4 files with correct unicode paths
946
+ const tree = await getTree(token, owner, repoName, treeSha);
947
+ const blobs = tree.filter((e) => e.type === "blob");
948
+ expect(blobs).toHaveLength(4);
949
+
950
+ const paths = blobs.map((e) => e.path).sort();
951
+ expect(paths).toContain("文章/hello.html");
952
+ expect(paths).toContain("游记/旅行.html");
953
+ expect(paths).toContain("日本語/テスト.html");
954
+ expect(paths).toContain("café/résumé.html");
955
+
956
+ // Verify: content can be retrieved correctly
957
+ const helloSha = blobShas["文章/hello.html"];
958
+ const content = await getBlobContent(token, owner, repoName, helloSha);
959
+ expect(content).toBe("<h1>你好世界</h1>");
960
+ }, 30000);
961
+ });
962
+
963
+ // ============================================================================
964
+ // Scenario 7: Large site (50+ files, independent repo)
965
+ // ============================================================================
966
+
967
+ describe.skipIf(!RUN_E2E)("Scenario 7: Large site (50+ files)", () => {
968
+ let token: string;
969
+ let owner: string;
970
+ const repoName = `moss-e2e-large-${Date.now()}`;
971
+
972
+ beforeAll(async () => {
973
+ token = getToken();
974
+ owner = await getUsername(token);
975
+ await createEphemeralRepo(token, repoName);
976
+ await new Promise((r) => setTimeout(r, 2000));
977
+ }, 30000);
978
+
979
+ afterAll(async () => {
980
+ await deleteRepo(token, owner, repoName);
981
+ }, 15000);
982
+
983
+ it("deploys 50 files without errors", async () => {
984
+ const FILE_COUNT = 50;
985
+
986
+ // Generate 50 files
987
+ const files = Array.from({ length: FILE_COUNT }, (_, i) => ({
988
+ path: `articles/article-${String(i + 1).padStart(3, "0")}/index.html`,
989
+ content: `<h1>Article ${i + 1}</h1><p>Content for article ${i + 1}.</p>`,
990
+ }));
991
+
992
+ // Upload all blobs
993
+ const blobShas: Record<string, string> = {};
994
+ for (const file of files) {
995
+ blobShas[file.path] = await uploadBlob(
996
+ token,
997
+ owner,
998
+ repoName,
999
+ textToBase64(file.content),
1000
+ "base64"
1001
+ );
1002
+ }
1003
+
1004
+ // Create tree
1005
+ const treeEntries = files.map((f) => ({
1006
+ path: f.path,
1007
+ mode: "100644",
1008
+ type: "blob",
1009
+ sha: blobShas[f.path],
1010
+ }));
1011
+
1012
+ const treeSha = await createTree(
1013
+ token,
1014
+ owner,
1015
+ repoName,
1016
+ treeEntries,
1017
+ null
1018
+ );
1019
+ const commitSha = await createCommit(
1020
+ token,
1021
+ owner,
1022
+ repoName,
1023
+ "Deploy site",
1024
+ treeSha,
1025
+ []
1026
+ );
1027
+ await updateRef(token, owner, repoName, "gh-pages", commitSha, false);
1028
+
1029
+ // Verify: tree has all 50 files
1030
+ const tree = await getTree(token, owner, repoName, treeSha);
1031
+ const blobs = tree.filter((e) => e.type === "blob");
1032
+ expect(blobs).toHaveLength(FILE_COUNT);
1033
+
1034
+ // Verify first and last
1035
+ expect(
1036
+ blobs.find((e) => e.path === "articles/article-001/index.html")
1037
+ ).toBeDefined();
1038
+ expect(
1039
+ blobs.find((e) => e.path === "articles/article-050/index.html")
1040
+ ).toBeDefined();
1041
+ }, 120000); // 2 minute timeout for 50 uploads
1042
+ });
1043
+
1044
+ // ============================================================================
1045
+ // Scenario 8: Auth failure (independent repo)
1046
+ // ============================================================================
1047
+
1048
+ describe.skipIf(!RUN_E2E)("Scenario 8: Auth failure", () => {
1049
+ let token: string;
1050
+ let owner: string;
1051
+ const repoName = `moss-e2e-auth-${Date.now()}`;
1052
+
1053
+ beforeAll(async () => {
1054
+ token = getToken();
1055
+ owner = await getUsername(token);
1056
+ await createEphemeralRepo(token, repoName);
1057
+ await new Promise((r) => setTimeout(r, 2000));
1058
+ }, 30000);
1059
+
1060
+ afterAll(async () => {
1061
+ await deleteRepo(token, owner, repoName);
1062
+ }, 15000);
1063
+
1064
+ it("returns 401 for invalid token", async () => {
1065
+ const badToken = "ghp_invalidtoken123456789012345678901";
1066
+
1067
+ const res = await fetch(
1068
+ `${GITHUB_API_BASE}/repos/${owner}/${repoName}/git/refs/heads/gh-pages`,
1069
+ {
1070
+ headers: {
1071
+ Accept: "application/vnd.github.v3+json",
1072
+ "User-Agent": "moss-E2E-Tests",
1073
+ Authorization: `Bearer ${badToken}`,
1074
+ },
1075
+ }
1076
+ );
1077
+
1078
+ expect(res.status).toBe(401);
1079
+ const body = await res.json();
1080
+ expect(body.message).toContain("Bad credentials");
1081
+ }, 15000);
1082
+
1083
+ it("returns structured error for blob upload with invalid token", async () => {
1084
+ const badToken = "ghp_invalidtoken123456789012345678901";
1085
+
1086
+ const res = await fetch(
1087
+ `${GITHUB_API_BASE}/repos/${owner}/${repoName}/git/blobs`,
1088
+ {
1089
+ method: "POST",
1090
+ headers: {
1091
+ Accept: "application/vnd.github.v3+json",
1092
+ "User-Agent": "moss-E2E-Tests",
1093
+ Authorization: `Bearer ${badToken}`,
1094
+ "Content-Type": "application/json",
1095
+ },
1096
+ body: JSON.stringify({
1097
+ content: textToBase64("test"),
1098
+ encoding: "base64",
1099
+ }),
1100
+ }
1101
+ );
1102
+
1103
+ expect(res.status).toBe(401);
1104
+ }, 15000);
1105
+
1106
+ it("succeeds after retrying with valid token", async () => {
1107
+ // First, verify bad token fails
1108
+ const badToken = "ghp_invalidtoken123456789012345678901";
1109
+ const failRes = await fetch(
1110
+ `${GITHUB_API_BASE}/repos/${owner}/${repoName}/git/refs/heads/gh-pages`,
1111
+ {
1112
+ headers: {
1113
+ Accept: "application/vnd.github.v3+json",
1114
+ "User-Agent": "moss-E2E-Tests",
1115
+ Authorization: `Bearer ${badToken}`,
1116
+ },
1117
+ }
1118
+ );
1119
+ expect(failRes.status).toBe(401);
1120
+
1121
+ // Then, verify good token succeeds
1122
+ const goodRes = await fetch(
1123
+ `${GITHUB_API_BASE}/repos/${owner}/${repoName}/git/refs/heads/gh-pages`,
1124
+ { headers: authHeaders(token) }
1125
+ );
1126
+ // 404 is expected (no gh-pages yet), but NOT 401
1127
+ expect(goodRes.status).not.toBe(401);
1128
+ }, 15000);
1129
+ });