meno-core 1.0.21 → 1.0.23
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/build-static.test.ts +424 -0
- package/build-static.ts +100 -13
- package/lib/client/ClientInitializer.ts +4 -0
- package/lib/client/core/ComponentBuilder.ts +155 -16
- package/lib/client/core/builders/embedBuilder.ts +48 -6
- package/lib/client/core/builders/linkBuilder.ts +2 -2
- package/lib/client/core/builders/linkNodeBuilder.ts +45 -5
- package/lib/client/core/builders/listBuilder.ts +12 -3
- package/lib/client/routing/Router.tsx +8 -1
- package/lib/client/templateEngine.ts +89 -98
- package/lib/server/__integration__/api-routes.test.ts +148 -0
- package/lib/server/__integration__/cms-integration.test.ts +161 -0
- package/lib/server/__integration__/server-lifecycle.test.ts +101 -0
- package/lib/server/__integration__/ssr-rendering.test.ts +131 -0
- package/lib/server/__integration__/static-assets.test.ts +80 -0
- package/lib/server/__integration__/test-helpers.ts +205 -0
- package/lib/server/ab/generateFunctions.ts +346 -0
- package/lib/server/ab/trackingScript.ts +45 -0
- package/lib/server/index.ts +2 -2
- package/lib/server/jsonLoader.ts +124 -46
- package/lib/server/routes/api/cms.ts +3 -2
- package/lib/server/routes/api/components.ts +13 -2
- package/lib/server/services/cmsService.ts +0 -5
- package/lib/server/services/componentService.ts +255 -29
- package/lib/server/services/configService.test.ts +950 -0
- package/lib/server/services/configService.ts +39 -0
- package/lib/server/services/index.ts +1 -1
- package/lib/server/ssr/htmlGenerator.test.ts +992 -0
- package/lib/server/ssr/htmlGenerator.ts +3 -3
- package/lib/server/ssr/imageMetadata.test.ts +168 -0
- package/lib/server/ssr/imageMetadata.ts +58 -0
- package/lib/server/ssr/jsCollector.test.ts +287 -0
- package/lib/server/ssr/ssrRenderer.test.ts +3702 -0
- package/lib/server/ssr/ssrRenderer.ts +131 -15
- package/lib/shared/constants.ts +3 -0
- package/lib/shared/fontLoader.test.ts +335 -0
- package/lib/shared/i18n.test.ts +106 -0
- package/lib/shared/i18n.ts +17 -11
- package/lib/shared/index.ts +3 -0
- package/lib/shared/itemTemplateUtils.ts +43 -1
- package/lib/shared/libraryLoader.test.ts +392 -0
- package/lib/shared/linkUtils.ts +24 -0
- package/lib/shared/nodeUtils.test.ts +100 -0
- package/lib/shared/nodeUtils.ts +43 -0
- package/lib/shared/registry/NodeTypeDefinition.ts +2 -2
- package/lib/shared/registry/nodeTypes/ListNodeType.ts +20 -2
- package/lib/shared/richtext/htmlToTiptap.test.ts +948 -0
- package/lib/shared/richtext/htmlToTiptap.ts +46 -2
- package/lib/shared/richtext/tiptapToHtml.ts +65 -0
- package/lib/shared/richtext/types.ts +4 -1
- package/lib/shared/types/cms.ts +2 -0
- package/lib/shared/types/components.ts +12 -3
- package/lib/shared/types/experiments.ts +55 -0
- package/lib/shared/types/index.ts +10 -0
- package/lib/shared/utils.ts +2 -6
- package/lib/shared/validation/propValidator.test.ts +50 -0
- package/lib/shared/validation/propValidator.ts +2 -2
- package/lib/shared/validation/schemas.ts +10 -2
- package/package.json +1 -1
|
@@ -167,6 +167,71 @@ export function resolveStyleMapping(
|
|
|
167
167
|
return mappedValue !== undefined ? mappedValue : undefined;
|
|
168
168
|
}
|
|
169
169
|
|
|
170
|
+
/**
|
|
171
|
+
* Process a single style value - resolves mappings and evaluates templates.
|
|
172
|
+
* Skips item templates ({{item.field}}, {{varName.field}}) which are resolved later during rendering.
|
|
173
|
+
*
|
|
174
|
+
* @param styleValue - The style value to process (may be string, number, or mapping object)
|
|
175
|
+
* @param props - Component props for resolving mappings and templates
|
|
176
|
+
* @returns The processed style value, or undefined if the value should be skipped
|
|
177
|
+
*/
|
|
178
|
+
function processStyleValue(
|
|
179
|
+
styleValue: unknown,
|
|
180
|
+
props: Record<string, unknown> | undefined
|
|
181
|
+
): string | number | undefined {
|
|
182
|
+
// First try style mapping resolution
|
|
183
|
+
const resolved = resolveStyleMapping(styleValue, props);
|
|
184
|
+
if (resolved !== undefined) {
|
|
185
|
+
return resolved;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// String values - evaluate templates (but skip item templates)
|
|
189
|
+
if (typeof styleValue === 'string') {
|
|
190
|
+
if (hasTemplates(styleValue) && !hasItemTemplates(styleValue)) {
|
|
191
|
+
return processCodeTemplates(styleValue, props);
|
|
192
|
+
}
|
|
193
|
+
return styleValue;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Number values - pass through
|
|
197
|
+
if (typeof styleValue === 'number') {
|
|
198
|
+
return styleValue;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return undefined;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Build evaluation context for template processing.
|
|
206
|
+
* Merges global context, props, componentDef, and optionally itemContext.
|
|
207
|
+
*
|
|
208
|
+
* @param context - The TemplateContext containing props and componentDef
|
|
209
|
+
* @param includeItemContext - Whether to include itemContext from the context object
|
|
210
|
+
* @returns A flat object suitable for template evaluation
|
|
211
|
+
*/
|
|
212
|
+
function buildEvalContext(
|
|
213
|
+
context: TemplateContext,
|
|
214
|
+
includeItemContext: boolean = false
|
|
215
|
+
): Record<string, unknown> {
|
|
216
|
+
const evalContext: Record<string, unknown> = {
|
|
217
|
+
...getGlobalTemplateContext(),
|
|
218
|
+
...context.props
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
if (context.componentDef && typeof context.componentDef === 'object') {
|
|
222
|
+
Object.assign(evalContext, context.componentDef as Record<string, unknown>);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (includeItemContext) {
|
|
226
|
+
const itemContext = (context as Record<string, unknown>).itemContext as Record<string, unknown> | undefined;
|
|
227
|
+
if (itemContext) {
|
|
228
|
+
Object.assign(evalContext, itemContext);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return evalContext;
|
|
233
|
+
}
|
|
234
|
+
|
|
170
235
|
/** Maximum allowed expression length to prevent DoS via complex expressions */
|
|
171
236
|
const MAX_EXPRESSION_LENGTH = 500;
|
|
172
237
|
|
|
@@ -315,22 +380,8 @@ export function processStructure(
|
|
|
315
380
|
}
|
|
316
381
|
|
|
317
382
|
if (typeof structure === 'string') {
|
|
318
|
-
// Build evaluation context
|
|
319
|
-
|
|
320
|
-
const evalContext: Record<string, unknown> = { ...getGlobalTemplateContext(), ...context.props };
|
|
321
|
-
|
|
322
|
-
// Add componentDef properties to context (for backward compatibility)
|
|
323
|
-
if (context.componentDef && typeof context.componentDef === 'object') {
|
|
324
|
-
Object.assign(evalContext, context.componentDef as Record<string, unknown>);
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
// Add parent cms-list item context for nested template resolution
|
|
328
|
-
// This enables {{item.field}} in components rendered inside cms-list
|
|
329
|
-
const itemContext = (context as Record<string, unknown>).itemContext as Record<string, unknown> | undefined;
|
|
330
|
-
if (itemContext) {
|
|
331
|
-
Object.assign(evalContext, itemContext);
|
|
332
|
-
}
|
|
333
|
-
|
|
383
|
+
// Build evaluation context with item context for nested template resolution
|
|
384
|
+
const evalContext = buildEvalContext(context, true);
|
|
334
385
|
|
|
335
386
|
// Check if entire string is a complete template {{expr}}
|
|
336
387
|
// Use evaluateTemplate to preserve type (objects, arrays, numbers)
|
|
@@ -504,12 +555,12 @@ export function processStructure(
|
|
|
504
555
|
if (isHtmlNode(processed) || isListNode(processed)) {
|
|
505
556
|
if (typeof value === 'string') {
|
|
506
557
|
// Process template in tag (e.g., "h{{size}}" -> "h1")
|
|
507
|
-
const evalContext
|
|
508
|
-
if (context.componentDef && typeof context.componentDef === 'object') {
|
|
509
|
-
Object.assign(evalContext, context.componentDef as Record<string, unknown>);
|
|
510
|
-
}
|
|
558
|
+
const evalContext = buildEvalContext(context, false);
|
|
511
559
|
// Use processCodeTemplates to handle partial templates like "h{{size}}"
|
|
512
560
|
(processed as any).tag = processCodeTemplates(value, evalContext);
|
|
561
|
+
} else if (value === false) {
|
|
562
|
+
// Preserve boolean false for fragment mode (no container)
|
|
563
|
+
(processed as any).tag = false;
|
|
513
564
|
} else {
|
|
514
565
|
(processed as any).tag = String(value);
|
|
515
566
|
}
|
|
@@ -526,16 +577,8 @@ export function processStructure(
|
|
|
526
577
|
// Handle html property for embed nodes - process templates like {{propName}}
|
|
527
578
|
if (preservedType === NODE_TYPE.EMBED) {
|
|
528
579
|
if (typeof value === 'string') {
|
|
529
|
-
// Build evaluation context
|
|
530
|
-
const evalContext
|
|
531
|
-
if (context.componentDef && typeof context.componentDef === 'object') {
|
|
532
|
-
Object.assign(evalContext, context.componentDef as Record<string, unknown>);
|
|
533
|
-
}
|
|
534
|
-
// Add parent cms-list item context for nested template resolution
|
|
535
|
-
const itemContext = (context as Record<string, unknown>).itemContext as Record<string, unknown> | undefined;
|
|
536
|
-
if (itemContext) {
|
|
537
|
-
Object.assign(evalContext, itemContext);
|
|
538
|
-
}
|
|
580
|
+
// Build evaluation context with item context for nested template resolution
|
|
581
|
+
const evalContext = buildEvalContext(context, true);
|
|
539
582
|
// Check if entire string is a complete template {{expr}}
|
|
540
583
|
if (/^\{\{.+\}\}$/.test(value) && !hasItemTemplates(value)) {
|
|
541
584
|
const result = evaluateTemplate(value, evalContext);
|
|
@@ -583,78 +626,29 @@ export function processStructure(
|
|
|
583
626
|
if (processedStyle && typeof processedStyle === 'object' && !Array.isArray(processedStyle)) {
|
|
584
627
|
// Check if it's a responsive style object
|
|
585
628
|
if (isResponsiveStyle(processedStyle as StyleValue)) {
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
} else if (typeof styleValue === 'string') {
|
|
598
|
-
if (hasTemplates(styleValue)) {
|
|
599
|
-
const evaluated = processCodeTemplates(styleValue, context.props);
|
|
600
|
-
resolvedResponsive[bkeyName]![styleKey] = evaluated;
|
|
601
|
-
} else {
|
|
602
|
-
resolvedResponsive[bkeyName]![styleKey] = styleValue;
|
|
603
|
-
}
|
|
604
|
-
} else if (typeof styleValue === 'number') {
|
|
605
|
-
resolvedResponsive[bkeyName]![styleKey] = styleValue;
|
|
606
|
-
}
|
|
607
|
-
}
|
|
608
|
-
}
|
|
609
|
-
}
|
|
610
|
-
resolvedStyle = resolvedResponsive;
|
|
611
|
-
} else {
|
|
612
|
-
// Preserve responsive styles for editor (same as SSR)
|
|
613
|
-
// responsiveStylesToClasses will generate prefixed classes (t-, mob-)
|
|
614
|
-
// CSS media queries will handle displaying the correct styles based on viewport
|
|
615
|
-
const resolvedResponsive: ResponsiveStyleObject = {};
|
|
616
|
-
for (const [bkeyName, bkeyValue] of Object.entries(processedStyle)) {
|
|
617
|
-
if (typeof bkeyValue === 'object' && bkeyValue !== null) {
|
|
618
|
-
resolvedResponsive[bkeyName] = {};
|
|
619
|
-
for (const [styleKey, styleValue] of Object.entries(bkeyValue)) {
|
|
620
|
-
const resolved = resolveStyleMapping(styleValue, context.props);
|
|
621
|
-
if (resolved !== undefined) {
|
|
622
|
-
resolvedResponsive[bkeyName]![styleKey] = resolved;
|
|
623
|
-
} else if (typeof styleValue === 'string') {
|
|
624
|
-
if (hasTemplates(styleValue)) {
|
|
625
|
-
const evaluated = processCodeTemplates(styleValue, context.props);
|
|
626
|
-
resolvedResponsive[bkeyName]![styleKey] = evaluated;
|
|
627
|
-
} else {
|
|
628
|
-
resolvedResponsive[bkeyName]![styleKey] = styleValue;
|
|
629
|
-
}
|
|
630
|
-
} else if (typeof styleValue === 'number') {
|
|
631
|
-
resolvedResponsive[bkeyName]![styleKey] = styleValue;
|
|
632
|
-
}
|
|
629
|
+
// Preserve responsive styles (for both SSR and editor)
|
|
630
|
+
// responsiveStylesToClasses will generate prefixed classes (t-, mob-)
|
|
631
|
+
// CSS media queries will handle displaying the correct styles based on viewport
|
|
632
|
+
const resolvedResponsive: ResponsiveStyleObject = {};
|
|
633
|
+
for (const [bkeyName, bkeyValue] of Object.entries(processedStyle)) {
|
|
634
|
+
if (typeof bkeyValue === 'object' && bkeyValue !== null) {
|
|
635
|
+
resolvedResponsive[bkeyName] = {};
|
|
636
|
+
for (const [styleKey, styleValue] of Object.entries(bkeyValue)) {
|
|
637
|
+
const processedValue = processStyleValue(styleValue, context.props);
|
|
638
|
+
if (processedValue !== undefined) {
|
|
639
|
+
resolvedResponsive[bkeyName]![styleKey] = processedValue;
|
|
633
640
|
}
|
|
634
641
|
}
|
|
635
642
|
}
|
|
636
|
-
resolvedStyle = resolvedResponsive;
|
|
637
643
|
}
|
|
644
|
+
resolvedStyle = resolvedResponsive;
|
|
638
645
|
} else {
|
|
639
646
|
// Legacy flat style object - resolve mappings and evaluate templates
|
|
640
647
|
resolvedStyle = {};
|
|
641
648
|
for (const [styleKey, styleValue] of Object.entries(processedStyle)) {
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
resolvedStyle[styleKey] = resolved;
|
|
646
|
-
} else if (typeof styleValue === 'string') {
|
|
647
|
-
// Evaluate template strings in style values (supports partial templates like "{{size}}px")
|
|
648
|
-
// Use processCodeTemplates to handle templates with text before/after
|
|
649
|
-
if (hasTemplates(styleValue)) {
|
|
650
|
-
const evaluated = processCodeTemplates(styleValue, context.props);
|
|
651
|
-
resolvedStyle[styleKey] = evaluated;
|
|
652
|
-
} else {
|
|
653
|
-
// No templates, keep original value
|
|
654
|
-
resolvedStyle[styleKey] = styleValue;
|
|
655
|
-
}
|
|
656
|
-
} else if (typeof styleValue === 'number') {
|
|
657
|
-
resolvedStyle[styleKey] = styleValue;
|
|
649
|
+
const processedValue = processStyleValue(styleValue, context.props);
|
|
650
|
+
if (processedValue !== undefined) {
|
|
651
|
+
resolvedStyle[styleKey] = processedValue;
|
|
658
652
|
}
|
|
659
653
|
}
|
|
660
654
|
}
|
|
@@ -712,10 +706,7 @@ export function processStructure(
|
|
|
712
706
|
// Special handling for attributes - process templates but don't treat as node structure
|
|
713
707
|
// This preserves type="checkbox" etc. which would otherwise be caught by node type handling
|
|
714
708
|
const processedAttributes: Record<string, unknown> = {};
|
|
715
|
-
const evalContext
|
|
716
|
-
if (context.componentDef && typeof context.componentDef === 'object') {
|
|
717
|
-
Object.assign(evalContext, context.componentDef as Record<string, unknown>);
|
|
718
|
-
}
|
|
709
|
+
const evalContext = buildEvalContext(context, false);
|
|
719
710
|
for (const [attrKey, attrValue] of Object.entries(value)) {
|
|
720
711
|
if (typeof attrValue === 'string' && hasTemplates(attrValue)) {
|
|
721
712
|
// Check if entire string is a complete template {{expr}} - preserve type (boolean, number)
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration Tests: API Routes
|
|
3
|
+
* Tests core API endpoints through real HTTP requests
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
|
7
|
+
import { createTestServer, fetch, TEST_PAGE_JSON, TEST_COMPONENT_DEF } from './test-helpers';
|
|
8
|
+
|
|
9
|
+
let ctx: Awaited<ReturnType<typeof createTestServer>>;
|
|
10
|
+
|
|
11
|
+
beforeAll(async () => {
|
|
12
|
+
const components = new Map();
|
|
13
|
+
components.set('TestButton', TEST_COMPONENT_DEF);
|
|
14
|
+
|
|
15
|
+
ctx = await createTestServer({
|
|
16
|
+
pages: {
|
|
17
|
+
'/': TEST_PAGE_JSON,
|
|
18
|
+
'/about': JSON.stringify({
|
|
19
|
+
meta: { title: 'About', description: 'About page' },
|
|
20
|
+
root: { type: 'node', tag: 'div', children: 'About content' },
|
|
21
|
+
}, null, 2),
|
|
22
|
+
},
|
|
23
|
+
components,
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
afterAll(() => {
|
|
28
|
+
ctx?.cleanup();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe('API Routes', () => {
|
|
32
|
+
// ========== Pages ==========
|
|
33
|
+
|
|
34
|
+
test('GET /api/pages returns page list', async () => {
|
|
35
|
+
const res = await fetch(`${ctx.baseUrl}/api/pages`);
|
|
36
|
+
expect(res.status).toBe(200);
|
|
37
|
+
|
|
38
|
+
const data = await res.json();
|
|
39
|
+
expect(data.pages).toBeArray();
|
|
40
|
+
expect(data.pages.length).toBe(2);
|
|
41
|
+
|
|
42
|
+
const paths = data.pages.map((p: any) => p.path);
|
|
43
|
+
expect(paths).toContain('/');
|
|
44
|
+
expect(paths).toContain('/about');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('GET /api/page-data/:path returns page JSON with line map', async () => {
|
|
48
|
+
const res = await fetch(`${ctx.baseUrl}/api/page-data/`);
|
|
49
|
+
expect(res.status).toBe(200);
|
|
50
|
+
|
|
51
|
+
const data = await res.json();
|
|
52
|
+
expect(data.meta).toBeDefined();
|
|
53
|
+
expect(data.meta.title).toBe('Test Page');
|
|
54
|
+
expect(data.root).toBeDefined();
|
|
55
|
+
expect(data._lineMap).toBeDefined();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('GET /api/page-data/nonexistent returns 404', async () => {
|
|
59
|
+
const res = await fetch(`${ctx.baseUrl}/api/page-data/nonexistent`);
|
|
60
|
+
expect(res.status).toBe(404);
|
|
61
|
+
|
|
62
|
+
const data = await res.json();
|
|
63
|
+
expect(data.error).toBeDefined();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// ========== Components ==========
|
|
67
|
+
|
|
68
|
+
test('GET /api/components returns component list', async () => {
|
|
69
|
+
const res = await fetch(`${ctx.baseUrl}/api/components`);
|
|
70
|
+
expect(res.status).toBe(200);
|
|
71
|
+
|
|
72
|
+
const data = await res.json();
|
|
73
|
+
expect(typeof data).toBe('object');
|
|
74
|
+
// Components loaded via mock loader - verify response structure
|
|
75
|
+
expect(data).toBeDefined();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('GET /api/component-data/:name returns 404 for missing component', async () => {
|
|
79
|
+
const res = await fetch(`${ctx.baseUrl}/api/component-data/NonExistentComponent`);
|
|
80
|
+
expect(res.status).toBe(404);
|
|
81
|
+
|
|
82
|
+
const data = await res.json();
|
|
83
|
+
expect(data.error).toBeDefined();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// ========== Config ==========
|
|
87
|
+
|
|
88
|
+
test('GET /api/config returns project config', async () => {
|
|
89
|
+
const res = await fetch(`${ctx.baseUrl}/api/config`);
|
|
90
|
+
expect(res.status).toBe(200);
|
|
91
|
+
|
|
92
|
+
const data = await res.json();
|
|
93
|
+
// Returns default config when no project.config.json exists
|
|
94
|
+
expect(data).toBeDefined();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// ========== Colors ==========
|
|
98
|
+
|
|
99
|
+
test('GET /api/colors returns color variables', async () => {
|
|
100
|
+
const res = await fetch(`${ctx.baseUrl}/api/colors`);
|
|
101
|
+
expect(res.status).toBe(200);
|
|
102
|
+
|
|
103
|
+
const data = await res.json();
|
|
104
|
+
expect(data).toBeDefined();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// ========== Usage ==========
|
|
108
|
+
|
|
109
|
+
test('GET /api/usage returns usage stats', async () => {
|
|
110
|
+
const res = await fetch(`${ctx.baseUrl}/api/usage`);
|
|
111
|
+
expect(res.status).toBe(200);
|
|
112
|
+
|
|
113
|
+
const data = await res.json();
|
|
114
|
+
expect(typeof data.pages).toBe('number');
|
|
115
|
+
expect(typeof data.locales).toBe('number');
|
|
116
|
+
expect(typeof data.cmsCollections).toBe('number');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// ========== Slug Mappings ==========
|
|
120
|
+
|
|
121
|
+
test('GET /api/slug-mappings returns slug translations', async () => {
|
|
122
|
+
const res = await fetch(`${ctx.baseUrl}/api/slug-mappings`);
|
|
123
|
+
expect(res.status).toBe(200);
|
|
124
|
+
|
|
125
|
+
const data = await res.json();
|
|
126
|
+
expect(data.mappings).toBeArray();
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// ========== Response Headers ==========
|
|
130
|
+
|
|
131
|
+
test('API responses have JSON content-type', async () => {
|
|
132
|
+
const res = await fetch(`${ctx.baseUrl}/api/pages`);
|
|
133
|
+
const contentType = res.headers.get('Content-Type');
|
|
134
|
+
expect(contentType).toContain('application/json');
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('API responses have cache-control: no-store', async () => {
|
|
138
|
+
const res = await fetch(`${ctx.baseUrl}/api/pages`);
|
|
139
|
+
const cacheControl = res.headers.get('Cache-Control');
|
|
140
|
+
expect(cacheControl).toContain('no-store');
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test('API responses include CORS headers', async () => {
|
|
144
|
+
const res = await fetch(`${ctx.baseUrl}/api/pages`);
|
|
145
|
+
const origin = res.headers.get('Access-Control-Allow-Origin');
|
|
146
|
+
expect(origin).toBeTruthy();
|
|
147
|
+
});
|
|
148
|
+
});
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration Tests: CMS Integration
|
|
3
|
+
* Tests CMS CRUD flow and route matching through HTTP
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
|
7
|
+
import { createTestServer, fetch, createTempProject, TEST_CMS_TEMPLATE } from './test-helpers';
|
|
8
|
+
import { FileSystemCMSProvider } from '../providers/fileSystemCMSProvider';
|
|
9
|
+
import { CMSService } from '../services/cmsService';
|
|
10
|
+
import { setProjectRoot } from '../projectContext';
|
|
11
|
+
|
|
12
|
+
let ctx: Awaited<ReturnType<typeof createTestServer>>;
|
|
13
|
+
let tempProject: ReturnType<typeof createTempProject>;
|
|
14
|
+
let originalProjectRoot: string;
|
|
15
|
+
|
|
16
|
+
beforeAll(async () => {
|
|
17
|
+
// Save original project root to restore later
|
|
18
|
+
const { getProjectRoot } = await import('../projectContext');
|
|
19
|
+
originalProjectRoot = getProjectRoot();
|
|
20
|
+
|
|
21
|
+
// Create temp project with CMS template and items
|
|
22
|
+
tempProject = createTempProject({
|
|
23
|
+
'pages/templates/posts.json': TEST_CMS_TEMPLATE,
|
|
24
|
+
'cms/posts/hello-world.json': JSON.stringify({
|
|
25
|
+
_id: 'hello-world',
|
|
26
|
+
title: 'Hello World',
|
|
27
|
+
slug: 'hello-world',
|
|
28
|
+
body: 'This is a blog post about hello world.',
|
|
29
|
+
}),
|
|
30
|
+
'cms/posts/second-post.json': JSON.stringify({
|
|
31
|
+
_id: 'second-post',
|
|
32
|
+
title: 'Second Post',
|
|
33
|
+
slug: 'second-post',
|
|
34
|
+
body: 'This is the second blog post.',
|
|
35
|
+
}),
|
|
36
|
+
'project.config.json': JSON.stringify({ siteUrl: 'https://example.com' }),
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
// Point project context to temp directory
|
|
40
|
+
setProjectRoot(tempProject.projectDir);
|
|
41
|
+
|
|
42
|
+
// Create CMS provider and service with real file system
|
|
43
|
+
const cmsProvider = new FileSystemCMSProvider(
|
|
44
|
+
`${tempProject.projectDir}/pages`,
|
|
45
|
+
`${tempProject.projectDir}/cms`
|
|
46
|
+
);
|
|
47
|
+
const cmsService = new CMSService(cmsProvider);
|
|
48
|
+
await cmsService.initialize();
|
|
49
|
+
|
|
50
|
+
ctx = await createTestServer({
|
|
51
|
+
pages: {},
|
|
52
|
+
cmsService,
|
|
53
|
+
cmsProvider,
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
afterAll(() => {
|
|
58
|
+
ctx?.cleanup();
|
|
59
|
+
tempProject?.cleanup();
|
|
60
|
+
// Restore original project root
|
|
61
|
+
setProjectRoot(originalProjectRoot);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe('CMS Integration', () => {
|
|
65
|
+
// ========== Collections API ==========
|
|
66
|
+
|
|
67
|
+
test('GET /api/cms/collections returns collection list', async () => {
|
|
68
|
+
const res = await fetch(`${ctx.baseUrl}/api/cms/collections`);
|
|
69
|
+
expect(res.status).toBe(200);
|
|
70
|
+
|
|
71
|
+
const data = await res.json();
|
|
72
|
+
expect(data.collections).toBeArray();
|
|
73
|
+
expect(data.collections.length).toBeGreaterThanOrEqual(1);
|
|
74
|
+
|
|
75
|
+
const postCollection = data.collections.find((c: any) => c.collection === 'posts');
|
|
76
|
+
expect(postCollection).toBeDefined();
|
|
77
|
+
expect(postCollection.urlPattern).toBe('/blog/{{slug}}');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('GET /api/cms/collections/:id returns schema for specific collection', async () => {
|
|
81
|
+
const res = await fetch(`${ctx.baseUrl}/api/cms/collections/posts`);
|
|
82
|
+
expect(res.status).toBe(200);
|
|
83
|
+
|
|
84
|
+
const data = await res.json();
|
|
85
|
+
expect(data.schema).toBeDefined();
|
|
86
|
+
expect(data.schema.fields).toBeDefined();
|
|
87
|
+
expect(data.schema.fields.title).toBeDefined();
|
|
88
|
+
expect(data.schema.fields.slug).toBeDefined();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// ========== Items API ==========
|
|
92
|
+
|
|
93
|
+
test('GET /api/cms/:collection returns item list', async () => {
|
|
94
|
+
const res = await fetch(`${ctx.baseUrl}/api/cms/posts`);
|
|
95
|
+
expect(res.status).toBe(200);
|
|
96
|
+
|
|
97
|
+
const data = await res.json();
|
|
98
|
+
expect(data.items).toBeArray();
|
|
99
|
+
expect(data.items.length).toBe(2);
|
|
100
|
+
expect(data.total).toBe(2);
|
|
101
|
+
|
|
102
|
+
const titles = data.items.map((i: any) => i.title);
|
|
103
|
+
expect(titles).toContain('Hello World');
|
|
104
|
+
expect(titles).toContain('Second Post');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('GET /api/cms/:collection?search=term returns filtered items', async () => {
|
|
108
|
+
const res = await fetch(`${ctx.baseUrl}/api/cms/posts?search=hello`);
|
|
109
|
+
expect(res.status).toBe(200);
|
|
110
|
+
|
|
111
|
+
const data = await res.json();
|
|
112
|
+
expect(data.items.length).toBe(1);
|
|
113
|
+
expect(data.items[0].title).toBe('Hello World');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test('GET /api/cms/:collection/:slug returns single item', async () => {
|
|
117
|
+
const res = await fetch(`${ctx.baseUrl}/api/cms/posts/hello-world`);
|
|
118
|
+
expect(res.status).toBe(200);
|
|
119
|
+
|
|
120
|
+
const data = await res.json();
|
|
121
|
+
expect(data.item).toBeDefined();
|
|
122
|
+
expect(data.item.title).toBe('Hello World');
|
|
123
|
+
expect(data.item.slug).toBe('hello-world');
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// ========== Client Data Endpoint ==========
|
|
127
|
+
|
|
128
|
+
test('GET /data/:collection/index.json returns items as JSON', async () => {
|
|
129
|
+
const res = await fetch(`${ctx.baseUrl}/data/posts/index.json`);
|
|
130
|
+
expect(res.status).toBe(200);
|
|
131
|
+
|
|
132
|
+
const contentType = res.headers.get('Content-Type');
|
|
133
|
+
expect(contentType).toContain('application/json');
|
|
134
|
+
|
|
135
|
+
const data = await res.json();
|
|
136
|
+
expect(data).toBeArray();
|
|
137
|
+
expect(data.length).toBe(2);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// ========== CMS Route Matching ==========
|
|
141
|
+
|
|
142
|
+
test('GET /blog/:slug renders CMS template with item data', async () => {
|
|
143
|
+
const res = await fetch(`${ctx.baseUrl}/blog/hello-world`);
|
|
144
|
+
expect(res.status).toBe(200);
|
|
145
|
+
|
|
146
|
+
const html = await res.text();
|
|
147
|
+
expect(html).toContain('<!DOCTYPE html>');
|
|
148
|
+
// The template interpolates {{cms.title}} and {{cms.body}}
|
|
149
|
+
expect(html).toContain('Hello World');
|
|
150
|
+
expect(html).toContain('hello world');
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test('CMS page template interpolates {{cms.field}} values', async () => {
|
|
154
|
+
const res = await fetch(`${ctx.baseUrl}/blog/second-post`);
|
|
155
|
+
expect(res.status).toBe(200);
|
|
156
|
+
|
|
157
|
+
const html = await res.text();
|
|
158
|
+
expect(html).toContain('Second Post');
|
|
159
|
+
expect(html).toContain('second blog post');
|
|
160
|
+
});
|
|
161
|
+
});
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration Tests: Server Lifecycle
|
|
3
|
+
* Tests server start, stop, port handling, and CORS
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, test, expect, afterAll } from 'bun:test';
|
|
7
|
+
import { createTestServer, fetch, TEST_PAGE_JSON } from './test-helpers';
|
|
8
|
+
|
|
9
|
+
// Track servers for cleanup
|
|
10
|
+
const servers: Array<{ cleanup: () => void }> = [];
|
|
11
|
+
afterAll(() => {
|
|
12
|
+
servers.forEach(s => s.cleanup());
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
describe('Server Lifecycle', () => {
|
|
16
|
+
test('starts on specified port and responds to requests', async () => {
|
|
17
|
+
const ctx = await createTestServer({
|
|
18
|
+
pages: { '/': TEST_PAGE_JSON },
|
|
19
|
+
});
|
|
20
|
+
servers.push(ctx);
|
|
21
|
+
|
|
22
|
+
const res = await fetch(`${ctx.baseUrl}/api/pages`);
|
|
23
|
+
expect(res.status).toBe(200);
|
|
24
|
+
expect(res.ok).toBe(true);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('returns 404 for unknown file requests', async () => {
|
|
28
|
+
const ctx = await createTestServer({
|
|
29
|
+
pages: { '/': TEST_PAGE_JSON },
|
|
30
|
+
});
|
|
31
|
+
servers.push(ctx);
|
|
32
|
+
|
|
33
|
+
// Unknown file paths (with extension) return 404
|
|
34
|
+
const res = await fetch(`${ctx.baseUrl}/nonexistent.json`);
|
|
35
|
+
expect(res.status).toBe(404);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('handles CORS preflight (OPTIONS) requests', async () => {
|
|
39
|
+
const ctx = await createTestServer();
|
|
40
|
+
servers.push(ctx);
|
|
41
|
+
|
|
42
|
+
const res = await fetch(`${ctx.baseUrl}/api/pages`, {
|
|
43
|
+
method: 'OPTIONS',
|
|
44
|
+
});
|
|
45
|
+
expect(res.status).toBe(204);
|
|
46
|
+
expect(res.headers.get('Access-Control-Allow-Origin')).toBeTruthy();
|
|
47
|
+
expect(res.headers.get('Access-Control-Allow-Methods')).toBeTruthy();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('gracefully shuts down with server.stop()', async () => {
|
|
51
|
+
const ctx = await createTestServer({
|
|
52
|
+
pages: { '/': TEST_PAGE_JSON },
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// Verify it's running
|
|
56
|
+
const res = await fetch(`${ctx.baseUrl}/api/pages`);
|
|
57
|
+
expect(res.status).toBe(200);
|
|
58
|
+
|
|
59
|
+
// Stop it
|
|
60
|
+
ctx.cleanup();
|
|
61
|
+
|
|
62
|
+
// After stop, fetch should fail
|
|
63
|
+
try {
|
|
64
|
+
await fetch(`${ctx.baseUrl}/api/pages`);
|
|
65
|
+
// If we reach here, the server is still up (shouldn't happen)
|
|
66
|
+
expect(false).toBe(true);
|
|
67
|
+
} catch (error) {
|
|
68
|
+
// Expected - connection refused
|
|
69
|
+
expect(error).toBeTruthy();
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('multiple servers can run in parallel on different ports', async () => {
|
|
74
|
+
const ctx1 = await createTestServer({
|
|
75
|
+
pages: { '/': TEST_PAGE_JSON },
|
|
76
|
+
});
|
|
77
|
+
servers.push(ctx1);
|
|
78
|
+
|
|
79
|
+
const ctx2 = await createTestServer({
|
|
80
|
+
pages: { '/about': TEST_PAGE_JSON },
|
|
81
|
+
});
|
|
82
|
+
servers.push(ctx2);
|
|
83
|
+
|
|
84
|
+
expect(ctx1.port).not.toBe(ctx2.port);
|
|
85
|
+
|
|
86
|
+
const [res1, res2] = await Promise.all([
|
|
87
|
+
fetch(`${ctx1.baseUrl}/api/pages`),
|
|
88
|
+
fetch(`${ctx2.baseUrl}/api/pages`),
|
|
89
|
+
]);
|
|
90
|
+
|
|
91
|
+
expect(res1.status).toBe(200);
|
|
92
|
+
expect(res2.status).toBe(200);
|
|
93
|
+
|
|
94
|
+
const data1 = await res1.json();
|
|
95
|
+
const data2 = await res2.json();
|
|
96
|
+
|
|
97
|
+
// First server has '/' page, second has '/about'
|
|
98
|
+
expect(data1.pages.some((p: any) => p.path === '/')).toBe(true);
|
|
99
|
+
expect(data2.pages.some((p: any) => p.path === '/about')).toBe(true);
|
|
100
|
+
});
|
|
101
|
+
});
|