import type { Component, PageComponent, } from '@nordcraft/core/dist/component/component.types' import { functionFormula, valueFormula, } from '@nordcraft/core/dist/formula/formulaUtils' import { describe, expect, test } from 'bun:test' import type { ProjectFiles } from '../ssr.types' import { renderPageBody } from './components' import { getPageFormulaContext } from './formulaContext' describe('renderPageBody', () => { test('should render a simple page', async () => { const component: Component = { name: 'SimpleComponent', contexts: {}, route: { path: [ { name: 'home', type: 'static', }, ], query: {}, }, events: [], nodes: { root: { tag: 'div', type: 'element', attrs: { 'data-test-attr': { type: 'value', value: 'test', }, }, style: { color: 'red' }, events: {}, classes: {}, children: [], }, }, formulas: {}, apis: {}, attributes: {}, variables: {}, } const { html } = await renderPageBody({ evaluateComponentApis: () => ({}) as any, component: component as any, formulaContext: { data: { Attributes: {}, }, component, env: {} as any, package: undefined, toddle: {} as any, }, env: {} as any, files: { components: { SimpleComponent: component } } as any, includedComponents: [component], projectId: 'test-project', req: {} as any, }) expect(html).toBe( '
', ) }) test('should render a static value provided by a context provider component', async () => { const providerComponent: Component = { name: 'ProviderComponent', formulas: { contextValue: { name: 'contextValue', exposeInContext: true, formula: { type: 'value', value: 'provided-value', }, }, }, nodes: { root: { type: 'component', name: 'ConsumerComponent', attrs: {}, children: [], events: {}, style: {}, }, }, } const consumerComponent: Component = { name: 'ConsumerComponent', contexts: { ProviderComponent: { formulas: ['contextValue'], workflows: [], }, }, nodes: { root: { type: 'text', value: { type: 'path', path: ['Contexts', 'ProviderComponent', 'contextValue'], }, }, }, formulas: {}, apis: {}, attributes: {}, variables: {}, events: [], } const { html } = await renderPageBody({ evaluateComponentApis: () => ({}) as any, component: providerComponent as any, formulaContext: { data: { Attributes: {}, }, component: providerComponent, env: {} as any, package: undefined, toddle: {} as any, }, env: {} as any, files: { components: { ProviderComponent: providerComponent, ConsumerComponent: consumerComponent, }, } as any, includedComponents: [providerComponent, consumerComponent], projectId: 'test-project', req: {} as any, }) expect(html).toBe( 'provided-value', ) }) // Bug: https://discord.com/channels/972416966683926538/1458386701612351612/1458386701612351612 test('should render correct style overrides when component is used through packages', async () => { const buttonComponent: Component = { name: 'ButtonComponent', nodes: { root: { type: 'element', tag: 'button', attrs: {}, style: { backgroundColor: 'blue', }, events: {}, classes: {}, children: [], }, }, formulas: {}, apis: {}, attributes: {}, variables: {}, events: [], } const buttonWrapperComponent: Component = { name: 'ButtonWrapperComponent', nodes: { root: { type: 'component', name: 'ButtonComponent', attrs: {}, children: [], events: {}, style: { backgroundColor: 'red', }, }, }, formulas: {}, apis: {}, attributes: {}, variables: {}, events: [], } const pageComponent: Component = { name: 'PageComponent', nodes: { root: { type: 'component', name: 'ButtonWrapperComponent', attrs: {}, children: [], events: {}, style: {}, package: 'test-package', }, }, formulas: {}, apis: {}, attributes: {}, variables: {}, events: [], } const { html } = await renderPageBody({ evaluateComponentApis: () => ({}) as any, component: pageComponent as any, formulaContext: { data: { Attributes: {}, }, component: pageComponent, env: {} as any, package: undefined, toddle: {} as any, }, env: {} as any, files: { components: { PageComponent: pageComponent, }, packages: { 'test-package': { components: { ButtonWrapperComponent: buttonWrapperComponent, ButtonComponent: buttonComponent, }, }, }, } as any, includedComponents: [ pageComponent, buttonWrapperComponent, buttonComponent, ], projectId: 'test-project', req: {} as any, }) // Own styling expect(html).toContain('PageComponent:root') // Package component styling override expect(html).toContain('test-package/ButtonWrapperComponent:root') }) // Bug: https://discord.com/channels/972416966683926538/1471073500520386760 test('should evaluate project formulas correctly inside package components', async () => { const packageWrapperComponent: Component = { name: 'PackageWrapperComponent', nodes: { root: { type: 'element', tag: 'span', attrs: {}, children: ['childText'], events: {}, }, childText: { type: 'slot', children: [], }, }, } const pageComponent: PageComponent = { name: 'PageComponent', route: { path: [], query: {}, }, nodes: { root: { type: 'element', tag: 'div', attrs: {}, children: ['firstChild', 'secondChild'], events: {}, style: {}, }, firstChild: { type: 'text', value: functionFormula('test-formula'), }, secondChild: { type: 'component', name: 'PackageWrapperComponent', package: 'test-package', attrs: {}, children: ['nestedChild'], events: {}, }, nestedChild: { type: 'text', value: functionFormula('test-formula'), }, }, formulas: {}, apis: {}, attributes: {}, variables: {}, events: [], } const files = { components: { PageComponent: pageComponent, }, formulas: { 'test-formula': { name: 'test-formula', formula: valueFormula('output-from-test-formula'), version: 2, arguments: [], }, }, packages: { 'test-package': { manifest: { name: 'test-package', commit: '123', }, components: { PackageWrapperComponent: packageWrapperComponent, }, }, }, } as Pick const formulaContext = getPageFormulaContext({ component: pageComponent, branchName: 'main', req: new Request('http://localhost'), logErrors: true, files, }) const { html } = await renderPageBody({ evaluateComponentApis: () => ({}) as any, component: pageComponent as any, formulaContext, env: formulaContext.env, files, includedComponents: [pageComponent, packageWrapperComponent], projectId: 'test-project', req: {} as any, }) // Expect the first child text node to render the formula value expect(html).toInclude( 'output-from-test-formula', ) // Expect the nested child text node inside the package component to also render the formula value, // proving that the formula is evaluated correctly even when used inside a package component expect(html).toInclude( 'output-from-test-formula', ) }) // Bug: https://discord.com/channels/972416966683926538/1458387768504746068/1458387768504746068 // TODO: Fix rendering so that context is properly passed through slots. Rendering should go through inner components before slotted content. test('should render context value when consumer is through multiple slots of a wrapped provider', async () => { // Four components required for test setup: // PageComponent - uses WrappedProviderComponent with a slotted ConsumerComponent // WrappedProviderComponent - uses ProviderComponent and passes slot to it // ProviderComponent - provides context value to its children through a slot // ConsumerComponent - consumes context value and renders it const pageComponent: Component = { name: 'PageComponent', nodes: { root: { type: 'component', name: 'WrappedProviderComponent', attrs: {}, children: ['consumerSlot'], events: {}, style: {}, }, consumerSlot: { type: 'component', name: 'ConsumerComponent', attrs: {}, children: [], events: {}, style: {}, }, }, formulas: {}, apis: {}, attributes: {}, variables: {}, events: [], } const wrappedProviderComponent: Component = { name: 'WrappedProviderComponent', nodes: { root: { type: 'component', name: 'ProviderComponent', attrs: {}, children: ['slot'], events: {}, style: {}, }, slot: { type: 'slot', children: [], }, }, formulas: {}, apis: {}, attributes: {}, variables: {}, events: [], } const providerComponent: Component = { name: 'ProviderComponent', formulas: { contextValue: { name: 'contextValue', exposeInContext: true, formula: { type: 'value', value: 'provided-value', }, }, }, nodes: { root: { type: 'slot', children: [], }, }, } const consumerComponent: Component = { name: 'ConsumerComponent', contexts: { ProviderComponent: { formulas: ['contextValue'], workflows: [], }, }, nodes: { root: { type: 'text', value: { type: 'path', path: ['Contexts', 'ProviderComponent', 'contextValue'], }, }, }, formulas: {}, apis: {}, attributes: {}, variables: {}, events: [], } const { html } = await renderPageBody({ evaluateComponentApis: () => ({}) as any, component: pageComponent as any, formulaContext: { data: { Attributes: {}, }, component: pageComponent, env: {} as any, package: undefined, toddle: {} as any, }, env: {} as any, files: { components: { PageComponent: pageComponent, WrappedProviderComponent: wrappedProviderComponent, ProviderComponent: providerComponent, ConsumerComponent: consumerComponent, }, } as any, includedComponents: [ pageComponent, wrappedProviderComponent, providerComponent, consumerComponent, ], projectId: 'test-project', req: {} as any, }) expect(html).toBe( `provided-value`, ) }) test('should render a component where a variable initial value references Page.Theme', async () => { const component: Component = { name: 'ThemeVariableComponent', route: { path: [{ name: 'home', type: 'static' }], query: {}, info: { theme: { formula: { type: 'value', value: 'dark', }, }, }, }, variables: { themeVar: { initialValue: { type: 'path', path: ['Page', 'Theme'], }, }, }, nodes: { root: { type: 'text', value: { type: 'path', path: ['Variables', 'themeVar'], }, }, }, formulas: {}, apis: {}, attributes: {}, events: [], } const { html } = await renderPageBody({ evaluateComponentApis: () => ({}) as any, component: component as any, formulaContext: getPageFormulaContext({ component: component as any, branchName: 'main', req: new Request('http://localhost'), logErrors: true, files: { components: { ThemeVariableComponent: component }, }, }), env: {} as any, files: { components: { ThemeVariableComponent: component } } as any, includedComponents: [component], projectId: 'test-project', req: {} as any, }) expect(html).toBe( 'dark', ) }) test('should render a component where Page.Theme references a variables initial value', async () => { const component: Component = { name: 'ThemeVariableComponent', route: { path: [{ name: 'home', type: 'static' }], query: {}, info: { theme: { formula: { type: 'path', path: ['Variables', 'themeVar'], }, }, }, }, variables: { themeVar: { initialValue: { type: 'value', value: 'hotdog', }, }, }, nodes: { root: { type: 'text', value: { type: 'path', path: ['Page', 'Theme'], }, }, }, formulas: {}, apis: {}, attributes: {}, events: [], } const { html } = await renderPageBody({ evaluateComponentApis: () => ({}) as any, component: component as any, formulaContext: getPageFormulaContext({ component: component as any, branchName: 'main', req: new Request('http://localhost'), logErrors: true, files: { components: { ThemeVariableComponent: component }, }, }), env: {} as any, files: { components: { ThemeVariableComponent: component } } as any, includedComponents: [component], projectId: 'test-project', req: {} as any, }) expect(html).toBe( 'hotdog', ) }) test('should render a child component where a variable initial value references Page.Theme from the parent', async () => { const childComponent: Component = { name: 'ChildComponent', variables: { childThemeVar: { initialValue: { type: 'path', path: ['Page', 'Theme'], }, }, }, nodes: { root: { type: 'text', value: { type: 'path', path: ['Variables', 'childThemeVar'], }, }, }, formulas: {}, apis: {}, attributes: {}, events: [], } const parentComponent: Component = { name: 'ParentComponent', route: { path: [{ name: 'home', type: 'static' }], query: {}, }, nodes: { root: { type: 'component', name: 'ChildComponent', attrs: {}, children: [], events: {}, style: {}, }, }, formulas: {}, apis: {}, attributes: {}, variables: {}, events: [], } const { html } = await renderPageBody({ evaluateComponentApis: () => ({}) as any, component: parentComponent as any, formulaContext: { data: { Attributes: {}, Page: { Theme: 'light', }, }, component: parentComponent, env: {} as any, package: undefined, toddle: {} as any, }, env: {} as any, files: { components: { ParentComponent: parentComponent, ChildComponent: childComponent, }, } as any, includedComponents: [parentComponent, childComponent], projectId: 'test-project', req: {} as any, }) expect(html).toBe( 'light', ) }) test('Component C should see the context value from Component A when Component B forwards its attribute using slots', async () => { // Component A: Provider // Accepts 'value' as attribute and provides it in context const componentA: Component = { name: 'ComponentA', attributes: { value: { name: 'value', testValue: 'test', }, }, formulas: { exposedValue: { name: 'exposedValue', exposeInContext: true, formula: { type: 'path', path: ['Attributes', 'value'], }, }, }, nodes: { root: { type: 'element', tag: 'div', attrs: {}, children: ['slot_node'], events: {}, style: {}, }, slot_node: { type: 'slot', name: 'default', children: [], }, }, contexts: {}, apis: {}, variables: {}, events: [], } // Component B: Intermediary // Accepts 'val' as attribute and forwards it to Component A's 'value' attribute const componentB: Component = { name: 'ComponentB', attributes: { val: { name: 'val', testValue: 'test', }, }, nodes: { root: { type: 'element', tag: 'div', attrs: {}, children: ['provider_node'], events: {}, style: {}, }, provider_node: { type: 'component', name: 'ComponentA', attrs: { value: { type: 'path', path: ['Attributes', 'val'], }, }, children: ['slot_forwarder'], events: {}, style: {}, }, slot_forwarder: { type: 'slot', name: 'default', children: [], }, }, contexts: {}, formulas: {}, apis: {}, variables: {}, events: [], } // Component C: Consumer // Consumes Component A's context 'exposedValue' const componentC: Component = { name: 'ComponentC', contexts: { ComponentA: { formulas: ['exposedValue'], workflows: [], }, }, nodes: { root: { type: 'text', value: { type: 'path', path: ['Contexts', 'ComponentA', 'exposedValue'], }, }, }, formulas: {}, apis: {}, attributes: {}, variables: {}, events: [], } // Root component that renders B with a specific value and C in its slot const rootComponent: any = { name: 'RootComponent', route: { path: [], query: {}, info: { theme: { formula: { type: 'value', value: 'light' }, }, }, }, nodes: { root: { type: 'component', name: 'ComponentB', attrs: { val: { type: 'value', value: 'expected-value', }, }, children: ['consumer_node'], events: {}, style: {}, }, consumer_node: { type: 'component', name: 'ComponentC', attrs: {}, children: [], events: {}, style: {}, slot: 'default', }, }, contexts: {}, formulas: {}, apis: {}, attributes: {}, variables: {}, events: [], } const files = { components: { RootComponent: rootComponent, ComponentB: componentB, ComponentA: componentA, ComponentC: componentC, }, } as any const { html } = await renderPageBody({ evaluateComponentApis: () => ({}) as any, component: rootComponent as any, formulaContext: getPageFormulaContext({ component: rootComponent as any, branchName: 'main', req: new Request('http://localhost'), logErrors: true, files, }), env: {} as any, files, includedComponents: [rootComponent, componentB, componentA, componentC], projectId: 'test-project', req: {} as any, }) // Component C should render 'expected-value' expect(html).toContain('expected-value') }) test('should render ID formula with the expected order', async () => { const component: Component = { name: 'IdTestComponent', nodes: { root: { type: 'element', tag: 'div', attrs: { 'data-custom-id': { type: 'function', name: '@toddle/concatenate', arguments: [ { name: '0', formula: { type: 'value', value: 'custom' }, type: { type: 'Array \\| String \\| Object' }, }, { name: '0', formula: { type: 'function', name: '@toddle/Id', arguments: [], display_name: 'Id', }, type: { type: 'Array \\| String \\| Object' }, }, ], }, }, children: [], events: {}, style: {}, }, }, formulas: {}, apis: {}, attributes: {}, variables: {}, events: [], route: { path: [], query: {}, }, } const { html } = await renderPageBody({ evaluateComponentApis: () => ({}) as any, component: component as any, formulaContext: getPageFormulaContext({ component: component as any, branchName: 'main', req: new Request('http://localhost'), logErrors: true, files: { components: { IdTestComponent: component }, }, }), env: {} as any, files: { components: { IdTestComponent: component } } as any, includedComponents: [component], projectId: 'test-project', req: {} as any, }) // Expect the ID formula to render with the correct order (0 in this case since it's the first formula rendered) expect(html).toBe( `
`, ) }) })