@@ -214,3 +214,332 @@ describe('extractImports', () => {
214214 expect ( result ) . toContain ( 'Box' )
215215 } )
216216} )
217+
218+ describe ( 'extractCustomComponentImports' , ( ) => {
219+ it ( 'should extract custom component imports' , ( ) => {
220+ const result = codeModule . extractCustomComponentImports ( [
221+ [ 'MyComponent' , '<Box><CustomButton /><CustomInput /></Box>' ] ,
222+ ] )
223+ expect ( result ) . toContain ( 'CustomButton' )
224+ expect ( result ) . toContain ( 'CustomInput' )
225+ expect ( result ) . not . toContain ( 'Box' )
226+ expect ( result ) . not . toContain ( 'MyComponent' )
227+ } )
228+
229+ it ( 'should not include devup-ui components' , ( ) => {
230+ const result = codeModule . extractCustomComponentImports ( [
231+ [
232+ 'MyComponent' ,
233+ '<Box><Flex><VStack><CustomCard /></VStack></Flex></Box>' ,
234+ ] ,
235+ ] )
236+ expect ( result ) . toContain ( 'CustomCard' )
237+ expect ( result ) . not . toContain ( 'Box' )
238+ expect ( result ) . not . toContain ( 'Flex' )
239+ expect ( result ) . not . toContain ( 'VStack' )
240+ } )
241+
242+ it ( 'should return empty array when no custom components' , ( ) => {
243+ const result = codeModule . extractCustomComponentImports ( [
244+ [ 'MyComponent' , '<Box><Flex><Text>Hello</Text></Flex></Box>' ] ,
245+ ] )
246+ expect ( result ) . toEqual ( [ ] )
247+ } )
248+
249+ it ( 'should sort custom components alphabetically' , ( ) => {
250+ const result = codeModule . extractCustomComponentImports ( [
251+ [ 'MyComponent' , '<Box><Zebra /><Apple /><Mango /></Box>' ] ,
252+ ] )
253+ expect ( result ) . toEqual ( [ 'Apple' , 'Mango' , 'Zebra' ] )
254+ } )
255+
256+ it ( 'should handle multiple components with same custom component' , ( ) => {
257+ const result = codeModule . extractCustomComponentImports ( [
258+ [ 'ComponentA' , '<Box><SharedButton /></Box>' ] ,
259+ [ 'ComponentB' , '<Flex><SharedButton /></Flex>' ] ,
260+ ] )
261+ expect ( result ) . toEqual ( [ 'SharedButton' ] )
262+ } )
263+
264+ it ( 'should handle nested custom components' , ( ) => {
265+ const result = codeModule . extractCustomComponentImports ( [
266+ [ 'Parent' , '<Box><ChildA><ChildB><ChildC /></ChildB></ChildA></Box>' ] ,
267+ ] )
268+ expect ( result ) . toContain ( 'ChildA' )
269+ expect ( result ) . toContain ( 'ChildB' )
270+ expect ( result ) . toContain ( 'ChildC' )
271+ } )
272+ } )
273+
274+ describe ( 'registerCodegen with viewport variant' , ( ) => {
275+ type CodegenHandler = ( event : {
276+ node : SceneNode
277+ language : string
278+ } ) => Promise < unknown [ ] >
279+
280+ it ( 'should generate responsive component codes for COMPONENT_SET with viewport variant' , async ( ) => {
281+ let capturedHandler : CodegenHandler | null = null
282+
283+ const figmaMock = {
284+ editorType : 'dev' ,
285+ mode : 'codegen' ,
286+ command : 'noop' ,
287+ codegen : {
288+ on : ( _event : string , handler : CodegenHandler ) => {
289+ capturedHandler = handler
290+ } ,
291+ } ,
292+ closePlugin : mock ( ( ) => { } ) ,
293+ } as unknown as typeof figma
294+
295+ codeModule . registerCodegen ( figmaMock )
296+
297+ expect ( capturedHandler ) . not . toBeNull ( )
298+ if ( capturedHandler === null ) throw new Error ( 'Handler not captured' )
299+
300+ const componentSetNode = {
301+ type : 'COMPONENT_SET' ,
302+ name : 'ResponsiveButton' ,
303+ visible : true ,
304+ componentPropertyDefinitions : {
305+ viewport : {
306+ type : 'VARIANT' ,
307+ defaultValue : 'desktop' ,
308+ variantOptions : [ 'mobile' , 'desktop' ] ,
309+ } ,
310+ } ,
311+ children : [
312+ {
313+ type : 'COMPONENT' ,
314+ name : 'viewport=mobile' ,
315+ visible : true ,
316+ variantProperties : { viewport : 'mobile' } ,
317+ children : [ ] ,
318+ layoutMode : 'VERTICAL' ,
319+ width : 320 ,
320+ height : 100 ,
321+ } ,
322+ {
323+ type : 'COMPONENT' ,
324+ name : 'viewport=desktop' ,
325+ visible : true ,
326+ variantProperties : { viewport : 'desktop' } ,
327+ children : [ ] ,
328+ layoutMode : 'HORIZONTAL' ,
329+ width : 1200 ,
330+ height : 100 ,
331+ } ,
332+ ] ,
333+ defaultVariant : {
334+ type : 'COMPONENT' ,
335+ name : 'viewport=desktop' ,
336+ visible : true ,
337+ variantProperties : { viewport : 'desktop' } ,
338+ children : [ ] ,
339+ } ,
340+ } as unknown as SceneNode
341+
342+ const handler = capturedHandler as CodegenHandler
343+ const result = await handler ( {
344+ node : componentSetNode ,
345+ language : 'devup-ui' ,
346+ } )
347+
348+ // Should include responsive components result
349+ const responsiveResult = result . find (
350+ ( r : unknown ) =>
351+ typeof r === 'object' &&
352+ r !== null &&
353+ 'title' in r &&
354+ ( r as { title : string } ) . title . includes ( 'Responsive' ) ,
355+ )
356+ expect ( responsiveResult ) . toBeDefined ( )
357+ } )
358+
359+ it ( 'should generate responsive code for node with parent SECTION' , async ( ) => {
360+ let capturedHandler : CodegenHandler | null = null
361+
362+ const figmaMock = {
363+ editorType : 'dev' ,
364+ mode : 'codegen' ,
365+ command : 'noop' ,
366+ codegen : {
367+ on : ( _event : string , handler : CodegenHandler ) => {
368+ capturedHandler = handler
369+ } ,
370+ } ,
371+ closePlugin : mock ( ( ) => { } ) ,
372+ } as unknown as typeof figma
373+
374+ codeModule . registerCodegen ( figmaMock )
375+
376+ expect ( capturedHandler ) . not . toBeNull ( )
377+ if ( capturedHandler === null ) throw new Error ( 'Handler not captured' )
378+
379+ // Create a SECTION node with children of different widths
380+ const sectionNode = {
381+ type : 'SECTION' ,
382+ name : 'ResponsiveSection' ,
383+ visible : true ,
384+ children : [
385+ {
386+ type : 'FRAME' ,
387+ name : 'MobileFrame' ,
388+ visible : true ,
389+ width : 375 ,
390+ height : 200 ,
391+ children : [ ] ,
392+ layoutMode : 'VERTICAL' ,
393+ } ,
394+ {
395+ type : 'FRAME' ,
396+ name : 'DesktopFrame' ,
397+ visible : true ,
398+ width : 1200 ,
399+ height : 200 ,
400+ children : [ ] ,
401+ layoutMode : 'HORIZONTAL' ,
402+ } ,
403+ ] ,
404+ }
405+
406+ // Create a child node that has the SECTION as parent
407+ const childNode = {
408+ type : 'FRAME' ,
409+ name : 'ChildFrame' ,
410+ visible : true ,
411+ width : 375 ,
412+ height : 100 ,
413+ children : [ ] ,
414+ layoutMode : 'VERTICAL' ,
415+ parent : sectionNode ,
416+ } as unknown as SceneNode
417+
418+ const handler = capturedHandler as CodegenHandler
419+ const result = await handler ( {
420+ node : childNode ,
421+ language : 'devup-ui' ,
422+ } )
423+
424+ // Should include responsive result from parent section
425+ const responsiveResult = result . find (
426+ ( r : unknown ) =>
427+ typeof r === 'object' &&
428+ r !== null &&
429+ 'title' in r &&
430+ ( r as { title : string } ) . title . includes ( 'Responsive' ) ,
431+ )
432+ expect ( responsiveResult ) . toBeDefined ( )
433+ } )
434+
435+ it ( 'should generate CLI with custom component imports' , async ( ) => {
436+ let capturedHandler : CodegenHandler | null = null
437+
438+ const figmaMock = {
439+ editorType : 'dev' ,
440+ mode : 'codegen' ,
441+ command : 'noop' ,
442+ codegen : {
443+ on : ( _event : string , handler : CodegenHandler ) => {
444+ capturedHandler = handler
445+ } ,
446+ } ,
447+ closePlugin : mock ( ( ) => { } ) ,
448+ } as unknown as typeof figma
449+
450+ codeModule . registerCodegen ( figmaMock )
451+
452+ expect ( capturedHandler ) . not . toBeNull ( )
453+ if ( capturedHandler === null ) throw new Error ( 'Handler not captured' )
454+
455+ // Create a custom component that will be referenced
456+ const customComponent = {
457+ type : 'COMPONENT' ,
458+ name : 'CustomButton' ,
459+ visible : true ,
460+ children : [ ] ,
461+ width : 100 ,
462+ height : 40 ,
463+ layoutMode : 'NONE' ,
464+ componentPropertyDefinitions : { } ,
465+ parent : null ,
466+ }
467+
468+ // Create an INSTANCE referencing the custom component
469+ const instanceNode = {
470+ type : 'INSTANCE' ,
471+ name : 'CustomButton' ,
472+ visible : true ,
473+ width : 100 ,
474+ height : 40 ,
475+ getMainComponentAsync : async ( ) => customComponent ,
476+ }
477+
478+ // Create a COMPONENT that contains the INSTANCE
479+ const componentNode = {
480+ type : 'COMPONENT' ,
481+ name : 'MyComponent' ,
482+ visible : true ,
483+ children : [ instanceNode ] ,
484+ width : 200 ,
485+ height : 100 ,
486+ layoutMode : 'VERTICAL' ,
487+ componentPropertyDefinitions : { } ,
488+ reactions : [ ] ,
489+ parent : null ,
490+ } as unknown as SceneNode
491+
492+ // Create COMPONENT_SET parent with proper children array
493+ const componentSetNode = {
494+ type : 'COMPONENT_SET' ,
495+ name : 'MyComponentSet' ,
496+ componentPropertyDefinitions : { } ,
497+ children : [ componentNode ] ,
498+ defaultVariant : componentNode ,
499+ reactions : [ ] ,
500+ }
501+
502+ // Set parent reference
503+ ; ( componentNode as { parent : unknown } ) . parent = componentSetNode
504+
505+ const handler = capturedHandler as CodegenHandler
506+ const result = await handler ( {
507+ node : componentNode ,
508+ language : 'devup-ui' ,
509+ } )
510+
511+ // Should include CLI outputs
512+ const bashCLI = result . find (
513+ ( r : unknown ) =>
514+ typeof r === 'object' &&
515+ r !== null &&
516+ 'title' in r &&
517+ ( r as { title : string } ) . title . includes ( 'CLI (Bash)' ) ,
518+ )
519+ const powershellCLI = result . find (
520+ ( r : unknown ) =>
521+ typeof r === 'object' &&
522+ r !== null &&
523+ 'title' in r &&
524+ ( r as { title : string } ) . title . includes ( 'CLI (PowerShell)' ) ,
525+ )
526+
527+ expect ( bashCLI ) . toBeDefined ( )
528+ expect ( powershellCLI ) . toBeDefined ( )
529+
530+ // Check that custom component import is included (bash escapes quotes)
531+ const bashCode = ( bashCLI as { code : string } | undefined ) ?. code
532+ const powershellCode = ( powershellCLI as { code : string } | undefined ) ?. code
533+
534+ if ( bashCode ) {
535+ expect ( bashCode ) . toContain (
536+ "import { CustomButton } from \\'@/components/CustomButton\\'" ,
537+ )
538+ }
539+ if ( powershellCode ) {
540+ expect ( powershellCode ) . toContain (
541+ "import { CustomButton } from '@/components/CustomButton'" ,
542+ )
543+ }
544+ } )
545+ } )
0 commit comments