@symbiosis-lab/moss-plugin-github 1.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +30 -0
- package/README.md +18 -0
- package/assets/icon.svg +3 -0
- package/assets/manifest.json +19 -0
- package/e2e/deploy-api.test.ts +1129 -0
- package/e2e/moss-cli.test.ts +478 -0
- package/features/auth/device-flow.feature +41 -0
- package/features/deploy/validation.feature +50 -0
- package/features/steps/auth.steps.ts +285 -0
- package/features/steps/deploy.steps.ts +354 -0
- package/package.json +51 -0
- package/src/__tests__/auth-flow.integration.test.ts +738 -0
- package/src/__tests__/auth.test.ts +147 -0
- package/src/__tests__/configure-domain.test.ts +263 -0
- package/src/__tests__/deploy.integration.test.ts +798 -0
- package/src/__tests__/git.test.ts +190 -0
- package/src/__tests__/github-api.test.ts +761 -0
- package/src/__tests__/github-deploy.test.ts +2411 -0
- package/src/__tests__/progress-timeout.test.ts +209 -0
- package/src/__tests__/repo-setup-progress.test.ts +367 -0
- package/src/__tests__/repo-setup.test.ts +370 -0
- package/src/__tests__/token.test.ts +152 -0
- package/src/__tests__/utils.test.ts +129 -0
- package/src/__tests__/workflow.test.ts +146 -0
- package/src/auth.ts +588 -0
- package/src/constants.ts +7 -0
- package/src/git.ts +60 -0
- package/src/github-api.ts +601 -0
- package/src/github-deploy.ts +593 -0
- package/src/main.ts +646 -0
- package/src/repo-setup.ts +685 -0
- package/src/token.ts +202 -0
- package/src/types.ts +91 -0
- package/src/utils.ts +108 -0
- package/src/workflow.ts +79 -0
- package/test-helpers/mock-github-api.ts +217 -0
- package/tsconfig.json +20 -0
- package/vitest.config.ts +50 -0
|
@@ -0,0 +1,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
|
+
});
|