nebula-cms 0.1.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.
- package/.claude/settings.local.json +42 -0
- package/.github/workflows/ci.yml +31 -0
- package/.mcp.json +12 -0
- package/.prettierignore +5 -0
- package/.prettierrc.cjs +22 -0
- package/AGENTS.md +183 -0
- package/LICENSE +201 -0
- package/README.md +128 -0
- package/package.json +74 -0
- package/playground/.claude/settings.local.json +5 -0
- package/playground/astro.config.mjs +7 -0
- package/playground/node_modules/.bin/astro +21 -0
- package/playground/node_modules/.bin/rollup +21 -0
- package/playground/node_modules/.bin/tsc +21 -0
- package/playground/node_modules/.bin/tsserver +21 -0
- package/playground/node_modules/.bin/vite +21 -0
- package/playground/node_modules/.vite/_svelte_metadata.json +1 -0
- package/playground/node_modules/.vite/deps/@astrojs_svelte_client__js.js +80 -0
- package/playground/node_modules/.vite/deps/@astrojs_svelte_client__js.js.map +7 -0
- package/playground/node_modules/.vite/deps/_metadata.json +184 -0
- package/playground/node_modules/.vite/deps/astro___aria-query.js +6776 -0
- package/playground/node_modules/.vite/deps/astro___aria-query.js.map +7 -0
- package/playground/node_modules/.vite/deps/astro___axobject-query.js +3754 -0
- package/playground/node_modules/.vite/deps/astro___axobject-query.js.map +7 -0
- package/playground/node_modules/.vite/deps/astro___html-escaper.js +34 -0
- package/playground/node_modules/.vite/deps/astro___html-escaper.js.map +7 -0
- package/playground/node_modules/.vite/deps/chunk-AJXJMYAF.js +0 -0
- package/playground/node_modules/.vite/deps/chunk-AJXJMYAF.js.map +7 -0
- package/playground/node_modules/.vite/deps/chunk-ALJIOON6.js +1005 -0
- package/playground/node_modules/.vite/deps/chunk-ALJIOON6.js.map +7 -0
- package/playground/node_modules/.vite/deps/chunk-BUSYA2B4.js +8 -0
- package/playground/node_modules/.vite/deps/chunk-BUSYA2B4.js.map +7 -0
- package/playground/node_modules/.vite/deps/chunk-CNYJBM5F.js +21 -0
- package/playground/node_modules/.vite/deps/chunk-CNYJBM5F.js.map +7 -0
- package/playground/node_modules/.vite/deps/chunk-DBPNBGEI.js +223 -0
- package/playground/node_modules/.vite/deps/chunk-DBPNBGEI.js.map +7 -0
- package/playground/node_modules/.vite/deps/chunk-G3C2FXJT.js +204 -0
- package/playground/node_modules/.vite/deps/chunk-G3C2FXJT.js.map +7 -0
- package/playground/node_modules/.vite/deps/chunk-GKDKFWC5.js +27 -0
- package/playground/node_modules/.vite/deps/chunk-GKDKFWC5.js.map +7 -0
- package/playground/node_modules/.vite/deps/chunk-HNCLEOC5.js +4376 -0
- package/playground/node_modules/.vite/deps/chunk-HNCLEOC5.js.map +7 -0
- package/playground/node_modules/.vite/deps/chunk-JICYXBFU.js +688 -0
- package/playground/node_modules/.vite/deps/chunk-JICYXBFU.js.map +7 -0
- package/playground/node_modules/.vite/deps/chunk-KCUTL6DD.js +5099 -0
- package/playground/node_modules/.vite/deps/chunk-KCUTL6DD.js.map +7 -0
- package/playground/node_modules/.vite/deps/chunk-ZP4UNCSN.js +23 -0
- package/playground/node_modules/.vite/deps/chunk-ZP4UNCSN.js.map +7 -0
- package/playground/node_modules/.vite/deps/chunk-ZREFNRZZ.js +148 -0
- package/playground/node_modules/.vite/deps/chunk-ZREFNRZZ.js.map +7 -0
- package/playground/node_modules/.vite/deps/package.json +3 -0
- package/playground/node_modules/.vite/deps/smol-toml.js +843 -0
- package/playground/node_modules/.vite/deps/smol-toml.js.map +7 -0
- package/playground/node_modules/.vite/deps/svelte.js +55 -0
- package/playground/node_modules/.vite/deps/svelte.js.map +7 -0
- package/playground/node_modules/.vite/deps/svelte___clsx.js +9 -0
- package/playground/node_modules/.vite/deps/svelte___clsx.js.map +7 -0
- package/playground/node_modules/.vite/deps/svelte_animate.js +57 -0
- package/playground/node_modules/.vite/deps/svelte_animate.js.map +7 -0
- package/playground/node_modules/.vite/deps/svelte_attachments.js +15 -0
- package/playground/node_modules/.vite/deps/svelte_attachments.js.map +7 -0
- package/playground/node_modules/.vite/deps/svelte_easing.js +67 -0
- package/playground/node_modules/.vite/deps/svelte_easing.js.map +7 -0
- package/playground/node_modules/.vite/deps/svelte_events.js +11 -0
- package/playground/node_modules/.vite/deps/svelte_events.js.map +7 -0
- package/playground/node_modules/.vite/deps/svelte_internal.js +5 -0
- package/playground/node_modules/.vite/deps/svelte_internal.js.map +7 -0
- package/playground/node_modules/.vite/deps/svelte_internal_client.js +402 -0
- package/playground/node_modules/.vite/deps/svelte_internal_client.js.map +7 -0
- package/playground/node_modules/.vite/deps/svelte_internal_disclose-version.js +10 -0
- package/playground/node_modules/.vite/deps/svelte_internal_disclose-version.js.map +7 -0
- package/playground/node_modules/.vite/deps/svelte_internal_flags_async.js +8 -0
- package/playground/node_modules/.vite/deps/svelte_internal_flags_async.js.map +7 -0
- package/playground/node_modules/.vite/deps/svelte_internal_flags_legacy.js +8 -0
- package/playground/node_modules/.vite/deps/svelte_internal_flags_legacy.js.map +7 -0
- package/playground/node_modules/.vite/deps/svelte_internal_flags_tracing.js +8 -0
- package/playground/node_modules/.vite/deps/svelte_internal_flags_tracing.js.map +7 -0
- package/playground/node_modules/.vite/deps/svelte_legacy.js +35 -0
- package/playground/node_modules/.vite/deps/svelte_legacy.js.map +7 -0
- package/playground/node_modules/.vite/deps/svelte_motion.js +545 -0
- package/playground/node_modules/.vite/deps/svelte_motion.js.map +7 -0
- package/playground/node_modules/.vite/deps/svelte_reactivity.js +29 -0
- package/playground/node_modules/.vite/deps/svelte_reactivity.js.map +7 -0
- package/playground/node_modules/.vite/deps/svelte_reactivity_window.js +127 -0
- package/playground/node_modules/.vite/deps/svelte_reactivity_window.js.map +7 -0
- package/playground/node_modules/.vite/deps/svelte_store.js +103 -0
- package/playground/node_modules/.vite/deps/svelte_store.js.map +7 -0
- package/playground/node_modules/.vite/deps/svelte_transition.js +208 -0
- package/playground/node_modules/.vite/deps/svelte_transition.js.map +7 -0
- package/playground/package.json +16 -0
- package/playground/pnpm-lock.yaml +3167 -0
- package/playground/src/content/authors/jane-doe.json +8 -0
- package/playground/src/content/config/build.toml +2 -0
- package/playground/src/content/courses/web-fundamentals.json +29 -0
- package/playground/src/content/docs/advanced.mdx +6 -0
- package/playground/src/content/docs/intro.md +6 -0
- package/playground/src/content/guides/getting-started.mdx +6 -0
- package/playground/src/content/posts/hello-world.md +7 -0
- package/playground/src/content/products/t-shirt.json +16 -0
- package/playground/src/content/recipes/pancakes.mdoc +8 -0
- package/playground/src/content/settings/site.yml +2 -0
- package/playground/src/content.config.ts +198 -0
- package/playground/src/env.d.ts +1 -0
- package/playground/src/pages/index.astro +11 -0
- package/playground/src/pages/nebula.astro +14 -0
- package/pnpm-workspace.yaml +2 -0
- package/scripts/subset-icons.mjs +178 -0
- package/src/astro/index.ts +295 -0
- package/src/client/Admin.svelte +283 -0
- package/src/client/components/BackendPicker.svelte +291 -0
- package/src/client/components/DraftChip.svelte +46 -0
- package/src/client/components/MetadataForm.svelte +56 -0
- package/src/client/components/ThemeToggle.svelte +18 -0
- package/src/client/components/dialogs/DeleteDraftDialog.svelte +51 -0
- package/src/client/components/dialogs/FilenameDialog.svelte +129 -0
- package/src/client/components/editor/EditorPane.svelte +227 -0
- package/src/client/components/editor/EditorTabs.svelte +81 -0
- package/src/client/components/editor/EditorToolbar.svelte +131 -0
- package/src/client/components/editor/FormatSelector.svelte +66 -0
- package/src/client/components/editor/Toolbar.svelte +17 -0
- package/src/client/components/fields/ArrayField.svelte +339 -0
- package/src/client/components/fields/ArrayItem.svelte +325 -0
- package/src/client/components/fields/BooleanField.svelte +114 -0
- package/src/client/components/fields/DateField.svelte +82 -0
- package/src/client/components/fields/EnumField.svelte +74 -0
- package/src/client/components/fields/FieldWrapper.svelte +96 -0
- package/src/client/components/fields/NumberField.svelte +99 -0
- package/src/client/components/fields/ObjectField.svelte +121 -0
- package/src/client/components/fields/SchemaField.svelte +107 -0
- package/src/client/components/fields/StringField.svelte +104 -0
- package/src/client/components/sidebar/AdminSidebar.svelte +339 -0
- package/src/client/components/sidebar/AdminSidebarSort.svelte +123 -0
- package/src/client/css/a11y.css +14 -0
- package/src/client/css/btn.css +113 -0
- package/src/client/css/dialog.css +29 -0
- package/src/client/css/field-input.css +39 -0
- package/src/client/css/reset.css +59 -0
- package/src/client/css/theme.css +77 -0
- package/src/client/index.ts +1 -0
- package/src/client/js/drafts/merge.svelte.ts +121 -0
- package/src/client/js/drafts/ops.svelte.ts +227 -0
- package/src/client/js/drafts/storage.ts +108 -0
- package/src/client/js/drafts/workers/diff.ts +40 -0
- package/src/client/js/editor/editor.svelte.ts +343 -0
- package/src/client/js/editor/languages.ts +98 -0
- package/src/client/js/editor/link-wrap.ts +45 -0
- package/src/client/js/editor/markdown-shortcuts.ts +261 -0
- package/src/client/js/handlers/admin.ts +246 -0
- package/src/client/js/state/dialogs.svelte.ts +35 -0
- package/src/client/js/state/router.svelte.ts +156 -0
- package/src/client/js/state/schema.svelte.ts +140 -0
- package/src/client/js/state/state.svelte.ts +334 -0
- package/src/client/js/state/theme.svelte.ts +173 -0
- package/src/client/js/storage/adapter.ts +102 -0
- package/src/client/js/storage/client.ts +150 -0
- package/src/client/js/storage/db.ts +36 -0
- package/src/client/js/storage/fsa.ts +110 -0
- package/src/client/js/storage/github.ts +297 -0
- package/src/client/js/storage/storage.ts +83 -0
- package/src/client/js/storage/workers/frontmatter.ts +320 -0
- package/src/client/js/storage/workers/storage.ts +177 -0
- package/src/client/js/storage/workers/toml-parser.ts +106 -0
- package/src/client/js/storage/workers/yaml-parser.ts +132 -0
- package/src/client/js/utils/file-types.ts +192 -0
- package/src/client/js/utils/format.ts +16 -0
- package/src/client/js/utils/frontmatter.ts +38 -0
- package/src/client/js/utils/schema-utils.ts +295 -0
- package/src/client/js/utils/slug.ts +18 -0
- package/src/client/js/utils/sort.ts +84 -0
- package/src/client/js/utils/stable-stringify.ts +27 -0
- package/src/client/js/utils/url-utils.ts +38 -0
- package/src/types.ts +25 -0
- package/src/virtual.d.ts +22 -0
- package/svelte.config.js +4 -0
- package/tests/astro/build.test.ts +63 -0
- package/tests/astro/index.test.ts +689 -0
- package/tests/client/components/Admin.test.ts +446 -0
- package/tests/client/components/BackendPicker.test.ts +239 -0
- package/tests/client/components/DraftChip.test.ts +53 -0
- package/tests/client/components/MetadataForm.test.ts +164 -0
- package/tests/client/components/dialogs/DeleteDraftDialog.test.ts +91 -0
- package/tests/client/components/dialogs/FilenameDialog.test.ts +209 -0
- package/tests/client/components/dialogs/dialog-stubs.ts +19 -0
- package/tests/client/components/editor/EditorPane.test.ts +100 -0
- package/tests/client/components/editor/EditorTabs.test.ts +253 -0
- package/tests/client/components/editor/EditorToolbar.test.ts +252 -0
- package/tests/client/components/editor/fixtures.ts +31 -0
- package/tests/client/components/fields/ArrayField.test.ts +197 -0
- package/tests/client/components/fields/BooleanField.test.ts +206 -0
- package/tests/client/components/fields/DateField.test.ts +210 -0
- package/tests/client/components/fields/EnumField.test.ts +246 -0
- package/tests/client/components/fields/NumberField.test.ts +240 -0
- package/tests/client/components/fields/ObjectField.test.ts +157 -0
- package/tests/client/components/fields/SchemaField.test.ts +190 -0
- package/tests/client/components/fields/StringField.test.ts +223 -0
- package/tests/client/components/sidebar/AdminSidebar.test.ts +285 -0
- package/tests/client/components/sidebar/AdminSidebarSort.test.ts +135 -0
- package/tests/client/components/sidebar/sort-mock.ts +23 -0
- package/tests/client/js/drafts/fixtures.ts +22 -0
- package/tests/client/js/drafts/merge.test.ts +282 -0
- package/tests/client/js/drafts/ops.test.ts +658 -0
- package/tests/client/js/drafts/storage.test.ts +200 -0
- package/tests/client/js/drafts/workers/diff.test.ts +165 -0
- package/tests/client/js/editor/editor.test.ts +616 -0
- package/tests/client/js/editor/link-wrap.test.ts +225 -0
- package/tests/client/js/editor/markdown-shortcuts.test.ts +370 -0
- package/tests/client/js/handlers/admin.test.ts +467 -0
- package/tests/client/js/state/router.test.ts +619 -0
- package/tests/client/js/state/schema.test.ts +266 -0
- package/tests/client/js/state/state.test.ts +328 -0
- package/tests/client/js/storage/adapter.test.ts +115 -0
- package/tests/client/js/storage/client.test.ts +250 -0
- package/tests/client/js/storage/db.test.ts +59 -0
- package/tests/client/js/storage/fsa.test.ts +284 -0
- package/tests/client/js/storage/github.test.ts +349 -0
- package/tests/client/js/storage/mock-port.ts +95 -0
- package/tests/client/js/storage/storage.test.ts +77 -0
- package/tests/client/js/storage/workers/frontmatter.test.ts +479 -0
- package/tests/client/js/storage/workers/storage.test.ts +299 -0
- package/tests/client/js/storage/workers/toml-parser.test.ts +169 -0
- package/tests/client/js/storage/workers/yaml-parser.test.ts +168 -0
- package/tests/client/js/utils/file-types.test.ts +268 -0
- package/tests/client/js/utils/frontmatter.test.ts +87 -0
- package/tests/client/js/utils/schema-utils.test.ts +318 -0
- package/tests/client/js/utils/slug.test.ts +58 -0
- package/tests/client/js/utils/sort.test.ts +276 -0
- package/tests/client/js/utils/stable-stringify.test.ts +68 -0
- package/tests/client/js/utils/url-utils.test.ts +70 -0
- package/tests/e2e/backend-connection.test.ts +301 -0
- package/tests/e2e/draft-lifecycle.test.ts +388 -0
- package/tests/e2e/editing.test.ts +355 -0
- package/tests/e2e/github-adapter.test.ts +330 -0
- package/tests/e2e/helpers/mock-adapter.ts +166 -0
- package/tests/e2e/helpers/test-app.ts +155 -0
- package/tests/e2e/navigation.test.ts +358 -0
- package/tests/e2e/publishing.test.ts +345 -0
- package/tests/e2e/unsaved-changes.test.ts +317 -0
- package/tests/setup.ts +2 -0
- package/tests/stubs/codemirror.ts +197 -0
- package/tsconfig.json +19 -0
- package/vitest.config.ts +178 -0
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
|
|
3
|
+
//////////////////////////////
|
|
4
|
+
// FsaAdapter tests
|
|
5
|
+
//
|
|
6
|
+
// The File System Access API is not available in Node.js, so we build a
|
|
7
|
+
// minimal in-memory mock of FileSystemDirectoryHandle / FileSystemFileHandle
|
|
8
|
+
// that mirrors the FSA contract expected by FsaAdapter.
|
|
9
|
+
//////////////////////////////
|
|
10
|
+
|
|
11
|
+
import { FsaAdapter } from '../../../../src/client/js/storage/fsa';
|
|
12
|
+
|
|
13
|
+
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Writable mock that stores text and returns it via text().
|
|
17
|
+
*/
|
|
18
|
+
interface MockFile {
|
|
19
|
+
_content: string;
|
|
20
|
+
text(): Promise<string>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Creates a minimal File mock.
|
|
25
|
+
* @param {string} content - The file content
|
|
26
|
+
* @return {MockFile} A mock File object
|
|
27
|
+
*/
|
|
28
|
+
function makeFile(content: string): MockFile {
|
|
29
|
+
return {
|
|
30
|
+
_content: content,
|
|
31
|
+
text: async () => content,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Creates a mock FileSystemWritableFileStream.
|
|
37
|
+
* @param {MockFileHandle} owner - The handle that owns this stream
|
|
38
|
+
* @return {object} A mock writable stream
|
|
39
|
+
*/
|
|
40
|
+
function makeWritable(owner: MockFileHandle) {
|
|
41
|
+
return {
|
|
42
|
+
write: vi.fn(async (data: string) => {
|
|
43
|
+
owner._content = data;
|
|
44
|
+
}),
|
|
45
|
+
close: vi.fn(async () => undefined),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Mock FileSystemFileHandle backed by an in-memory string.
|
|
51
|
+
*/
|
|
52
|
+
class MockFileHandle {
|
|
53
|
+
kind = 'file' as const;
|
|
54
|
+
_content: string;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* @param {string} content - Initial file content
|
|
58
|
+
*/
|
|
59
|
+
constructor(content = '') {
|
|
60
|
+
this._content = content;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Returns the mock File for this handle.
|
|
65
|
+
* @return {Promise<MockFile>} The file object
|
|
66
|
+
*/
|
|
67
|
+
async getFile(): Promise<MockFile> {
|
|
68
|
+
return makeFile(this._content);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Returns a writable stream for this handle.
|
|
73
|
+
* @return {Promise<ReturnType<typeof makeWritable>>} The writable stream
|
|
74
|
+
*/
|
|
75
|
+
async createWritable() {
|
|
76
|
+
return makeWritable(this);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Builds a mock FileSystemDirectoryHandle that holds a flat map of name → entry.
|
|
82
|
+
* @param {Record<string, MockFileHandle | MockDirHandle>} children - The directory's children
|
|
83
|
+
* @return {MockDirHandle} A mock directory handle
|
|
84
|
+
*/
|
|
85
|
+
class MockDirHandle {
|
|
86
|
+
kind = 'directory' as const;
|
|
87
|
+
private children: Map<string, MockFileHandle | MockDirHandle>;
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* @param {Record<string, MockFileHandle | MockDirHandle>} entries - Initial children map
|
|
91
|
+
*/
|
|
92
|
+
constructor(entries: Record<string, MockFileHandle | MockDirHandle> = {}) {
|
|
93
|
+
this.children = new Map(Object.entries(entries));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Returns a child directory handle by name.
|
|
98
|
+
* @param {string} name - The directory name
|
|
99
|
+
* @return {Promise<MockDirHandle>} The directory handle
|
|
100
|
+
*/
|
|
101
|
+
async getDirectoryHandle(name: string): Promise<MockDirHandle> {
|
|
102
|
+
const entry = this.children.get(name);
|
|
103
|
+
if (!entry || entry.kind !== 'directory') {
|
|
104
|
+
throw new DOMException(`${name} not found`, 'NotFoundError');
|
|
105
|
+
}
|
|
106
|
+
return entry as MockDirHandle;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Returns a child file handle, optionally creating it.
|
|
111
|
+
* @param {string} name - The file name
|
|
112
|
+
* @param {{ create?: boolean }} options - Options object
|
|
113
|
+
* @return {Promise<MockFileHandle>} The file handle
|
|
114
|
+
*/
|
|
115
|
+
async getFileHandle(
|
|
116
|
+
name: string,
|
|
117
|
+
options?: { create?: boolean },
|
|
118
|
+
): Promise<MockFileHandle> {
|
|
119
|
+
if (!this.children.has(name)) {
|
|
120
|
+
if (options?.create) {
|
|
121
|
+
const newHandle = new MockFileHandle('');
|
|
122
|
+
this.children.set(name, newHandle);
|
|
123
|
+
return newHandle;
|
|
124
|
+
}
|
|
125
|
+
throw new DOMException(`${name} not found`, 'NotFoundError');
|
|
126
|
+
}
|
|
127
|
+
return this.children.get(name) as MockFileHandle;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Removes a child entry by name.
|
|
132
|
+
* @param {string} name - The entry name to remove
|
|
133
|
+
* @return {Promise<void>}
|
|
134
|
+
*/
|
|
135
|
+
async removeEntry(name: string): Promise<void> {
|
|
136
|
+
if (!this.children.has(name)) {
|
|
137
|
+
throw new DOMException(`${name} not found`, 'NotFoundError');
|
|
138
|
+
}
|
|
139
|
+
this.children.delete(name);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Async generator that yields [name, entry] pairs for all children.
|
|
144
|
+
* @return {AsyncIterable<[string, MockFileHandle | MockDirHandle]>}
|
|
145
|
+
*/
|
|
146
|
+
async *entries(): AsyncIterable<[string, MockFileHandle | MockDirHandle]> {
|
|
147
|
+
for (const [name, entry] of this.children) {
|
|
148
|
+
yield [name, entry];
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ── Fixtures ─────────────────────────────────────────────────────────────────
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Builds a root handle pre-populated with src/content/posts containing
|
|
157
|
+
* two .md files, one .mdx file, and a directory entry (to be skipped).
|
|
158
|
+
* @return {MockDirHandle} The mock root handle
|
|
159
|
+
*/
|
|
160
|
+
function makeRoot(): MockDirHandle {
|
|
161
|
+
const postsDir = new MockDirHandle({
|
|
162
|
+
'hello.md': new MockFileHandle('---\ntitle: Hello\n---\n'),
|
|
163
|
+
'world.md': new MockFileHandle('---\ntitle: World\n---\n'),
|
|
164
|
+
'page.mdx': new MockFileHandle('---\ntitle: Page\n---\n'),
|
|
165
|
+
subdir: new MockDirHandle(),
|
|
166
|
+
});
|
|
167
|
+
const contentDir = new MockDirHandle({ posts: postsDir });
|
|
168
|
+
const srcDir = new MockDirHandle({ content: contentDir });
|
|
169
|
+
return new MockDirHandle({ src: srcDir });
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ── Tests ─────────────────────────────────────────────────────────────────────
|
|
173
|
+
|
|
174
|
+
describe('FsaAdapter', () => {
|
|
175
|
+
let root: MockDirHandle;
|
|
176
|
+
let adapter: FsaAdapter;
|
|
177
|
+
|
|
178
|
+
beforeEach(() => {
|
|
179
|
+
root = makeRoot();
|
|
180
|
+
adapter = new FsaAdapter(root as unknown as FileSystemDirectoryHandle);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
describe('listFiles', () => {
|
|
184
|
+
it('returns all .md and .mdx files with their content', async () => {
|
|
185
|
+
const files = await adapter.listFiles('posts', ['.md', '.mdx']);
|
|
186
|
+
const names = files.map((f) => f.filename).sort();
|
|
187
|
+
expect(names).toEqual(['hello.md', 'page.mdx', 'world.md']);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('returns the correct content for each file', async () => {
|
|
191
|
+
const files = await adapter.listFiles('posts', ['.md', '.mdx']);
|
|
192
|
+
const hello = files.find((f) => f.filename === 'hello.md');
|
|
193
|
+
expect(hello?.content).toBe('---\ntitle: Hello\n---\n');
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('skips directory entries', async () => {
|
|
197
|
+
const files = await adapter.listFiles('posts', ['.md', '.mdx']);
|
|
198
|
+
const names = files.map((f) => f.filename);
|
|
199
|
+
expect(names).not.toContain('subdir');
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('returns an empty array for an empty collection directory', async () => {
|
|
203
|
+
const emptyDir = new MockDirHandle();
|
|
204
|
+
const contentDir = new MockDirHandle({ empty: emptyDir });
|
|
205
|
+
const srcDir = new MockDirHandle({ content: contentDir });
|
|
206
|
+
const emptyRoot = new MockDirHandle({ src: srcDir });
|
|
207
|
+
const emptyAdapter = new FsaAdapter(
|
|
208
|
+
emptyRoot as unknown as FileSystemDirectoryHandle,
|
|
209
|
+
);
|
|
210
|
+
const files = await emptyAdapter.listFiles('empty', ['.md', '.mdx']);
|
|
211
|
+
expect(files).toEqual([]);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('filters by the given extensions', async () => {
|
|
215
|
+
const mdOnly = await adapter.listFiles('posts', ['.md']);
|
|
216
|
+
const names = mdOnly.map((f) => f.filename).sort();
|
|
217
|
+
expect(names).toEqual(['hello.md', 'world.md']);
|
|
218
|
+
expect(names).not.toContain('page.mdx');
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it('returns nothing when no files match the extensions', async () => {
|
|
222
|
+
const files = await adapter.listFiles('posts', ['.yaml']);
|
|
223
|
+
expect(files).toEqual([]);
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
describe('readFile', () => {
|
|
228
|
+
it('returns the file content for a known file', async () => {
|
|
229
|
+
const content = await adapter.readFile('posts', 'hello.md');
|
|
230
|
+
expect(content).toBe('---\ntitle: Hello\n---\n');
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it('throws when the file does not exist', async () => {
|
|
234
|
+
await expect(adapter.readFile('posts', 'missing.md')).rejects.toThrow();
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
describe('writeFile', () => {
|
|
239
|
+
it('writes content to an existing file', async () => {
|
|
240
|
+
await adapter.writeFile('posts', 'hello.md', 'new content');
|
|
241
|
+
const content = await adapter.readFile('posts', 'hello.md');
|
|
242
|
+
expect(content).toBe('new content');
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it('creates a new file when it does not exist', async () => {
|
|
246
|
+
await adapter.writeFile('posts', 'brand-new.md', 'fresh content');
|
|
247
|
+
const content = await adapter.readFile('posts', 'brand-new.md');
|
|
248
|
+
expect(content).toBe('fresh content');
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
describe('deleteFile', () => {
|
|
253
|
+
it('removes an existing file from the collection', async () => {
|
|
254
|
+
await adapter.deleteFile('posts', 'hello.md');
|
|
255
|
+
const files = await adapter.listFiles('posts', ['.md', '.mdx']);
|
|
256
|
+
const names = files.map((f) => f.filename);
|
|
257
|
+
expect(names).not.toContain('hello.md');
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it('throws when the file does not exist', async () => {
|
|
261
|
+
await expect(
|
|
262
|
+
adapter.deleteFile('posts', 'nonexistent.md'),
|
|
263
|
+
).rejects.toThrow();
|
|
264
|
+
});
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
describe('writeFiles', () => {
|
|
268
|
+
it('writes all files sequentially', async () => {
|
|
269
|
+
await adapter.writeFiles([
|
|
270
|
+
{ collection: 'posts', filename: 'hello.md', content: 'updated hello' },
|
|
271
|
+
{ collection: 'posts', filename: 'world.md', content: 'updated world' },
|
|
272
|
+
]);
|
|
273
|
+
const hello = await adapter.readFile('posts', 'hello.md');
|
|
274
|
+
const world = await adapter.readFile('posts', 'world.md');
|
|
275
|
+
expect(hello).toBe('updated hello');
|
|
276
|
+
expect(world).toBe('updated world');
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it('is a no-op for an empty array', async () => {
|
|
280
|
+
// Should resolve without throwing
|
|
281
|
+
await expect(adapter.writeFiles([])).resolves.toBeUndefined();
|
|
282
|
+
});
|
|
283
|
+
});
|
|
284
|
+
});
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
|
|
3
|
+
//////////////////////////////
|
|
4
|
+
// GitHubAdapter tests
|
|
5
|
+
//
|
|
6
|
+
// All network calls are intercepted by replacing globalThis.fetch with a
|
|
7
|
+
// vi.fn() that returns pre-built Response objects. Each test group
|
|
8
|
+
// configures fetch to respond to the specific API endpoints exercised
|
|
9
|
+
// by the method under test.
|
|
10
|
+
//////////////////////////////
|
|
11
|
+
|
|
12
|
+
import { GitHubAdapter } from '../../../../src/client/js/storage/github';
|
|
13
|
+
|
|
14
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Creates a minimal Response-like object for use in fetch mocks.
|
|
18
|
+
* @param {unknown} body - The response body (will be JSON-serialized if object)
|
|
19
|
+
* @param {number} status - HTTP status code
|
|
20
|
+
* @param {{ text?: boolean }} opts - When text is true, body is treated as a raw string
|
|
21
|
+
* @return {Response} A mock Response instance
|
|
22
|
+
*/
|
|
23
|
+
function mockResponse(
|
|
24
|
+
body: unknown,
|
|
25
|
+
status = 200,
|
|
26
|
+
opts: { text?: boolean } = {},
|
|
27
|
+
): Response {
|
|
28
|
+
const bodyStr = opts.text ? (body as string) : JSON.stringify(body);
|
|
29
|
+
return new Response(bodyStr, {
|
|
30
|
+
status,
|
|
31
|
+
headers: { 'Content-Type': opts.text ? 'text/plain' : 'application/json' },
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ── Setup ─────────────────────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
let fetchMock: ReturnType<typeof vi.fn>;
|
|
38
|
+
|
|
39
|
+
beforeEach(() => {
|
|
40
|
+
fetchMock = vi.fn();
|
|
41
|
+
globalThis.fetch = fetchMock;
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
afterEach(() => {
|
|
45
|
+
vi.restoreAllMocks();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// ── Tests ─────────────────────────────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
describe('GitHubAdapter', () => {
|
|
51
|
+
const TOKEN = 'ghp_test_token';
|
|
52
|
+
const REPO = 'owner/my-repo';
|
|
53
|
+
|
|
54
|
+
describe('validate', () => {
|
|
55
|
+
it('stores the default branch returned by the repo endpoint', async () => {
|
|
56
|
+
fetchMock.mockResolvedValueOnce(
|
|
57
|
+
mockResponse({ default_branch: 'develop' }),
|
|
58
|
+
);
|
|
59
|
+
const adapter = new GitHubAdapter(TOKEN, REPO);
|
|
60
|
+
await adapter.validate();
|
|
61
|
+
// Confirm the branch is used in subsequent calls by inspecting listFiles
|
|
62
|
+
fetchMock.mockResolvedValueOnce(mockResponse([]));
|
|
63
|
+
await adapter.listFiles('posts', ['.md', '.mdx']);
|
|
64
|
+
const listURL = fetchMock.mock.calls[1][0] as string;
|
|
65
|
+
expect(listURL).toContain('ref=develop');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('throws for a 401 response', async () => {
|
|
69
|
+
fetchMock.mockResolvedValueOnce(mockResponse({}, 401));
|
|
70
|
+
const adapter = new GitHubAdapter(TOKEN, REPO);
|
|
71
|
+
await expect(adapter.validate()).rejects.toThrow('Invalid or expired');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('throws for a 403 response', async () => {
|
|
75
|
+
fetchMock.mockResolvedValueOnce(mockResponse({}, 403));
|
|
76
|
+
const adapter = new GitHubAdapter(TOKEN, REPO);
|
|
77
|
+
await expect(adapter.validate()).rejects.toThrow('lacks repository');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('throws for a 404 response', async () => {
|
|
81
|
+
fetchMock.mockResolvedValueOnce(mockResponse({}, 404));
|
|
82
|
+
const adapter = new GitHubAdapter(TOKEN, REPO);
|
|
83
|
+
await expect(adapter.validate()).rejects.toThrow('not found');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('throws a generic error for other non-ok statuses', async () => {
|
|
87
|
+
fetchMock.mockResolvedValueOnce(mockResponse({}, 500));
|
|
88
|
+
const adapter = new GitHubAdapter(TOKEN, REPO);
|
|
89
|
+
await expect(adapter.validate()).rejects.toThrow('GitHub API error: 500');
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe('listFiles', () => {
|
|
94
|
+
it('returns empty array when the collection path returns 404', async () => {
|
|
95
|
+
fetchMock.mockResolvedValueOnce(mockResponse({}, 404));
|
|
96
|
+
const adapter = new GitHubAdapter(TOKEN, REPO);
|
|
97
|
+
const files = await adapter.listFiles('posts', ['.md', '.mdx']);
|
|
98
|
+
expect(files).toEqual([]);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('returns only files matching the given extensions', async () => {
|
|
102
|
+
// Directory listing response
|
|
103
|
+
fetchMock.mockResolvedValueOnce(
|
|
104
|
+
mockResponse([
|
|
105
|
+
{
|
|
106
|
+
name: 'hello.md',
|
|
107
|
+
download_url: 'https://raw.github.com/hello.md',
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
name: 'world.mdx',
|
|
111
|
+
download_url: 'https://raw.github.com/world.mdx',
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
name: 'image.png',
|
|
115
|
+
download_url: 'https://raw.github.com/image.png',
|
|
116
|
+
},
|
|
117
|
+
]),
|
|
118
|
+
);
|
|
119
|
+
// readFile calls for hello.md and world.mdx
|
|
120
|
+
fetchMock.mockResolvedValueOnce(
|
|
121
|
+
mockResponse('# Hello', 200, { text: true }),
|
|
122
|
+
);
|
|
123
|
+
fetchMock.mockResolvedValueOnce(
|
|
124
|
+
mockResponse('# World', 200, { text: true }),
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
const adapter = new GitHubAdapter(TOKEN, REPO);
|
|
128
|
+
const files = await adapter.listFiles('posts', ['.md', '.mdx']);
|
|
129
|
+
const names = files.map((f) => f.filename).sort();
|
|
130
|
+
expect(names).toEqual(['hello.md', 'world.mdx']);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('filters to only the requested extensions', async () => {
|
|
134
|
+
fetchMock.mockResolvedValueOnce(
|
|
135
|
+
mockResponse([
|
|
136
|
+
{
|
|
137
|
+
name: 'hello.md',
|
|
138
|
+
download_url: 'https://raw.github.com/hello.md',
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
name: 'world.mdx',
|
|
142
|
+
download_url: 'https://raw.github.com/world.mdx',
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
name: 'data.yaml',
|
|
146
|
+
download_url: 'https://raw.github.com/data.yaml',
|
|
147
|
+
},
|
|
148
|
+
]),
|
|
149
|
+
);
|
|
150
|
+
// Only .yaml file should be fetched
|
|
151
|
+
fetchMock.mockResolvedValueOnce(
|
|
152
|
+
mockResponse('key: value', 200, { text: true }),
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
const adapter = new GitHubAdapter(TOKEN, REPO);
|
|
156
|
+
const files = await adapter.listFiles('posts', ['.yaml']);
|
|
157
|
+
expect(files).toHaveLength(1);
|
|
158
|
+
expect(files[0].filename).toBe('data.yaml');
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('throws when the listing request fails with a non-404 error', async () => {
|
|
162
|
+
fetchMock.mockResolvedValueOnce(mockResponse({}, 500));
|
|
163
|
+
const adapter = new GitHubAdapter(TOKEN, REPO);
|
|
164
|
+
await expect(adapter.listFiles('posts', ['.md', '.mdx'])).rejects.toThrow(
|
|
165
|
+
'Failed to list files',
|
|
166
|
+
);
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
describe('deleteFile', () => {
|
|
171
|
+
it('sends a DELETE request with the current SHA', async () => {
|
|
172
|
+
// GET to retrieve the current SHA
|
|
173
|
+
fetchMock.mockResolvedValueOnce(
|
|
174
|
+
mockResponse({ sha: 'file-sha-123', name: 'old.md' }),
|
|
175
|
+
);
|
|
176
|
+
// DELETE succeeds
|
|
177
|
+
fetchMock.mockResolvedValueOnce(mockResponse({ commit: {} }));
|
|
178
|
+
|
|
179
|
+
const adapter = new GitHubAdapter(TOKEN, REPO);
|
|
180
|
+
await adapter.deleteFile('posts', 'old.md');
|
|
181
|
+
|
|
182
|
+
// Verify the DELETE call
|
|
183
|
+
const deleteCall = fetchMock.mock.calls[1];
|
|
184
|
+
expect(deleteCall[1].method).toBe('DELETE');
|
|
185
|
+
const deleteBody = JSON.parse(deleteCall[1].body as string);
|
|
186
|
+
expect(deleteBody.sha).toBe('file-sha-123');
|
|
187
|
+
expect(deleteBody.message).toContain('old.md');
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('throws when the file does not exist', async () => {
|
|
191
|
+
fetchMock.mockResolvedValueOnce(mockResponse({}, 404));
|
|
192
|
+
const adapter = new GitHubAdapter(TOKEN, REPO);
|
|
193
|
+
await expect(adapter.deleteFile('posts', 'missing.md')).rejects.toThrow(
|
|
194
|
+
'File not found for deletion',
|
|
195
|
+
);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('throws when the DELETE request fails', async () => {
|
|
199
|
+
fetchMock.mockResolvedValueOnce(
|
|
200
|
+
mockResponse({ sha: 'sha-abc', name: 'target.md' }),
|
|
201
|
+
);
|
|
202
|
+
fetchMock.mockResolvedValueOnce(mockResponse({}, 422));
|
|
203
|
+
const adapter = new GitHubAdapter(TOKEN, REPO);
|
|
204
|
+
await expect(adapter.deleteFile('posts', 'target.md')).rejects.toThrow(
|
|
205
|
+
'Failed to delete',
|
|
206
|
+
);
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
describe('readFile', () => {
|
|
211
|
+
it('returns the raw file content', async () => {
|
|
212
|
+
fetchMock.mockResolvedValueOnce(
|
|
213
|
+
mockResponse('---\ntitle: Test\n---\n', 200, { text: true }),
|
|
214
|
+
);
|
|
215
|
+
const adapter = new GitHubAdapter(TOKEN, REPO);
|
|
216
|
+
const content = await adapter.readFile('posts', 'test.md');
|
|
217
|
+
expect(content).toBe('---\ntitle: Test\n---\n');
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('sends the raw+json Accept header', async () => {
|
|
221
|
+
fetchMock.mockResolvedValueOnce(
|
|
222
|
+
mockResponse('content', 200, { text: true }),
|
|
223
|
+
);
|
|
224
|
+
const adapter = new GitHubAdapter(TOKEN, REPO);
|
|
225
|
+
await adapter.readFile('posts', 'test.md');
|
|
226
|
+
const headers = fetchMock.mock.calls[0][1].headers as Record<
|
|
227
|
+
string,
|
|
228
|
+
string
|
|
229
|
+
>;
|
|
230
|
+
expect(headers['Accept']).toBe('application/vnd.github.raw+json');
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it('throws when the file is not found', async () => {
|
|
234
|
+
fetchMock.mockResolvedValueOnce(mockResponse({}, 404));
|
|
235
|
+
const adapter = new GitHubAdapter(TOKEN, REPO);
|
|
236
|
+
await expect(adapter.readFile('posts', 'missing.md')).rejects.toThrow(
|
|
237
|
+
'Failed to read',
|
|
238
|
+
);
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
describe('writeFile', () => {
|
|
243
|
+
it('sends a PUT request without sha for a new file', async () => {
|
|
244
|
+
// GET returns 404 (file doesn't exist yet)
|
|
245
|
+
fetchMock.mockResolvedValueOnce(mockResponse({}, 404));
|
|
246
|
+
// PUT succeeds
|
|
247
|
+
fetchMock.mockResolvedValueOnce(mockResponse({ content: {} }));
|
|
248
|
+
|
|
249
|
+
const adapter = new GitHubAdapter(TOKEN, REPO);
|
|
250
|
+
await adapter.writeFile('posts', 'new.md', '# New');
|
|
251
|
+
|
|
252
|
+
const putCall = fetchMock.mock.calls[1];
|
|
253
|
+
const putBody = JSON.parse(putCall[1].body as string);
|
|
254
|
+
expect(putBody.sha).toBeUndefined();
|
|
255
|
+
expect(putBody.message).toContain('new.md');
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it('sends a PUT request with sha for an existing file', async () => {
|
|
259
|
+
// GET returns existing file with sha
|
|
260
|
+
fetchMock.mockResolvedValueOnce(
|
|
261
|
+
mockResponse({ sha: 'abc123', name: 'existing.md' }),
|
|
262
|
+
);
|
|
263
|
+
// PUT succeeds
|
|
264
|
+
fetchMock.mockResolvedValueOnce(mockResponse({ content: {} }));
|
|
265
|
+
|
|
266
|
+
const adapter = new GitHubAdapter(TOKEN, REPO);
|
|
267
|
+
await adapter.writeFile('posts', 'existing.md', 'updated');
|
|
268
|
+
|
|
269
|
+
const putBody = JSON.parse(fetchMock.mock.calls[1][1].body as string);
|
|
270
|
+
expect(putBody.sha).toBe('abc123');
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it('throws when the PUT request fails', async () => {
|
|
274
|
+
fetchMock.mockResolvedValueOnce(mockResponse({}, 404));
|
|
275
|
+
fetchMock.mockResolvedValueOnce(
|
|
276
|
+
mockResponse('error text', 422, { text: true }),
|
|
277
|
+
);
|
|
278
|
+
|
|
279
|
+
const adapter = new GitHubAdapter(TOKEN, REPO);
|
|
280
|
+
await expect(adapter.writeFile('posts', 'bad.md', 'x')).rejects.toThrow(
|
|
281
|
+
'Failed to write',
|
|
282
|
+
);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it('base64-encodes the content', async () => {
|
|
286
|
+
fetchMock.mockResolvedValueOnce(mockResponse({}, 404));
|
|
287
|
+
fetchMock.mockResolvedValueOnce(mockResponse({ content: {} }));
|
|
288
|
+
|
|
289
|
+
const adapter = new GitHubAdapter(TOKEN, REPO);
|
|
290
|
+
await adapter.writeFile('posts', 'check.md', 'hello');
|
|
291
|
+
|
|
292
|
+
const putBody = JSON.parse(fetchMock.mock.calls[1][1].body as string);
|
|
293
|
+
// 'hello' base64 encodes to 'aGVsbG8='
|
|
294
|
+
expect(putBody.content).toBe('aGVsbG8=');
|
|
295
|
+
});
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
describe('writeFiles', () => {
|
|
299
|
+
it('is a no-op for an empty array', async () => {
|
|
300
|
+
const adapter = new GitHubAdapter(TOKEN, REPO);
|
|
301
|
+
await expect(adapter.writeFiles([])).resolves.toBeUndefined();
|
|
302
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it('delegates to writeFile for a single-file array', async () => {
|
|
306
|
+
// GET (check sha) + PUT
|
|
307
|
+
fetchMock.mockResolvedValueOnce(mockResponse({}, 404));
|
|
308
|
+
fetchMock.mockResolvedValueOnce(mockResponse({ content: {} }));
|
|
309
|
+
|
|
310
|
+
const adapter = new GitHubAdapter(TOKEN, REPO);
|
|
311
|
+
await adapter.writeFiles([
|
|
312
|
+
{ collection: 'posts', filename: 'one.md', content: 'body' },
|
|
313
|
+
]);
|
|
314
|
+
// Two calls: GET for sha check, PUT for write
|
|
315
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it('uses the Git Trees API for multiple files', async () => {
|
|
319
|
+
// GET /git/ref/heads/main
|
|
320
|
+
fetchMock.mockResolvedValueOnce(
|
|
321
|
+
mockResponse({ object: { sha: 'commit-sha' } }),
|
|
322
|
+
);
|
|
323
|
+
// GET /git/commits/commit-sha
|
|
324
|
+
fetchMock.mockResolvedValueOnce(
|
|
325
|
+
mockResponse({ tree: { sha: 'tree-sha' } }),
|
|
326
|
+
);
|
|
327
|
+
// POST /git/trees
|
|
328
|
+
fetchMock.mockResolvedValueOnce(mockResponse({ sha: 'new-tree-sha' }));
|
|
329
|
+
// POST /git/commits
|
|
330
|
+
fetchMock.mockResolvedValueOnce(mockResponse({ sha: 'new-commit-sha' }));
|
|
331
|
+
// PATCH /git/refs/heads/main
|
|
332
|
+
fetchMock.mockResolvedValueOnce(mockResponse({ ref: 'refs/heads/main' }));
|
|
333
|
+
|
|
334
|
+
const adapter = new GitHubAdapter(TOKEN, REPO);
|
|
335
|
+
await adapter.writeFiles([
|
|
336
|
+
{ collection: 'posts', filename: 'a.md', content: 'A' },
|
|
337
|
+
{ collection: 'posts', filename: 'b.md', content: 'B' },
|
|
338
|
+
]);
|
|
339
|
+
|
|
340
|
+
// Should have made 5 API calls
|
|
341
|
+
expect(fetchMock).toHaveBeenCalledTimes(5);
|
|
342
|
+
// The tree POST should contain both file paths
|
|
343
|
+
const treeBody = JSON.parse(fetchMock.mock.calls[2][1].body as string);
|
|
344
|
+
const paths = treeBody.tree.map((t: { path: string }) => t.path);
|
|
345
|
+
expect(paths).toContain('src/content/posts/a.md');
|
|
346
|
+
expect(paths).toContain('src/content/posts/b.md');
|
|
347
|
+
});
|
|
348
|
+
});
|
|
349
|
+
});
|