@larkiny/astro-github-loader 0.10.1 → 0.11.0

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.
@@ -0,0 +1,599 @@
1
+ import { beforeEach, describe, it, expect, vi } from "vitest";
2
+ import { toCollectionEntry } from "./github.content.js";
3
+ import { Octokit } from "octokit";
4
+ import type { ImportOptions } from "./github.types.js";
5
+
6
+ /**
7
+ * Test suite for Git Trees API optimization
8
+ *
9
+ * These tests verify that the new Git Trees API approach:
10
+ * 1. Correctly discovers files matching include patterns
11
+ * 2. Reduces API calls compared to recursive approach
12
+ * 3. Works with the expected repository configuration
13
+ *
14
+ * These are unit tests with mocked API responses to test the optimization
15
+ * logic without requiring network access.
16
+ */
17
+ describe("Git Trees API Optimization", () => {
18
+ let octokit: Octokit;
19
+
20
+ // Mock commit data
21
+ const mockCommit = {
22
+ sha: "abc123def456",
23
+ commit: {
24
+ tree: {
25
+ sha: "tree123abc456"
26
+ },
27
+ message: "Test commit",
28
+ author: {
29
+ name: "Test Author",
30
+ email: "test@example.com",
31
+ date: "2024-01-01T00:00:00Z"
32
+ },
33
+ committer: {
34
+ name: "Test Committer",
35
+ email: "test@example.com",
36
+ date: "2024-01-01T00:00:00Z"
37
+ }
38
+ }
39
+ };
40
+
41
+ // Mock tree data representing a repository structure similar to algokit-cli
42
+ const mockTreeData = {
43
+ sha: "tree123abc456",
44
+ url: "https://api.github.com/repos/test/repo/git/trees/tree123abc456",
45
+ tree: [
46
+ {
47
+ path: "docs/algokit.md",
48
+ mode: "100644",
49
+ type: "blob",
50
+ sha: "file1sha",
51
+ size: 1234,
52
+ url: "https://api.github.com/repos/test/repo/git/blobs/file1sha"
53
+ },
54
+ {
55
+ path: "docs/features",
56
+ mode: "040000",
57
+ type: "tree",
58
+ sha: "dir1sha",
59
+ url: "https://api.github.com/repos/test/repo/git/trees/dir1sha"
60
+ },
61
+ {
62
+ path: "docs/features/accounts.md",
63
+ mode: "100644",
64
+ type: "blob",
65
+ sha: "file2sha",
66
+ size: 2345,
67
+ url: "https://api.github.com/repos/test/repo/git/blobs/file2sha"
68
+ },
69
+ {
70
+ path: "docs/features/tasks.md",
71
+ mode: "100644",
72
+ type: "blob",
73
+ sha: "file3sha",
74
+ size: 3456,
75
+ url: "https://api.github.com/repos/test/repo/git/blobs/file3sha"
76
+ },
77
+ {
78
+ path: "docs/features/generate.md",
79
+ mode: "100644",
80
+ type: "blob",
81
+ sha: "file4sha",
82
+ size: 4567,
83
+ url: "https://api.github.com/repos/test/repo/git/blobs/file4sha"
84
+ },
85
+ {
86
+ path: "docs/cli/index.md",
87
+ mode: "100644",
88
+ type: "blob",
89
+ sha: "file5sha",
90
+ size: 5678,
91
+ url: "https://api.github.com/repos/test/repo/git/blobs/file5sha"
92
+ },
93
+ {
94
+ path: "README.md",
95
+ mode: "100644",
96
+ type: "blob",
97
+ sha: "file6sha",
98
+ size: 678,
99
+ url: "https://api.github.com/repos/test/repo/git/blobs/file6sha"
100
+ },
101
+ {
102
+ path: "package.json",
103
+ mode: "100644",
104
+ type: "blob",
105
+ sha: "file7sha",
106
+ size: 789,
107
+ url: "https://api.github.com/repos/test/repo/git/blobs/file7sha"
108
+ }
109
+ ],
110
+ truncated: false
111
+ };
112
+
113
+ beforeEach(() => {
114
+ // Create Octokit instance
115
+ octokit = new Octokit({ auth: "mock-token" });
116
+
117
+ // Reset all mocks
118
+ vi.restoreAllMocks();
119
+ });
120
+
121
+ describe("API call efficiency", () => {
122
+ it("should use Git Trees API (2 calls) instead of recursive getContent (N calls)", async () => {
123
+ // Mock the API calls
124
+ const listCommitsMock = vi.spyOn(octokit.rest.repos, 'listCommits')
125
+ .mockResolvedValue({
126
+ data: [mockCommit],
127
+ status: 200,
128
+ url: '',
129
+ headers: {}
130
+ } as any);
131
+
132
+ const getTreeMock = vi.spyOn(octokit.rest.git, 'getTree')
133
+ .mockResolvedValue({
134
+ data: mockTreeData,
135
+ status: 200,
136
+ url: '',
137
+ headers: {}
138
+ } as any);
139
+
140
+ const getContentMock = vi.spyOn(octokit.rest.repos, 'getContent')
141
+ .mockResolvedValue({
142
+ data: [],
143
+ status: 200,
144
+ url: '',
145
+ headers: {}
146
+ } as any);
147
+
148
+ // Mock fetch for file downloads
149
+ global.fetch = vi.fn().mockResolvedValue({
150
+ ok: true,
151
+ status: 200,
152
+ headers: new Headers(),
153
+ text: async () => "# Test Content\n\nThis is test markdown content."
154
+ } as any);
155
+
156
+ const testConfig: ImportOptions = {
157
+ name: "Test Repo",
158
+ owner: "algorandfoundation",
159
+ repo: "algokit-cli",
160
+ ref: "chore/content-fix",
161
+ includes: [
162
+ {
163
+ pattern: "docs/{features/**/*.md,algokit.md}",
164
+ basePath: "test-output",
165
+ },
166
+ ],
167
+ };
168
+
169
+ // Create minimal mock context with Astro-specific components
170
+ const mockStore = new Map();
171
+ const mockContext = {
172
+ store: {
173
+ set: (entry: any) => mockStore.set(entry.id, entry),
174
+ get: (id: string) => mockStore.get(id),
175
+ clear: () => mockStore.clear(),
176
+ entries: () => mockStore.entries(),
177
+ keys: () => mockStore.keys(),
178
+ values: () => mockStore.values(),
179
+ },
180
+ meta: new Map(),
181
+ logger: {
182
+ info: vi.fn(),
183
+ warn: vi.fn(),
184
+ error: vi.fn(),
185
+ debug: vi.fn(),
186
+ verbose: vi.fn(),
187
+ logFileProcessing: vi.fn(),
188
+ logImportSummary: vi.fn(),
189
+ withSpinner: async (msg: string, fn: () => Promise<any>) => await fn(),
190
+ getLevel: () => 'default',
191
+ },
192
+ config: {},
193
+ entryTypes: new Map([
194
+ ['.md', {
195
+ getEntryInfo: async ({ contents, fileUrl }: any) => ({
196
+ body: contents,
197
+ data: {}
198
+ })
199
+ }]
200
+ ]),
201
+ generateDigest: (content: string) => {
202
+ // Simple hash function for testing
203
+ return content.length.toString();
204
+ },
205
+ parseData: async (data: any) => data,
206
+ };
207
+
208
+ await toCollectionEntry({
209
+ context: mockContext as any,
210
+ octokit,
211
+ options: testConfig,
212
+ });
213
+
214
+ // Verify Git Trees API is used
215
+ expect(listCommitsMock).toHaveBeenCalledTimes(1);
216
+ expect(listCommitsMock).toHaveBeenCalledWith(
217
+ expect.objectContaining({
218
+ owner: "algorandfoundation",
219
+ repo: "algokit-cli",
220
+ sha: "chore/content-fix",
221
+ per_page: 1,
222
+ })
223
+ );
224
+
225
+ expect(getTreeMock).toHaveBeenCalledTimes(1);
226
+ expect(getTreeMock).toHaveBeenCalledWith(
227
+ expect.objectContaining({
228
+ owner: "algorandfoundation",
229
+ repo: "algokit-cli",
230
+ tree_sha: "tree123abc456",
231
+ recursive: "true",
232
+ })
233
+ );
234
+
235
+ // Verify getContent is NOT called (old recursive approach)
236
+ expect(getContentMock).not.toHaveBeenCalled();
237
+
238
+ console.log('āœ… API Efficiency Test Results:');
239
+ console.log(` - listCommits calls: ${listCommitsMock.mock.calls.length} (expected: 1)`);
240
+ console.log(` - getTree calls: ${getTreeMock.mock.calls.length} (expected: 1)`);
241
+ console.log(` - getContent calls: ${getContentMock.mock.calls.length} (expected: 0)`);
242
+ console.log(` - Total API calls for discovery: ${listCommitsMock.mock.calls.length + getTreeMock.mock.calls.length}`);
243
+ console.log(` - šŸŽ‰ Optimization achieved: 2 calls instead of potentially 10+ recursive calls`);
244
+ });
245
+ });
246
+
247
+ describe("file filtering", () => {
248
+ it("should correctly filter files matching the glob pattern", async () => {
249
+ const listCommitsMock = vi.spyOn(octokit.rest.repos, 'listCommits')
250
+ .mockResolvedValue({
251
+ data: [mockCommit],
252
+ status: 200,
253
+ url: '',
254
+ headers: {}
255
+ } as any);
256
+
257
+ const getTreeMock = vi.spyOn(octokit.rest.git, 'getTree')
258
+ .mockResolvedValue({
259
+ data: mockTreeData,
260
+ status: 200,
261
+ url: '',
262
+ headers: {}
263
+ } as any);
264
+
265
+ global.fetch = vi.fn().mockResolvedValue({
266
+ ok: true,
267
+ status: 200,
268
+ headers: new Headers(),
269
+ text: async () => "# Test Content\n\nMockfile content."
270
+ } as any);
271
+
272
+ const testConfig: ImportOptions = {
273
+ name: "Test filtering",
274
+ owner: "algorandfoundation",
275
+ repo: "algokit-cli",
276
+ ref: "chore/content-fix",
277
+ includes: [
278
+ {
279
+ pattern: "docs/{features/**/*.md,algokit.md}",
280
+ basePath: "test-output",
281
+ },
282
+ ],
283
+ };
284
+
285
+ const mockStore = new Map();
286
+ const mockContext = {
287
+ store: {
288
+ set: (entry: any) => {
289
+ mockStore.set(entry.id, entry);
290
+ return entry;
291
+ },
292
+ get: (id: string) => mockStore.get(id),
293
+ clear: () => mockStore.clear(),
294
+ entries: () => mockStore.entries(),
295
+ keys: () => mockStore.keys(),
296
+ values: () => mockStore.values(),
297
+ },
298
+ meta: new Map(),
299
+ logger: {
300
+ info: vi.fn(),
301
+ warn: vi.fn(),
302
+ error: vi.fn(),
303
+ debug: vi.fn(),
304
+ verbose: vi.fn(),
305
+ logFileProcessing: vi.fn(),
306
+ logImportSummary: vi.fn(),
307
+ withSpinner: async (msg: string, fn: () => Promise<any>) => await fn(),
308
+ getLevel: () => 'default',
309
+ },
310
+ config: {},
311
+ entryTypes: new Map([['.md', { getEntryInfo: async ({ contents }: any) => ({ body: contents, data: {} }) }]]),
312
+ generateDigest: (content: string) => content.length.toString(),
313
+ parseData: async (data: any) => data,
314
+ };
315
+
316
+ const stats = await toCollectionEntry({
317
+ context: mockContext as any,
318
+ octokit,
319
+ options: testConfig,
320
+ });
321
+
322
+ console.log('\nšŸ” File Filtering Test Results:');
323
+ console.log(` - Pattern: docs/{features/**/*.md,algokit.md}`);
324
+ console.log(` - Files in tree: ${mockTreeData.tree.length}`);
325
+ console.log(` - Files processed: ${stats.processed}`);
326
+ console.log(` - Files matched: ${mockStore.size}`);
327
+
328
+ // Based on our mock data, we should match:
329
+ // - docs/algokit.md (explicit match)
330
+ // - docs/features/accounts.md (matches features/**/*.md)
331
+ // - docs/features/tasks.md (matches features/**/*.md)
332
+ // - docs/features/generate.md (matches features/**/*.md)
333
+ // Should NOT match:
334
+ // - docs/cli/index.md (not in pattern)
335
+ // - README.md (not in pattern)
336
+ // - package.json (not in pattern)
337
+
338
+ expect(stats.processed).toBe(4); // algokit.md + 3 features/*.md files
339
+ expect(mockStore.size).toBe(4);
340
+
341
+ // Verify correct files were stored
342
+ const storedIds = Array.from(mockStore.keys());
343
+ expect(storedIds).toContain('docs/algokit');
344
+ expect(storedIds.some(id => id.includes('features'))).toBe(true);
345
+ expect(storedIds).not.toContain('package');
346
+ expect(storedIds).not.toContain('README');
347
+ });
348
+
349
+ it("should filter to match only specific file when pattern is exact", async () => {
350
+ vi.spyOn(octokit.rest.repos, 'listCommits')
351
+ .mockResolvedValue({ data: [mockCommit], status: 200, url: '', headers: {} } as any);
352
+
353
+ vi.spyOn(octokit.rest.git, 'getTree')
354
+ .mockResolvedValue({ data: mockTreeData, status: 200, url: '', headers: {} } as any);
355
+
356
+ global.fetch = vi.fn().mockResolvedValue({
357
+ ok: true,
358
+ status: 200,
359
+ headers: new Headers(),
360
+ text: async () => "# Single File Content"
361
+ } as any);
362
+
363
+ const testConfig: ImportOptions = {
364
+ name: "Exact match test",
365
+ owner: "test",
366
+ repo: "repo",
367
+ ref: "main",
368
+ includes: [{
369
+ pattern: "docs/algokit.md", // Exact file
370
+ basePath: "test-output",
371
+ }],
372
+ };
373
+
374
+ const mockStore = new Map();
375
+ const mockContext = {
376
+ store: {
377
+ set: (entry: any) => mockStore.set(entry.id, entry),
378
+ get: (id: string) => mockStore.get(id),
379
+ clear: () => mockStore.clear(),
380
+ entries: () => mockStore.entries(),
381
+ keys: () => mockStore.keys(),
382
+ values: () => mockStore.values(),
383
+ },
384
+ meta: new Map(),
385
+ logger: {
386
+ info: vi.fn(),
387
+ warn: vi.fn(),
388
+ error: vi.fn(),
389
+ debug: vi.fn(),
390
+ verbose: vi.fn(),
391
+ logFileProcessing: vi.fn(),
392
+ logImportSummary: vi.fn(),
393
+ withSpinner: async (msg: string, fn: () => Promise<any>) => await fn(),
394
+ getLevel: () => 'default',
395
+ },
396
+ config: {},
397
+ entryTypes: new Map([['.md', { getEntryInfo: async ({ contents }: any) => ({ body: contents, data: {} }) }]]),
398
+ generateDigest: (content: string) => content.length.toString(),
399
+ parseData: async (data: any) => data,
400
+ };
401
+
402
+ const stats = await toCollectionEntry({
403
+ context: mockContext as any,
404
+ octokit,
405
+ options: testConfig,
406
+ });
407
+
408
+ console.log('\nšŸŽÆ Exact Pattern Match Test:');
409
+ console.log(` - Pattern: docs/algokit.md`);
410
+ console.log(` - Files processed: ${stats.processed}`);
411
+ console.log(` - Expected: 1 file`);
412
+
413
+ expect(stats.processed).toBe(1);
414
+ expect(mockStore.size).toBe(1);
415
+ expect(Array.from(mockStore.keys())[0]).toContain('algokit');
416
+ });
417
+ });
418
+
419
+ describe("download URL construction", () => {
420
+ it("should construct valid raw.githubusercontent.com URLs from tree data", async () => {
421
+ vi.spyOn(octokit.rest.repos, 'listCommits')
422
+ .mockResolvedValue({ data: [mockCommit], status: 200, url: '', headers: {} } as any);
423
+
424
+ vi.spyOn(octokit.rest.git, 'getTree')
425
+ .mockResolvedValue({ data: mockTreeData, status: 200, url: '', headers: {} } as any);
426
+
427
+ const fetchMock = vi.fn().mockResolvedValue({
428
+ ok: true,
429
+ status: 200,
430
+ headers: new Headers(),
431
+ text: async () => "# Content"
432
+ } as any);
433
+ global.fetch = fetchMock;
434
+
435
+ const testConfig: ImportOptions = {
436
+ name: "URL test",
437
+ owner: "algorandfoundation",
438
+ repo: "algokit-cli",
439
+ ref: "chore/content-fix",
440
+ includes: [{
441
+ pattern: "docs/algokit.md",
442
+ basePath: "test-output",
443
+ }],
444
+ };
445
+
446
+ const mockStore = new Map();
447
+ const mockContext = {
448
+ store: {
449
+ set: (entry: any) => mockStore.set(entry.id, entry),
450
+ get: (id: string) => mockStore.get(id),
451
+ clear: () => mockStore.clear(),
452
+ entries: () => mockStore.entries(),
453
+ keys: () => mockStore.keys(),
454
+ values: () => mockStore.values(),
455
+ },
456
+ meta: new Map(),
457
+ logger: {
458
+ info: vi.fn(),
459
+ warn: vi.fn(),
460
+ error: vi.fn(),
461
+ debug: vi.fn(),
462
+ verbose: vi.fn(),
463
+ logFileProcessing: vi.fn(),
464
+ logImportSummary: vi.fn(),
465
+ withSpinner: async (msg: string, fn: () => Promise<any>) => await fn(),
466
+ getLevel: () => 'default',
467
+ },
468
+ config: {},
469
+ entryTypes: new Map([['.md', { getEntryInfo: async ({ contents }: any) => ({ body: contents, data: {} }) }]]),
470
+ generateDigest: (content: string) => content.length.toString(),
471
+ parseData: async (data: any) => data,
472
+ };
473
+
474
+ await toCollectionEntry({
475
+ context: mockContext as any,
476
+ octokit,
477
+ options: testConfig,
478
+ });
479
+
480
+ // Find fetch calls to raw.githubusercontent.com
481
+ const rawGithubCalls = fetchMock.mock.calls.filter(call => {
482
+ const url = call[0]?.toString() || '';
483
+ return url.includes('raw.githubusercontent.com');
484
+ });
485
+
486
+ console.log('\nšŸ”— URL Construction Test:');
487
+ console.log(` - Total fetch calls: ${fetchMock.mock.calls.length}`);
488
+ console.log(` - Calls to raw.githubusercontent.com: ${rawGithubCalls.length}`);
489
+
490
+ expect(rawGithubCalls.length).toBeGreaterThan(0);
491
+
492
+ const exampleUrl = rawGithubCalls[0][0]?.toString();
493
+ console.log(` - Example URL: ${exampleUrl}`);
494
+
495
+ // Verify URL format: https://raw.githubusercontent.com/{owner}/{repo}/{commit_sha}/{file_path}
496
+ expect(exampleUrl).toMatch(
497
+ /^https:\/\/raw\.githubusercontent\.com\/algorandfoundation\/algokit-cli\/abc123def456\/docs\/algokit\.md$/
498
+ );
499
+ });
500
+ });
501
+
502
+ describe("real-world config simulation", () => {
503
+ it("should handle the production algokit-cli config pattern correctly", async () => {
504
+ vi.spyOn(octokit.rest.repos, 'listCommits')
505
+ .mockResolvedValue({ data: [mockCommit], status: 200, url: '', headers: {} } as any);
506
+
507
+ vi.spyOn(octokit.rest.git, 'getTree')
508
+ .mockResolvedValue({ data: mockTreeData, status: 200, url: '', headers: {} } as any);
509
+
510
+ global.fetch = vi.fn().mockResolvedValue({
511
+ ok: true,
512
+ status: 200,
513
+ headers: new Headers(),
514
+ text: async () => "# Content"
515
+ } as any);
516
+
517
+ // This is the actual production config from content.config.ts
518
+ const productionConfig: ImportOptions = {
519
+ name: "AlgoKit CLI Docs",
520
+ owner: "algorandfoundation",
521
+ repo: "algokit-cli",
522
+ ref: "chore/content-fix",
523
+ includes: [
524
+ {
525
+ pattern: "docs/{features/**/*.md,algokit.md}",
526
+ basePath: "src/content/docs/algokit/cli",
527
+ pathMappings: {
528
+ "docs/features/": "",
529
+ "docs/algokit.md": "overview.md",
530
+ },
531
+ },
532
+ {
533
+ pattern: "docs/cli/index.md",
534
+ basePath: "src/content/docs/reference/algokit-cli/",
535
+ pathMappings: {
536
+ "docs/cli/index.md": "index.md",
537
+ },
538
+ },
539
+ ],
540
+ };
541
+
542
+ const mockStore = new Map();
543
+ const mockContext = {
544
+ store: {
545
+ set: (entry: any) => mockStore.set(entry.id, entry),
546
+ get: (id: string) => mockStore.get(id),
547
+ clear: () => mockStore.clear(),
548
+ entries: () => mockStore.entries(),
549
+ keys: () => mockStore.keys(),
550
+ values: () => mockStore.values(),
551
+ },
552
+ meta: new Map(),
553
+ logger: {
554
+ info: vi.fn(),
555
+ warn: vi.fn(),
556
+ error: vi.fn(),
557
+ debug: vi.fn(),
558
+ verbose: vi.fn(),
559
+ logFileProcessing: vi.fn(),
560
+ logImportSummary: vi.fn(),
561
+ withSpinner: async (msg: string, fn: () => Promise<any>) => await fn(),
562
+ getLevel: () => 'default',
563
+ },
564
+ config: {},
565
+ entryTypes: new Map([['.md', { getEntryInfo: async ({ contents }: any) => ({ body: contents, data: {} }) }]]),
566
+ generateDigest: (content: string) => content.length.toString(),
567
+ parseData: async (data: any) => data,
568
+ };
569
+
570
+ const stats = await toCollectionEntry({
571
+ context: mockContext as any,
572
+ octokit,
573
+ options: productionConfig,
574
+ });
575
+
576
+ console.log('\nšŸ“‹ Production Config Test:');
577
+ console.log(` - Pattern 1: docs/{features/**/*.md,algokit.md}`);
578
+ console.log(` - Pattern 2: docs/cli/index.md`);
579
+ console.log(` - Files processed: ${stats.processed}`);
580
+ console.log(` - Expected files:`);
581
+ console.log(` • docs/algokit.md → overview.md (from pattern 1)`);
582
+ console.log(` • docs/features/accounts.md → accounts.md (from pattern 1)`);
583
+ console.log(` • docs/features/tasks.md → tasks.md (from pattern 1)`);
584
+ console.log(` • docs/features/generate.md → generate.md (from pattern 1)`);
585
+ console.log(` • docs/cli/index.md → index.md (from pattern 2)`);
586
+
587
+ // Should match 4 files from pattern 1 + 1 file from pattern 2
588
+ expect(stats.processed).toBe(5);
589
+
590
+ const storedIds = Array.from(mockStore.keys());
591
+ console.log(` - Stored IDs:`, storedIds);
592
+
593
+ // Verify expected files are stored
594
+ expect(storedIds.some(id => id.includes('overview'))).toBe(true); // algokit.md mapped to overview
595
+ expect(storedIds.filter(id => id.includes('features')).length).toBe(3); // 3 features files
596
+ expect(storedIds.some(id => id.includes('cli') && id.includes('index'))).toBe(true); // cli/index.md
597
+ });
598
+ });
599
+ });