jsx-child-disambiguation.test.ts 5.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131
  1. import { describe, it, expect, beforeEach, afterEach } from 'vitest';
  2. import * as fs from 'node:fs';
  3. import * as path from 'node:path';
  4. import * as os from 'node:os';
  5. import { CodeGraph } from '../src';
  6. /**
  7. * A JSX tag names ONE component, and the file it is written in says which:
  8. * the one that file declares, or the one it imports. The synthesizer used to
  9. * take the first node of that name in the whole graph, which is a coin flip as
  10. * soon as a name repeats — and repeated component names are the norm, not the
  11. * exception (`Section`, `Picker`, `FrameCard`, one per feature folder).
  12. *
  13. * Getting it wrong costs twice: the parent gains an edge to a component it
  14. * never renders, and the component it DOES render is left with no caller, so
  15. * every walk back from that subtree — Screens' navigation attribution,
  16. * `getCallers`, an impact radius — dead-ends there. On an Expo app that showed
  17. * up as a navigation standing alone on the Screens tab with no screen behind
  18. * it, while the edge pointed at an unrelated card in another sheet.
  19. *
  20. * Each decoy here is deliberately named to sort BEFORE the right answer, so a
  21. * first-match resolver picks it and the test fails.
  22. */
  23. describe('JSX child disambiguation among same-named components', () => {
  24. let dir: string;
  25. let cg: any;
  26. beforeEach(() => {
  27. dir = fs.mkdtempSync(path.join(os.tmpdir(), 'jsx-child-'));
  28. fs.writeFileSync(path.join(dir, 'package.json'), '{"dependencies":{"react":"^18.0.0"}}');
  29. });
  30. afterEach(() => {
  31. cg?.close?.();
  32. fs.rmSync(dir, { recursive: true, force: true });
  33. });
  34. const write = (rel: string, body: string) => {
  35. const p = path.join(dir, rel);
  36. fs.mkdirSync(path.dirname(p), { recursive: true });
  37. fs.writeFileSync(p, body);
  38. };
  39. async function index() {
  40. cg = await CodeGraph.init(dir, { silent: true });
  41. await cg.indexAll();
  42. return (cg as any).db.db;
  43. }
  44. /** The files a jsx-render edge out of `parent` points into. */
  45. const rendersFrom = (db: any, parent: string): string[] =>
  46. db
  47. .prepare(
  48. `SELECT t.file_path AS f FROM edges e
  49. JOIN nodes s ON s.id = e.source
  50. JOIN nodes t ON t.id = e.target
  51. WHERE s.name = ? AND json_extract(e.metadata, '$.synthesizedBy') = 'jsx-render'
  52. ORDER BY f`
  53. )
  54. .all(parent)
  55. .map((r: any) => r.f);
  56. it('follows the import when the same name is declared in another file', async () => {
  57. write('a-decoy/card.tsx', `export function Card() { return <div>decoy</div>; }\n`);
  58. write('real/card.tsx', `export function Card() { return <div>real</div>; }\n`);
  59. write(
  60. 'grid.tsx',
  61. `import { Card } from './real/card';
  62. export function Grid() { return <div><Card /></div>; }
  63. `
  64. );
  65. const db = await index();
  66. expect(rendersFrom(db, 'Grid')).toEqual(['real/card.tsx']);
  67. });
  68. it('follows a tsconfig path alias the same way a relative import is followed', async () => {
  69. fs.writeFileSync(
  70. path.join(dir, 'tsconfig.json'),
  71. JSON.stringify({ compilerOptions: { baseUrl: '.', paths: { '@/*': ['src/*'] } } })
  72. );
  73. write('src/a-decoy/row.tsx', `export function Row() { return <li>decoy</li>; }\n`);
  74. write('src/real/row.tsx', `export function Row() { return <li>real</li>; }\n`);
  75. write(
  76. 'src/list.tsx',
  77. `import { Row } from '@/real/row';
  78. export function List() { return <ul><Row /></ul>; }
  79. `
  80. );
  81. const db = await index();
  82. expect(rendersFrom(db, 'List')).toEqual(['src/real/row.tsx']);
  83. });
  84. it('prefers a component declared in the same file over a same-named import elsewhere', async () => {
  85. write('a-decoy/pill.tsx', `export function Pill() { return <span>decoy</span>; }\n`);
  86. write(
  87. 'toolbar.tsx',
  88. `function Pill() { return <span>local</span>; }
  89. export function Toolbar() { return <div><Pill /></div>; }
  90. `
  91. );
  92. const db = await index();
  93. expect(rendersFrom(db, 'Toolbar')).toEqual(['toolbar.tsx']);
  94. });
  95. it('prefers a JS component over a same-named class in the app’s native half', async () => {
  96. // A React Native app: `<CaptureSettings/>` is the TS component the screen
  97. // imports, never the Swift type that happens to share its name.
  98. write('a-ios/CaptureSettings.swift', `class CaptureSettings {\n func sync() {}\n}\n`);
  99. write('ui/capture-settings.tsx', `export function CaptureSettings() { return <div />; }\n`);
  100. write(
  101. 'ui/overlay.tsx',
  102. `import { CaptureSettings } from './capture-settings';
  103. export function Overlay() { return <div><CaptureSettings /></div>; }
  104. `
  105. );
  106. const db = await index();
  107. expect(rendersFrom(db, 'Overlay')).toEqual(['ui/capture-settings.tsx']);
  108. });
  109. it('still links a name that appears exactly once', async () => {
  110. write('only/badge.tsx', `export function Badge() { return <b>1</b>; }\n`);
  111. write(
  112. 'header.tsx',
  113. `import { Badge } from './only/badge';
  114. export function Header() { return <h1><Badge /></h1>; }
  115. `
  116. );
  117. const db = await index();
  118. expect(rendersFrom(db, 'Header')).toEqual(['only/badge.tsx']);
  119. });
  120. });