import {
FileType,
InMemoryFileRecord,
ProjectPlugin,
ProjectPluginStructure,
ProjectUIDL,
UIDLElementNode,
} from '@teleporthq/teleport-types'
import { createNextWidgetProjectPlugins } from '../src/widgets'
const APP_CONTENT = `import './style.css'
export default function MyApp({ Component, pageProps }) {
return
}
`
const elementNode = (elementType: string, children: UIDLElementNode[] = []): UIDLElementNode => ({
type: 'element',
content: {
elementType,
children,
},
})
const buildStructure = (pageChildren: UIDLElementNode[]): ProjectPluginStructure => {
const files = new Map()
files.set('_app', {
path: ['pages'],
files: [{ name: '_app', fileType: FileType.JS, content: APP_CONTENT }],
})
const uidl = {
name: 'test',
root: {
name: 'App',
node: elementNode('container', pageChildren),
},
components: {},
} as unknown as ProjectUIDL
return {
uidl,
files,
dependencies: {},
devDependencies: {},
} as unknown as ProjectPluginStructure
}
const runAll = async (structure: ProjectPluginStructure): Promise => {
const plugins: ProjectPlugin[] = createNextWidgetProjectPlugins()
for (const plugin of plugins) {
await plugin.runAfter(structure)
}
}
interface WidgetCase {
elementType: string
fileKey: string
fileName: string
componentName: string
dependency?: string
version?: string
}
const WIDGET_CASES: WidgetCase[] = [
{
elementType: 'qrcode-node',
fileKey: 'tq-qrcode-component',
fileName: 'tq-qrcode',
componentName: 'TqQrCode',
dependency: 'qrcode',
version: '1.5.4',
},
{
elementType: 'barcode-node',
fileKey: 'tq-barcode-component',
fileName: 'tq-barcode',
componentName: 'TqBarcode',
dependency: 'jsbarcode',
version: '3.12.1',
},
{
elementType: 'signature-node',
fileKey: 'tq-signature-component',
fileName: 'tq-signature',
componentName: 'TqSignature',
dependency: 'signature_pad',
version: '5.0.4',
},
{
elementType: 'color-picker-node',
fileKey: 'tq-color-picker-component',
fileName: 'tq-color-picker',
componentName: 'TqColorPicker',
dependency: '@simonwep/pickr',
version: '1.9.1',
},
{
elementType: 'emoji-picker-node',
fileKey: 'tq-emoji-picker-component',
fileName: 'tq-emoji-picker',
componentName: 'TqEmojiPicker',
dependency: 'emoji-picker-element',
version: '1.26.3',
},
{
elementType: 'motion-node',
fileKey: 'tq-motion-component',
fileName: 'tq-motion',
componentName: 'TqMotion',
dependency: 'framer-motion',
version: '^11.18.0',
},
{
elementType: 'scroll-scene-node',
fileKey: 'tq-scroll-scene-component',
fileName: 'tq-scroll-scene',
componentName: 'TqScrollScene',
dependency: 'framer-motion',
version: '^11.18.0',
},
{
// Pure-DOM scrub player — deliberately no npm dependency.
elementType: 'scroll-video-node',
fileKey: 'tq-scroll-video-component',
fileName: 'tq-scroll-video',
componentName: 'TqScrollVideo',
},
]
describe('Next widget project plugins', () => {
it('are a no-op for projects without any widget primitive', async () => {
const structure = buildStructure([elementNode('container')])
await runAll(structure)
for (const widget of WIDGET_CASES) {
expect(structure.files.has(widget.fileKey)).toBe(false)
if (widget.dependency) {
expect(structure.dependencies[widget.dependency]).toBeUndefined()
}
}
expect(structure.files.has('tq-form-file-input-component')).toBe(false)
expect(structure.files.get('_app')?.files[0].content).toBe(APP_CONTENT)
})
it.each(WIDGET_CASES)(
'emits the $componentName wrapper + dependency when $elementType is used',
async (widget) => {
const structure = buildStructure([elementNode(widget.elementType)])
await runAll(structure)
const record = structure.files.get(widget.fileKey)
expect(record?.path).toEqual(['components'])
expect(record?.files[0].name).toBe(widget.fileName)
expect(record?.files[0].fileType).toBe(FileType.JS)
expect(record?.files[0].content).toContain(`const ${widget.componentName} =`)
expect(record?.files[0].content).toContain(`export default ${widget.componentName}`)
if (widget.dependency) {
expect(structure.dependencies[widget.dependency]).toBe(widget.version)
}
}
)
it('detects a widget primitive nested deep in the tree', async () => {
const structure = buildStructure([
elementNode('container', [elementNode('container', [elementNode('qrcode-node')])]),
])
await runAll(structure)
expect(structure.files.has('tq-qrcode-component')).toBe(true)
})
it('injects the Pickr theme stylesheet into _app only for the color picker', async () => {
const structure = buildStructure([elementNode('color-picker-node')])
await runAll(structure)
const appContent = structure.files.get('_app')?.files[0].content as string
expect(appContent).toContain("import '@simonwep/pickr/dist/themes/nano.min.css'")
})
it('does not inject any stylesheet for a widget without CSS (qr code)', async () => {
const structure = buildStructure([elementNode('qrcode-node')])
await runAll(structure)
const appContent = structure.files.get('_app')?.files[0].content as string
expect(appContent).toBe(APP_CONTENT)
})
it('bumps react/react-dom to ^18 only when the motion widget is used', async () => {
const withoutMotion = buildStructure([elementNode('qrcode-node')])
await runAll(withoutMotion)
expect(withoutMotion.dependencies.react).toBeUndefined()
expect(withoutMotion.dependencies['react-dom']).toBeUndefined()
const withMotion = buildStructure([elementNode('motion-node')])
await runAll(withMotion)
expect(withMotion.dependencies['framer-motion']).toBe('^11.18.0')
expect(withMotion.dependencies.react).toBe('^18.3.1')
expect(withMotion.dependencies['react-dom']).toBe('^18.3.1')
})
it('emits a root .npmrc (legacy-peer-deps) only when the React-18 bump runs', async () => {
const withoutMotion = buildStructure([elementNode('qrcode-node')])
await runAll(withoutMotion)
expect(withoutMotion.files.get('tq-motion-component-npmrc')).toBeUndefined()
const withMotion = buildStructure([elementNode('motion-node')])
await runAll(withMotion)
const npmrc = withMotion.files.get('tq-motion-component-npmrc')
expect(npmrc?.path).toEqual([])
expect(npmrc?.files[0].name).toBe('.npmrc')
expect(npmrc?.files[0].content).toContain('legacy-peer-deps=true')
})
it('generates a TqMotion wrapper that imports framer-motion and renders children', async () => {
const structure = buildStructure([elementNode('motion-node')])
await runAll(structure)
const content = structure.files.get('tq-motion-component')?.files[0].content as string
expect(content).toContain(
"import { motion, useInView, useMotionValueEvent, useReducedMotion, useScroll, useSpring } from 'framer-motion'"
)
expect(content).toContain(' {
const structure = buildStructure([elementNode('motion-node')])
await runAll(structure)
const content = structure.files.get('tq-motion-component')?.files[0].content as string
// in-view is no longer the bare whileInView (which could miss an already-in-view
// hero) — it is gated on useInView OR a forced reveal after a timed visibility check.
expect(content).toContain('useInView(ref')
expect(content).toContain('setForceReveal')
expect(content).toContain('getBoundingClientRect')
expect(content).toContain('animate: revealed ? toVars : fromVars')
expect(content).not.toContain('whileInView')
})
it('staggers the real repeated items (descends grid/array-mapper wrappers), not the block', async () => {
const structure = buildStructure([elementNode('motion-node')])
await runAll(structure)
const content = structure.files.get('tq-motion-component')?.files[0].content as string
// The descent helper + per-child wrapping must be generated so a wrapped grid
// cascades its cards rather than animating as one block.
expect(content).toContain('mapStaggerTargets')
expect(content).toContain('cloneElement')
expect(content).toContain('index * Number(stagger)')
})
it('uses next/dynamic ssr:false for the window-dependent wrappers', async () => {
const structure = buildStructure([
elementNode('color-picker-node'),
elementNode('emoji-picker-node'),
])
await runAll(structure)
expect(structure.files.get('tq-color-picker-component')?.files[0].content).toContain(
"import('@simonwep/pickr')"
)
expect(structure.files.get('tq-emoji-picker-component')?.files[0].content).toContain(
"import('emoji-picker-element')"
)
})
})
describe('form-file-input widget plugin (dependency-less wrapper)', () => {
const buildContent = async (): Promise<{
structure: ProjectPluginStructure
content: string
}> => {
const structure = buildStructure([elementNode('form-file-input-node')])
await runAll(structure)
const content = structure.files.get('tq-form-file-input-component')?.files[0].content as string
return { structure, content }
}
it('emits the TqFormFileInput wrapper without adding any npm dependency', async () => {
const { structure, content } = await buildContent()
const record = structure.files.get('tq-form-file-input-component')
expect(record?.path).toEqual(['components'])
expect(record?.files[0].name).toBe('tq-form-file-input')
expect(record?.files[0].fileType).toBe(FileType.JS)
expect(content).toContain('const TqFormFileInput =')
expect(content).toContain('export default TqFormFileInput')
// Pure DOM/FileReader wrapper: nothing lands in package.json and the
// react version stays whatever the template ships.
expect(Object.keys(structure.dependencies)).toEqual([])
})
it('reads picked files into PickedFile POJOs (browser-pick-files shape) via FileReader', async () => {
const { content } = await buildContent()
expect(content).toContain('readAsDataURL')
for (const field of ['name:', 'size:', 'type:', 'lastModified:', 'dataURL:']) {
expect(content).toContain(field)
}
// No upload at pick time — uploads belong to the form-submit workflow.
expect(content).not.toContain('fetch(')
})
it('renders dataURL previews with per-item remove and the form-file-input marker', async () => {
const { content } = await buildContent()
expect(content).toContain('data-thq="form-file-input"')
expect(content).toContain('src={file.dataURL}')
expect(content).toContain('removeAt')
expect(content).toContain('aria-label="Remove file"')
})
it('mirrors state into a nameless hidden input so form payloads never see the JSON', async () => {
const { content } = await buildContent()
const mirrorLine = content.split('\n').find((line) => line.includes('type="hidden"')) as string
expect(mirrorLine).toBeDefined()
expect(mirrorLine).not.toContain('name=')
// Commits reach the page state through the generated onChange contract
// (event.target.value = JSON array) plus a native change on the mirror.
expect(content).toContain('onChange({ target: { value: serialized } })')
expect(content).toContain("dispatchEvent(new Event('change', { bubbles: true }))")
})
})
describe('category widget plugins (dependency-less, data-driven wrappers)', () => {
const contentFor = async (elementType: string, fileKey: string): Promise => {
const structure = buildStructure([elementNode(elementType)])
await runAll(structure)
return structure.files.get(fileKey)?.files[0].content as string
}
it('emits the Category Menu wrapper with real products-list links, no npm dependency', async () => {
const structure = buildStructure([elementNode('categories-megamenu-node')])
await runAll(structure)
const content = structure.files.get('tq-categories-megamenu-component')?.files[0]
.content as string
expect(content).toContain('const TqCategoriesMegamenu =')
expect(content).toContain('export default TqCategoriesMegamenu')
expect(content).toContain('data-thq="categories-megamenu-node"')
// Every category is a real crawlable to products-list?=.
expect(content).toContain('productsListHref')
expect(content).toContain("'?' + paramKey + '=' + encodeURIComponent")
expect(content).toContain('buildCategoryTree')
expect(content).toContain('sanitizeSvg')
// Pure wrapper — nothing lands in package.json.
expect(Object.keys(structure.dependencies)).toEqual([])
})
it('emits the Category Filter wrapper with multi-select + shallow URL routing, no npm dependency', async () => {
const structure = buildStructure([elementNode('categories-filter-node')])
await runAll(structure)
const content = structure.files.get('tq-categories-filter-component')?.files[0]
.content as string
expect(content).toContain('const TqCategoriesFilter =')
expect(content).toContain('export default TqCategoriesFilter')
expect(content).toContain('data-thq="categories-filter-node"')
expect(content).toContain("import { useRouter } from 'next/router'")
// Multi-select: parent selects subtree, minimal antichain to the URL.
expect(content).toContain('toggleSelection')
expect(content).toContain('computeMinimalSelectedIds')
expect(content).toContain('expandSelection')
// Selection is written comma-joined to the URL via shallow routing.
expect(content).toContain('{ shallow: true }')
expect(content).toContain('router.replace')
expect(content).toContain('type="checkbox"')
expect(Object.keys(structure.dependencies)).toEqual([])
})
it('does not emit either category wrapper when the project omits it', async () => {
const structure = buildStructure([elementNode('container')])
await runAll(structure)
expect(structure.files.has('tq-categories-megamenu-component')).toBe(false)
expect(structure.files.has('tq-categories-filter-component')).toBe(false)
})
})