import { describe, expect, it } from 'vitest'; import { accessPath, compile, jsxExpression, type ReactTargetOptions, react, replaceJsxChild } from '..'; import { parse } from '../ast'; import { anyTag, byTag, hasChild } from '../matchers'; import { addProp, childAsProp, replace, wrap } from '../react'; /** * The TS printer emits `` (space before slash) — collapse all whitespace * so test substrings can ignore that detail and focus on structure. */ const collapse = (s: string): string => s.replace(/\s+/g, ''); const compileReact = (source: string, options: ReactTargetOptions = {}) => compile(source, { config: { target: react(options) } }); describe('parse', () => { it('produces a TSX SourceFile with parent pointers set', () => { const { ast } = parse('const x = ;'); expect(ast.statements.length).toBe(1); expect(ast.statements[0]!.parent).toBe(ast); }); }); describe('compile (no transforms)', () => { it('round-trips a simple TSX module', async () => { const source = `import { Foo } from 'bar';\nexport function App() { return ; }\n`; const { code } = await compileReact(source); // Identifier and JSX preserved; quote/whitespace style is whatever the printer decides. expect(code).toContain('Foo'); expect(code).toContain('bar'); expect(collapse(code)).toContain(collapse(`return;`)); }); }); describe('compile (transformImports — bare-string rule)', () => { it('rewrites the module specifier and leaves identifiers untouched', async () => { const source = `import { PlayIcon } from '@videojs/icons/components';\nconst _x = PlayIcon;`; const { code } = await compileReact(source, { imports: { '@videojs/icons/components': '@videojs/icons/react' }, }); expect(code).toContain(`import { PlayIcon } from "@videojs/icons/react"`); }); it('leaves unrelated imports untouched', async () => { const source = `import { Other } from 'unrelated';\nimport { PlayIcon } from '@videojs/icons/components';\nconst _ = [Other, PlayIcon];`; const { code } = await compileReact(source, { imports: { '@videojs/icons/components': '@videojs/icons/react' }, }); expect(code).toMatch(/from ['"]unrelated['"]/); expect(code).toContain('PlayIcon'); }); }); describe('compile (transformImports — function rule)', () => { it('rewrites per-identifier source and bucket-merges by resolved target', async () => { const source = `import { Alpha, Beta } from '@fixture/components';\nconst _ = [Alpha, Beta];`; const { code } = await compileReact(source, { imports: { '@fixture/components': (name) => ({ source: `./ui/${name.toLowerCase()}`, name }), }, }); expect(code).toContain(`import { Alpha } from "./ui/alpha"`); expect(code).toContain(`import { Beta } from "./ui/beta"`); }); it('renames identifiers when the rule returns a different `name`', async () => { const source = `import { OldName } from 'src';\nconst _ = OldName;`; const { code } = await compileReact(source, { imports: { src: (_name) => ({ source: 'dst', name: 'NewName' }) }, }); expect(code).toContain(`import { NewName as OldName } from "dst"`); }); }); describe('replace', () => { it('substitutes a matched element with a new tag and adds the import', async () => { const source = `function App(){ return ; }`; const { code } = await compileReact(source, { transforms: [replace({ match: byTag('Old'), with: { source: 'pkg', name: 'New' } })], }); expect(code).toContain(` { const source = `function App(){ return ; }`; const { code } = await compileReact(source, { transforms: [replace({ match: byTag('Old'), with: { source: 'pkg', name: 'New' } })], }); expect(collapse(code)).toContain(collapse(``)); }); }); describe('wrap', () => { it('wraps a matched element with a new tag and adds the import', async () => { const source = `function App(){ return ; }`; const { code } = await compileReact(source, { transforms: [wrap({ match: byTag('Inner'), with: { source: 'pkg', name: 'Outer' } })], }); expect(collapse(code)).toContain(collapse(``)); expect(code).toContain(`import { Outer } from "pkg"`); }); }); describe('childAsProp', () => { it('lifts a single JSX-element child into the named prop', async () => { const source = `function App(){ return ; }`; const { code } = await compileReact(source, { transforms: [childAsProp({ match: byTag('T'), prop: 'render' })], }); expect(collapse(code)).toContain(collapse(`}/>`)); }); it('skips when prop is already set', async () => { const source = `function App(){ return }>; }`; const { code } = await compileReact(source, { transforms: [childAsProp({ match: byTag('T'), prop: 'render' })], }); expect(collapse(code)).toContain(collapse(``)); expect(collapse(code)).toContain(collapse(``)); }); it('skips when there are multiple JSX-element children', async () => { const source = `function App(){ return ; }`; const { code } = await compileReact(source, { transforms: [childAsProp({ match: byTag('T'), prop: 'render' })], }); expect(collapse(code)).toContain(collapse(``)); }); it('matches an array of tags via anyTag', async () => { const source = `function App(){ return <>; }`; const { code } = await compileReact(source, { transforms: [childAsProp({ match: anyTag(['T1', 'T2']), prop: 'render' })], }); const trimmed = collapse(code); expect(trimmed).toContain(collapse(`}/>`)); expect(trimmed).toContain(collapse(`}/>`)); }); }); describe('replaceJsxChild', () => { it('replaces matched JSX children with expression helpers', async () => { const source = `function App({ values }){ return ; }`; const { code } = await compileReact(source, { transforms: [ replaceJsxChild({ match: byTag('Token'), replace: (_node, factory) => jsxExpression(factory, accessPath(factory, 'values', 'poster-image')), }), ], }); expect(collapse(code)).toContain(collapse(`{values["poster-image"]}`)); }); }); describe('addProp', () => { it('emits a JSX value by default and adds the import', async () => { const source = `function App(){ return ; }`; const { code } = await compileReact(source, { transforms: [ addProp({ match: byTag('PlayButton'), prop: 'render', value: { source: './button', name: 'Button' } }), ], }); expect(collapse(code)).toContain(collapse(`}/>`)); expect(code).toContain(`import { Button } from "./button"`); }); it('emits a bare reference when kind is "ref"', async () => { const source = `function App(){ return ; }`; const { code } = await compileReact(source, { transforms: [ addProp({ match: byTag('PlayButton'), prop: 'as', value: { source: './button', name: 'Button', kind: 'ref' }, }), ], }); expect(collapse(code)).toContain(collapse(``)); }); it('skips elements where the prop is already set', async () => { const source = `function App(){ return }/>; }`; const { code } = await compileReact(source, { transforms: [ addProp({ match: byTag('PlayButton'), prop: 'render', value: { source: './button', name: 'Button' } }), ], }); expect(collapse(code)).toContain(collapse(``)); expect(code).not.toContain('import { Button }'); }); it('overwrites the existing prop when overwrite is true', async () => { const source = `function App(){ return }/>; }`; const { code } = await compileReact(source, { transforms: [ addProp({ match: byTag('PlayButton'), prop: 'render', overwrite: true, value: { source: './button', name: 'Button' }, }), ], }); expect(collapse(code)).toContain(collapse(`}/>`)); }); }); describe('matchers', () => { it('byTag supports dotted tags', async () => { const source = `function App(){ return ; }`; const { code } = await compileReact(source, { transforms: [replace({ match: byTag('Popover.Root'), with: { source: 'pkg', name: 'NewRoot' } })], }); expect(code).toContain(` { const source = `function App(){ return <>; }`; const isA1 = (node: import('../matchers').JsxElementLike) => { const attrs = 'attributes' in node ? node.attributes : (node as never); const props = (attrs as { properties?: ReadonlyArray<{ initializer?: { text?: string } }> }).properties ?? []; return props.some((p) => p.initializer?.text === '1'); }; const { code } = await compileReact(source, { transforms: [replace({ match: byTag('Foo', { when: isA1 }), with: { source: 'pkg', name: 'Bar' } })], }); expect(code).toContain(` { const source = `function App(){ return <>
; }`; const { code } = await compileReact(source, { transforms: [replace({ match: byTag('A', { when: hasChild(byTag('B')) }), with: { source: 'p', name: 'Z' } })], }); // First has direct child → replaced; second has only a nested → not replaced. expect(code).toContain('
'); }); it('hasChild with deep:true matches descendants', async () => { const source = `function App(){ return
; }`; const { code } = await compileReact(source, { transforms: [ replace({ match: byTag('A', { when: hasChild(byTag('B'), { deep: true }) }), with: { source: 'p', name: 'Z' }, }), ], }); expect(code).toContain(''); }); it('hasChild composes with byTag for nested shape checks', async () => { const source = `function App(){ return <>; }`; const { code } = await compileReact(source, { transforms: [ replace({ match: byTag('Outer', { when: hasChild(byTag('Inner', { when: hasChild(byTag('Target')) })) }), with: { source: 'p', name: 'Matched' }, }), ], }); // Only the Outer with Inner→Target gets replaced. expect(code).toContain(''); expect(code).toContain('