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,95 @@
|
|
|
1
|
+
/* Shared mock MessagePort factory for storage layer tests.
|
|
2
|
+
*
|
|
3
|
+
* Both the StorageClient tests (client-side) and the storage worker tests
|
|
4
|
+
* (worker-side) need a fake MessagePort backed by EventTarget. This module
|
|
5
|
+
* provides a base factory and two role-specific wrappers:
|
|
6
|
+
*
|
|
7
|
+
* - makeClientMockPort(): simulates a port from the client's perspective,
|
|
8
|
+
* exposing a `respond` helper that fires incoming worker responses.
|
|
9
|
+
* - makeWorkerMockPort(): simulates a port from the worker's perspective,
|
|
10
|
+
* exposing a `send` helper that fires incoming client requests.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { vi, type MockInstance } from 'vitest';
|
|
14
|
+
import type {
|
|
15
|
+
StorageRequest,
|
|
16
|
+
StorageResponse,
|
|
17
|
+
} from '../../../../src/client/js/storage/adapter';
|
|
18
|
+
|
|
19
|
+
// The base port shape shared by both client and worker mocks.
|
|
20
|
+
interface BaseMockPort {
|
|
21
|
+
port: MessagePort;
|
|
22
|
+
postSpy: ReturnType<typeof vi.fn>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Client-side mock adds a `respond` helper and includes `dispatchEvent`.
|
|
26
|
+
export interface ClientMockPort extends BaseMockPort {
|
|
27
|
+
respond: (data: StorageResponse & { _id?: string }) => void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Worker-side mock adds a `send` helper and omits `dispatchEvent`.
|
|
31
|
+
export interface WorkerMockPort extends BaseMockPort {
|
|
32
|
+
postSpy: MockInstance;
|
|
33
|
+
send: (req: StorageRequest & { _id?: string }) => void;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Creates a mock MessagePort for client-side tests. The port includes
|
|
38
|
+
* dispatchEvent so StorageClient can fire events internally, and exposes
|
|
39
|
+
* a `respond` helper to simulate incoming worker responses.
|
|
40
|
+
* @return {ClientMockPort} The mock port, postMessage spy, and respond helper
|
|
41
|
+
*/
|
|
42
|
+
export function makeClientMockPort(): ClientMockPort {
|
|
43
|
+
const target = new EventTarget();
|
|
44
|
+
const postSpy = vi.fn();
|
|
45
|
+
|
|
46
|
+
const port = {
|
|
47
|
+
addEventListener: target.addEventListener.bind(target),
|
|
48
|
+
removeEventListener: target.removeEventListener.bind(target),
|
|
49
|
+
dispatchEvent: target.dispatchEvent.bind(target),
|
|
50
|
+
postMessage: postSpy,
|
|
51
|
+
start: vi.fn(),
|
|
52
|
+
} as unknown as MessagePort;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Fires a message event on the port, simulating a response from the worker.
|
|
56
|
+
* @param {StorageResponse & { _id?: string }} data - The response payload
|
|
57
|
+
* @return {void}
|
|
58
|
+
*/
|
|
59
|
+
function respond(data: StorageResponse & { _id?: string }): void {
|
|
60
|
+
const event = new MessageEvent('message', { data });
|
|
61
|
+
target.dispatchEvent(event);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return { port, postSpy, respond };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Creates a mock MessagePort for worker-side tests. The port captures
|
|
69
|
+
* outgoing messages via postSpy and exposes a `send` helper to simulate
|
|
70
|
+
* incoming client requests.
|
|
71
|
+
* @return {WorkerMockPort} The mock port, postMessage spy, and send helper
|
|
72
|
+
*/
|
|
73
|
+
export function makeWorkerMockPort(): WorkerMockPort {
|
|
74
|
+
const target = new EventTarget();
|
|
75
|
+
const postSpy = vi.fn();
|
|
76
|
+
|
|
77
|
+
const port = {
|
|
78
|
+
addEventListener: target.addEventListener.bind(target),
|
|
79
|
+
removeEventListener: target.removeEventListener.bind(target),
|
|
80
|
+
postMessage: postSpy,
|
|
81
|
+
start: vi.fn(),
|
|
82
|
+
} as unknown as MessagePort;
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Fires a message event to simulate a client request arriving on the port.
|
|
86
|
+
* @param {StorageRequest & { _id?: string }} req - The request payload
|
|
87
|
+
* @return {void}
|
|
88
|
+
*/
|
|
89
|
+
function send(req: StorageRequest & { _id?: string }): void {
|
|
90
|
+
const event = new MessageEvent('message', { data: req });
|
|
91
|
+
target.dispatchEvent(event);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return { port, postSpy, send };
|
|
95
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
|
|
3
|
+
//////////////////////////////
|
|
4
|
+
// IDB backend persistence tests
|
|
5
|
+
//
|
|
6
|
+
// Uses fake-indexeddb (polyfilled via the setup file) to exercise the
|
|
7
|
+
// actual IDB read/write logic without a browser. The FileSystemDirectoryHandle
|
|
8
|
+
// type is unavailable in Node.js, so FSA tests use a plain object cast —
|
|
9
|
+
// fake-indexeddb stores arbitrary values without type enforcement.
|
|
10
|
+
//////////////////////////////
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
saveBackend,
|
|
14
|
+
loadBackend,
|
|
15
|
+
clearBackend,
|
|
16
|
+
} from '../../../../src/client/js/storage/storage';
|
|
17
|
+
|
|
18
|
+
// ── Tests ─────────────────────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
describe('saveBackend / loadBackend', () => {
|
|
21
|
+
it('persists a github backend config and retrieves it', async () => {
|
|
22
|
+
const config = {
|
|
23
|
+
type: 'github' as const,
|
|
24
|
+
token: 'tok',
|
|
25
|
+
repo: 'owner/repo',
|
|
26
|
+
};
|
|
27
|
+
await saveBackend(config);
|
|
28
|
+
const result = await loadBackend();
|
|
29
|
+
expect(result).toEqual(config);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('persists an fsa backend config and retrieves it', async () => {
|
|
33
|
+
// Use a plain object cast — fake-indexeddb doesn't enforce the actual type
|
|
34
|
+
const fakeHandle = { kind: 'directory', name: 'root' };
|
|
35
|
+
const config = {
|
|
36
|
+
type: 'fsa' as const,
|
|
37
|
+
handle: fakeHandle as unknown as FileSystemDirectoryHandle,
|
|
38
|
+
};
|
|
39
|
+
await saveBackend(config);
|
|
40
|
+
const result = await loadBackend();
|
|
41
|
+
expect(result?.type).toBe('fsa');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('overwrites a previous config when called again', async () => {
|
|
45
|
+
const first = { type: 'github' as const, token: 'first', repo: 'o/r' };
|
|
46
|
+
const second = { type: 'github' as const, token: 'second', repo: 'o/r' };
|
|
47
|
+
await saveBackend(first);
|
|
48
|
+
await saveBackend(second);
|
|
49
|
+
const result = await loadBackend();
|
|
50
|
+
expect((result as typeof second).token).toBe('second');
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe('loadBackend', () => {
|
|
55
|
+
it('returns null when no backend has been saved', async () => {
|
|
56
|
+
// Clear any state from previous tests
|
|
57
|
+
await clearBackend();
|
|
58
|
+
const result = await loadBackend();
|
|
59
|
+
expect(result).toBeNull();
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe('clearBackend', () => {
|
|
64
|
+
it('removes the stored config so loadBackend returns null', async () => {
|
|
65
|
+
const config = { type: 'github' as const, token: 'tok', repo: 'o/r' };
|
|
66
|
+
await saveBackend(config);
|
|
67
|
+
await clearBackend();
|
|
68
|
+
const result = await loadBackend();
|
|
69
|
+
expect(result).toBeNull();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('is safe to call when no config is stored', async () => {
|
|
73
|
+
await clearBackend();
|
|
74
|
+
// Calling again should not throw
|
|
75
|
+
await expect(clearBackend()).resolves.toBeUndefined();
|
|
76
|
+
});
|
|
77
|
+
});
|
|
@@ -0,0 +1,479 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
|
|
3
|
+
//////////////////////////////
|
|
4
|
+
// Orchestrator worker tests
|
|
5
|
+
//
|
|
6
|
+
// The orchestrator worker registers a self.addEventListener('message', ...)
|
|
7
|
+
// listener that handles 'port' and 'parse' message types. It categorises files
|
|
8
|
+
// by extension — frontmatter files have their YAML block extracted and sent to
|
|
9
|
+
// the YAML parser worker; JSON files are parsed inline; YAML/TOML data files
|
|
10
|
+
// are routed to their respective parser workers.
|
|
11
|
+
//
|
|
12
|
+
// We mock Worker globally to intercept parser worker instantiation and
|
|
13
|
+
// simulate parse-batch-result responses. StorageClient is mocked via vi.mock.
|
|
14
|
+
//
|
|
15
|
+
// IMPORTANT: The orchestrator lazily spawns parser workers and caches them at
|
|
16
|
+
// module level. This means once a YAML worker is spawned in one test, all
|
|
17
|
+
// subsequent tests reuse it. The spawnedWorkers array is therefore cumulative
|
|
18
|
+
// across the entire suite — we do NOT clear it between tests.
|
|
19
|
+
//////////////////////////////
|
|
20
|
+
|
|
21
|
+
// ── Mock StorageClient ──────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
const mockListFiles = vi.fn();
|
|
24
|
+
|
|
25
|
+
vi.mock('../../../../../src/client/js/storage/client', () => ({
|
|
26
|
+
// Must use function keyword — arrow functions cannot be used with `new`
|
|
27
|
+
StorageClient: function () {
|
|
28
|
+
return { listFiles: mockListFiles };
|
|
29
|
+
},
|
|
30
|
+
}));
|
|
31
|
+
|
|
32
|
+
// ── Mock Worker class ────────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Tracks all spawned mock workers by URL for assertions.
|
|
36
|
+
* NOT cleared between tests — the orchestrator caches workers at module level.
|
|
37
|
+
*/
|
|
38
|
+
const spawnedWorkers: MockWorkerInstance[] = [];
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* A minimal mock of Worker that captures postMessage calls and allows
|
|
42
|
+
* simulating onmessage responses from parser workers.
|
|
43
|
+
*/
|
|
44
|
+
class MockWorkerInstance {
|
|
45
|
+
url: string;
|
|
46
|
+
onmessage: ((event: MessageEvent) => void) | null = null;
|
|
47
|
+
messages: Array<Record<string, unknown>> = [];
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* @param {string | URL} url - The URL passed to the Worker constructor
|
|
51
|
+
*/
|
|
52
|
+
constructor(url: string | URL) {
|
|
53
|
+
this.url = String(url);
|
|
54
|
+
spawnedWorkers.push(this);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Captures posted messages and auto-responds with parse-batch-result
|
|
59
|
+
* by extracting a title from YAML/TOML-like "title: <value>" lines.
|
|
60
|
+
* @param {Record<string, unknown>} msg - The message sent to the worker
|
|
61
|
+
* @return {void}
|
|
62
|
+
*/
|
|
63
|
+
postMessage(msg: Record<string, unknown>): void {
|
|
64
|
+
this.messages.push(msg);
|
|
65
|
+
|
|
66
|
+
// Auto-respond to parse-batch with mock results
|
|
67
|
+
if (msg.type === 'parse-batch' && this.onmessage) {
|
|
68
|
+
const items = msg.items as Array<{ key: string; content: string }>;
|
|
69
|
+
const results: Record<string, Record<string, unknown>> = {};
|
|
70
|
+
for (const item of items) {
|
|
71
|
+
// Simulate parsing by creating a data object with the title extracted
|
|
72
|
+
// from a "title: <value>" line in the YAML/TOML content
|
|
73
|
+
const titleMatch = item.content.match(/title:\s*(.+)/);
|
|
74
|
+
results[item.key] = titleMatch ? { title: titleMatch[1].trim() } : {};
|
|
75
|
+
}
|
|
76
|
+
// Respond asynchronously to match real worker behavior
|
|
77
|
+
const id = msg.id as string;
|
|
78
|
+
const handler = this.onmessage;
|
|
79
|
+
queueMicrotask(() => {
|
|
80
|
+
handler({
|
|
81
|
+
data: { type: 'parse-batch-result', id, ok: true, results },
|
|
82
|
+
} as MessageEvent);
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
vi.stubGlobal('Worker', MockWorkerInstance);
|
|
89
|
+
|
|
90
|
+
// ── Self mock ──────────────────────────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
// The handler type uses a loose event shape: real MessageEvent validates
|
|
93
|
+
// that ports[] entries are actual MessagePort instances, which we cannot
|
|
94
|
+
// construct in Node.js. We type the captured handler loosely and call it
|
|
95
|
+
// with plain objects that mirror the fields the worker actually reads.
|
|
96
|
+
let messageHandler:
|
|
97
|
+
| ((event: {
|
|
98
|
+
data: Record<string, unknown>;
|
|
99
|
+
ports: unknown[];
|
|
100
|
+
}) => Promise<void>)
|
|
101
|
+
| null = null;
|
|
102
|
+
const selfPostMessage = vi.fn();
|
|
103
|
+
|
|
104
|
+
vi.stubGlobal('self', {
|
|
105
|
+
addEventListener: vi.fn((type: string, handler: unknown) => {
|
|
106
|
+
if (type === 'message') {
|
|
107
|
+
messageHandler = handler as (event: {
|
|
108
|
+
data: Record<string, unknown>;
|
|
109
|
+
ports: unknown[];
|
|
110
|
+
}) => Promise<void>;
|
|
111
|
+
}
|
|
112
|
+
}),
|
|
113
|
+
postMessage: selfPostMessage,
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// ── Import module (after stubs) ────────────────────────────────────────────
|
|
117
|
+
|
|
118
|
+
await import('../../../../../src/client/js/storage/workers/frontmatter');
|
|
119
|
+
|
|
120
|
+
// ── Helpers ────────────────────────────────────────────────────────────────
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Dispatches a fake event to the captured handler by calling it directly
|
|
124
|
+
* with a plain object. We bypass MessageEvent construction because the
|
|
125
|
+
* constructor validates that ports[] entries are real MessagePort instances.
|
|
126
|
+
* @param {Record<string, unknown>} data - The message data payload
|
|
127
|
+
* @param {unknown[]} ports - Optional port-like objects to include
|
|
128
|
+
* @return {Promise<void>}
|
|
129
|
+
*/
|
|
130
|
+
async function dispatch(
|
|
131
|
+
data: Record<string, unknown>,
|
|
132
|
+
ports: unknown[] = [],
|
|
133
|
+
): Promise<void> {
|
|
134
|
+
await messageHandler!({ data, ports });
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Creates a minimal port-like object that satisfies the worker's StorageClient constructor.
|
|
139
|
+
* @return {unknown} A fake port object
|
|
140
|
+
*/
|
|
141
|
+
function makeFakePort(): unknown {
|
|
142
|
+
return {
|
|
143
|
+
addEventListener: vi.fn(),
|
|
144
|
+
start: vi.fn(),
|
|
145
|
+
postMessage: vi.fn(),
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Waits for microtask queue to flush so async parser worker responses resolve.
|
|
151
|
+
* @return {Promise<void>}
|
|
152
|
+
*/
|
|
153
|
+
async function flushMicrotasks(): Promise<void> {
|
|
154
|
+
await new Promise<void>((resolve) => queueMicrotask(resolve));
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Finds the latest result message from selfPostMessage calls.
|
|
159
|
+
* @return {Record<string, unknown> | undefined} The result payload, or undefined
|
|
160
|
+
*/
|
|
161
|
+
function findResult(): Record<string, unknown> | undefined {
|
|
162
|
+
const call = selfPostMessage.mock.calls.find((c) => c[0].type === 'result');
|
|
163
|
+
return call?.[0];
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Finds the latest error message from selfPostMessage calls.
|
|
168
|
+
* @return {Record<string, unknown> | undefined} The error payload, or undefined
|
|
169
|
+
*/
|
|
170
|
+
function findError(): Record<string, unknown> | undefined {
|
|
171
|
+
const call = selfPostMessage.mock.calls.find((c) => c[0].type === 'error');
|
|
172
|
+
return call?.[0];
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ── Tests ─────────────────────────────────────────────────────────────────────
|
|
176
|
+
|
|
177
|
+
describe('orchestrator worker', () => {
|
|
178
|
+
beforeEach(() => {
|
|
179
|
+
vi.clearAllMocks();
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
afterEach(() => {
|
|
183
|
+
vi.clearAllMocks();
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('registers a message listener on self', () => {
|
|
187
|
+
expect(messageHandler).toBeTypeOf('function');
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
describe('port message', () => {
|
|
191
|
+
it('does not post a response for a port initialization message', async () => {
|
|
192
|
+
await dispatch({ type: 'port' }, [makeFakePort()]);
|
|
193
|
+
expect(selfPostMessage).not.toHaveBeenCalled();
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
describe('parse message', () => {
|
|
198
|
+
it('posts an error when storageClient is not initialized', async () => {
|
|
199
|
+
// The storageClient is module-level state — it may or may not be set
|
|
200
|
+
// depending on test execution order. We trigger a guaranteed error by
|
|
201
|
+
// leaving mockListFiles returning undefined, which causes a runtime
|
|
202
|
+
// error inside the worker's parse handler.
|
|
203
|
+
await dispatch({ type: 'parse', collection: 'posts' });
|
|
204
|
+
const err = findError();
|
|
205
|
+
expect(err).toBeDefined();
|
|
206
|
+
expect(typeof err!.message).toBe('string');
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it('spawns a YAML worker and extracts YAML block for frontmatter files', async () => {
|
|
210
|
+
await dispatch({ type: 'port' }, [makeFakePort()]);
|
|
211
|
+
|
|
212
|
+
mockListFiles.mockResolvedValueOnce([
|
|
213
|
+
{
|
|
214
|
+
filename: 'post.md',
|
|
215
|
+
content: '---\ntitle: Hello World\n---\nBody content here',
|
|
216
|
+
},
|
|
217
|
+
]);
|
|
218
|
+
|
|
219
|
+
await dispatch({ type: 'parse', collection: 'posts' });
|
|
220
|
+
await flushMicrotasks();
|
|
221
|
+
|
|
222
|
+
// A YAML worker should have been spawned
|
|
223
|
+
const yamlWorker = spawnedWorkers.find((w) =>
|
|
224
|
+
w.url.includes('yaml-parser'),
|
|
225
|
+
);
|
|
226
|
+
expect(yamlWorker).toBeDefined();
|
|
227
|
+
|
|
228
|
+
// It should have received a parse-batch with the extracted YAML block
|
|
229
|
+
const batchMsg = yamlWorker!.messages.find(
|
|
230
|
+
(m) => m.type === 'parse-batch',
|
|
231
|
+
);
|
|
232
|
+
expect(batchMsg).toBeDefined();
|
|
233
|
+
|
|
234
|
+
const items = batchMsg!.items as Array<{ key: string; content: string }>;
|
|
235
|
+
// The YAML block should NOT include the --- delimiters or body
|
|
236
|
+
expect(items[0].content).toBe('title: Hello World');
|
|
237
|
+
expect(items[0].key).toBe('post.md');
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it('sorts frontmatter files alphabetically by title', async () => {
|
|
241
|
+
await dispatch({ type: 'port' }, [makeFakePort()]);
|
|
242
|
+
|
|
243
|
+
mockListFiles.mockResolvedValueOnce([
|
|
244
|
+
{ filename: 'b-post.md', content: '---\ntitle: B Post\n---\nBody B' },
|
|
245
|
+
{ filename: 'a-post.md', content: '---\ntitle: A Post\n---\nBody A' },
|
|
246
|
+
]);
|
|
247
|
+
|
|
248
|
+
await dispatch({ type: 'parse', collection: 'posts' });
|
|
249
|
+
await flushMicrotasks();
|
|
250
|
+
|
|
251
|
+
const result = findResult();
|
|
252
|
+
expect(result).toBeDefined();
|
|
253
|
+
expect(result!.collection).toBe('posts');
|
|
254
|
+
|
|
255
|
+
const items = result!.items as Array<{
|
|
256
|
+
filename: string;
|
|
257
|
+
data: Record<string, unknown>;
|
|
258
|
+
}>;
|
|
259
|
+
expect(items[0].filename).toBe('a-post.md');
|
|
260
|
+
expect(items[1].filename).toBe('b-post.md');
|
|
261
|
+
expect(items[0].data.title).toBe('A Post');
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it('parses JSON data files inline without spawning a worker', async () => {
|
|
265
|
+
await dispatch({ type: 'port' }, [makeFakePort()]);
|
|
266
|
+
|
|
267
|
+
const workerCountBefore = spawnedWorkers.length;
|
|
268
|
+
|
|
269
|
+
mockListFiles.mockResolvedValueOnce([
|
|
270
|
+
{
|
|
271
|
+
filename: 'config.json',
|
|
272
|
+
content: '{"title": "JSON Config", "count": 42}',
|
|
273
|
+
},
|
|
274
|
+
]);
|
|
275
|
+
|
|
276
|
+
await dispatch({
|
|
277
|
+
type: 'parse',
|
|
278
|
+
collection: 'data',
|
|
279
|
+
extensions: ['.json'],
|
|
280
|
+
});
|
|
281
|
+
await flushMicrotasks();
|
|
282
|
+
|
|
283
|
+
const result = findResult();
|
|
284
|
+
expect(result).toBeDefined();
|
|
285
|
+
|
|
286
|
+
const items = result!.items as Array<{
|
|
287
|
+
filename: string;
|
|
288
|
+
data: Record<string, unknown>;
|
|
289
|
+
}>;
|
|
290
|
+
expect(items[0].filename).toBe('config.json');
|
|
291
|
+
expect(items[0].data.title).toBe('JSON Config');
|
|
292
|
+
expect(items[0].data.count).toBe(42);
|
|
293
|
+
|
|
294
|
+
// No NEW parser workers should have been spawned for JSON-only parsing
|
|
295
|
+
expect(spawnedWorkers.length).toBe(workerCountBefore);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it('routes YAML data files to the YAML worker', async () => {
|
|
299
|
+
await dispatch({ type: 'port' }, [makeFakePort()]);
|
|
300
|
+
|
|
301
|
+
mockListFiles.mockResolvedValueOnce([
|
|
302
|
+
{ filename: 'settings.yaml', content: 'title: YAML Settings' },
|
|
303
|
+
]);
|
|
304
|
+
|
|
305
|
+
await dispatch({
|
|
306
|
+
type: 'parse',
|
|
307
|
+
collection: 'data',
|
|
308
|
+
extensions: ['.yaml'],
|
|
309
|
+
});
|
|
310
|
+
await flushMicrotasks();
|
|
311
|
+
|
|
312
|
+
// The YAML worker should exist (spawned in earlier test or this one)
|
|
313
|
+
const yamlWorker = spawnedWorkers.find((w) =>
|
|
314
|
+
w.url.includes('yaml-parser'),
|
|
315
|
+
);
|
|
316
|
+
expect(yamlWorker).toBeDefined();
|
|
317
|
+
|
|
318
|
+
// Find the batch message containing YAML data file content
|
|
319
|
+
const batchMsg = yamlWorker!.messages.find((m) => {
|
|
320
|
+
if (m.type !== 'parse-batch') return false;
|
|
321
|
+
const batchItems = m.items as Array<{
|
|
322
|
+
key: string;
|
|
323
|
+
content: string;
|
|
324
|
+
}>;
|
|
325
|
+
return batchItems.some((i) => i.key === 'settings.yaml');
|
|
326
|
+
});
|
|
327
|
+
expect(batchMsg).toBeDefined();
|
|
328
|
+
|
|
329
|
+
const batchItems = batchMsg!.items as Array<{
|
|
330
|
+
key: string;
|
|
331
|
+
content: string;
|
|
332
|
+
}>;
|
|
333
|
+
const settingsItem = batchItems.find((i) => i.key === 'settings.yaml');
|
|
334
|
+
// YAML data files send full content, not extracted block
|
|
335
|
+
expect(settingsItem!.content).toBe('title: YAML Settings');
|
|
336
|
+
|
|
337
|
+
const result = findResult();
|
|
338
|
+
expect(result).toBeDefined();
|
|
339
|
+
const items = result!.items as Array<{
|
|
340
|
+
filename: string;
|
|
341
|
+
data: Record<string, unknown>;
|
|
342
|
+
}>;
|
|
343
|
+
expect(items[0].data.title).toBe('YAML Settings');
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it('routes TOML data files to the TOML worker', async () => {
|
|
347
|
+
await dispatch({ type: 'port' }, [makeFakePort()]);
|
|
348
|
+
|
|
349
|
+
mockListFiles.mockResolvedValueOnce([
|
|
350
|
+
{ filename: 'config.toml', content: 'title = "TOML Config"' },
|
|
351
|
+
]);
|
|
352
|
+
|
|
353
|
+
await dispatch({
|
|
354
|
+
type: 'parse',
|
|
355
|
+
collection: 'data',
|
|
356
|
+
extensions: ['.toml'],
|
|
357
|
+
});
|
|
358
|
+
await flushMicrotasks();
|
|
359
|
+
|
|
360
|
+
// A TOML worker should have been spawned
|
|
361
|
+
const tomlWorker = spawnedWorkers.find((w) =>
|
|
362
|
+
w.url.includes('toml-parser'),
|
|
363
|
+
);
|
|
364
|
+
expect(tomlWorker).toBeDefined();
|
|
365
|
+
|
|
366
|
+
const batchMsg = tomlWorker!.messages.find(
|
|
367
|
+
(m) => m.type === 'parse-batch',
|
|
368
|
+
);
|
|
369
|
+
expect(batchMsg).toBeDefined();
|
|
370
|
+
|
|
371
|
+
const result = findResult();
|
|
372
|
+
expect(result).toBeDefined();
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
it('falls back to filename for sorting when title is absent', async () => {
|
|
376
|
+
await dispatch({ type: 'port' }, [makeFakePort()]);
|
|
377
|
+
|
|
378
|
+
mockListFiles.mockResolvedValueOnce([
|
|
379
|
+
{ filename: 'z-file.md', content: 'No frontmatter' },
|
|
380
|
+
{ filename: 'a-file.md', content: 'No frontmatter' },
|
|
381
|
+
]);
|
|
382
|
+
|
|
383
|
+
await dispatch({ type: 'parse', collection: 'posts' });
|
|
384
|
+
await flushMicrotasks();
|
|
385
|
+
|
|
386
|
+
const result = findResult();
|
|
387
|
+
const items = result!.items as Array<{
|
|
388
|
+
filename: string;
|
|
389
|
+
data: Record<string, unknown>;
|
|
390
|
+
}>;
|
|
391
|
+
expect(items[0].filename).toBe('a-file.md');
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
it('posts an error when listFiles throws', async () => {
|
|
395
|
+
await dispatch({ type: 'port' }, [makeFakePort()]);
|
|
396
|
+
|
|
397
|
+
mockListFiles.mockRejectedValueOnce(new Error('storage failure'));
|
|
398
|
+
|
|
399
|
+
await dispatch({ type: 'parse', collection: 'posts' });
|
|
400
|
+
await flushMicrotasks();
|
|
401
|
+
|
|
402
|
+
const err = findError();
|
|
403
|
+
expect(err).toBeDefined();
|
|
404
|
+
expect(err!.message).toBe('storage failure');
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
it('includes files with empty frontmatter (data defaults to {})', async () => {
|
|
408
|
+
await dispatch({ type: 'port' }, [makeFakePort()]);
|
|
409
|
+
|
|
410
|
+
mockListFiles.mockResolvedValueOnce([
|
|
411
|
+
{ filename: 'no-fm.md', content: 'No frontmatter here' },
|
|
412
|
+
]);
|
|
413
|
+
|
|
414
|
+
await dispatch({ type: 'parse', collection: 'posts' });
|
|
415
|
+
await flushMicrotasks();
|
|
416
|
+
|
|
417
|
+
const result = findResult();
|
|
418
|
+
const items = result!.items as Array<{
|
|
419
|
+
filename: string;
|
|
420
|
+
data: Record<string, unknown>;
|
|
421
|
+
}>;
|
|
422
|
+
expect(items[0].data).toEqual({});
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
it('sorts correctly across mixed file types', async () => {
|
|
426
|
+
await dispatch({ type: 'port' }, [makeFakePort()]);
|
|
427
|
+
|
|
428
|
+
mockListFiles.mockResolvedValueOnce([
|
|
429
|
+
{ filename: 'z-post.md', content: '---\ntitle: Zebra\n---\nbody' },
|
|
430
|
+
{
|
|
431
|
+
filename: 'config.json',
|
|
432
|
+
content: '{"title": "Alpha Config"}',
|
|
433
|
+
},
|
|
434
|
+
{ filename: 'middle.yaml', content: 'title: Middle Entry' },
|
|
435
|
+
]);
|
|
436
|
+
|
|
437
|
+
await dispatch({
|
|
438
|
+
type: 'parse',
|
|
439
|
+
collection: 'mixed',
|
|
440
|
+
extensions: ['.md', '.json', '.yaml'],
|
|
441
|
+
});
|
|
442
|
+
await flushMicrotasks();
|
|
443
|
+
|
|
444
|
+
const result = findResult();
|
|
445
|
+
expect(result).toBeDefined();
|
|
446
|
+
const items = result!.items as Array<{
|
|
447
|
+
filename: string;
|
|
448
|
+
data: Record<string, unknown>;
|
|
449
|
+
}>;
|
|
450
|
+
// Alpha Config < Middle Entry < Zebra
|
|
451
|
+
expect(items[0].data.title).toBe('Alpha Config');
|
|
452
|
+
expect(items[1].data.title).toBe('Middle Entry');
|
|
453
|
+
expect(items[2].data.title).toBe('Zebra');
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
it('handles invalid JSON gracefully with empty data', async () => {
|
|
457
|
+
await dispatch({ type: 'port' }, [makeFakePort()]);
|
|
458
|
+
|
|
459
|
+
mockListFiles.mockResolvedValueOnce([
|
|
460
|
+
{ filename: 'bad.json', content: '{ invalid json' },
|
|
461
|
+
]);
|
|
462
|
+
|
|
463
|
+
await dispatch({
|
|
464
|
+
type: 'parse',
|
|
465
|
+
collection: 'data',
|
|
466
|
+
extensions: ['.json'],
|
|
467
|
+
});
|
|
468
|
+
await flushMicrotasks();
|
|
469
|
+
|
|
470
|
+
const result = findResult();
|
|
471
|
+
expect(result).toBeDefined();
|
|
472
|
+
const items = result!.items as Array<{
|
|
473
|
+
filename: string;
|
|
474
|
+
data: Record<string, unknown>;
|
|
475
|
+
}>;
|
|
476
|
+
expect(items[0].data).toEqual({});
|
|
477
|
+
});
|
|
478
|
+
});
|
|
479
|
+
});
|