expo-router.test.ts 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536
  1. import { describe, it, expect, beforeAll, afterEach } from 'vitest';
  2. import * as fs from 'fs';
  3. import * as path from 'path';
  4. import * as os from 'os';
  5. import { CodeGraph } from '../src';
  6. import { initGrammars, loadAllGrammars } from '../src/extraction/grammars';
  7. import { buildScreens } from '../src/ui-server/api/screens';
  8. import {
  9. expoRouterResolver,
  10. routePathForFile,
  11. defaultExportName,
  12. readHrefArgument,
  13. readHrefViaLocal,
  14. normalizeHrefPath,
  15. } from '../src/resolution/frameworks/expo-router';
  16. import type { ResolutionContext, UnresolvedRef } from '../src/resolution/types';
  17. import type { Node } from '../src/types';
  18. // =============================================================================
  19. // Route paths from file names
  20. // =============================================================================
  21. describe('expo-router: routePathForFile', () => {
  22. it.each([
  23. ['app/index.tsx', '/'],
  24. ['src/app/index.tsx', '/'],
  25. ['src/app/object-detail.tsx', '/object-detail'],
  26. ['src/app/capture/index.tsx', '/capture'],
  27. ['src/app/capture/review/index.tsx', '/capture/review'],
  28. ['src/app/sheets/need-help.tsx', '/sheets/need-help'],
  29. ['src/app/item/[id].tsx', '/item/[id]'],
  30. ['src/app/docs/[...slug].tsx', '/docs/[...slug]'],
  31. ['src/app/(tabs)/home.tsx', '/home'],
  32. ['src/app/(auth)/(stack)/login.tsx', '/login'],
  33. ['src/app/+not-found.tsx', '/+not-found'],
  34. ['src/app/settings.ios.tsx', '/settings'],
  35. ['src/app/legacy.js', '/legacy'],
  36. ['apps/mobile/src/app/home.tsx', '/home'],
  37. ])('%s → %s', (file, route) => {
  38. expect(routePathForFile(file)).toBe(route);
  39. });
  40. it.each([
  41. 'src/app/_layout.tsx',
  42. 'src/app/(tabs)/_layout.tsx',
  43. 'src/app/+html.tsx',
  44. 'src/app/+native-intent.tsx',
  45. 'src/app/_private-helper.ts',
  46. 'src/app/home.test.tsx',
  47. 'src/app/__tests__/home.tsx',
  48. 'src/app/types.d.ts',
  49. 'src/app/styles.css',
  50. 'src/components/app/thing.tsx'.replace('components/app/', 'components/'), // no app dir
  51. 'src/appearance/theme.tsx',
  52. ])('%s is not a screen', (file) => {
  53. expect(routePathForFile(file)).toBeNull();
  54. });
  55. });
  56. // =============================================================================
  57. // Default export → screen name
  58. // =============================================================================
  59. describe('expo-router: defaultExportName', () => {
  60. it.each([
  61. ['export default function ObjectDetail() {}', 'ObjectDetail'],
  62. ['export default async function Screen() {}', 'Screen'],
  63. ['export default class Legacy extends React.Component {}', 'Legacy'],
  64. ['function Home() {}\nexport default Home', 'Home'],
  65. ['function Home() {}\nexport default Home;', 'Home'],
  66. ['export default memo(Home)', 'Home'],
  67. ['export default React.memo(Home)', 'Home'],
  68. ['export default observer(Home, opts)', 'Home'],
  69. ['export { Home as default }', 'Home'],
  70. ])('%s → %s', (src, name) => {
  71. expect(defaultExportName(src)?.name).toBe(name);
  72. });
  73. it('yields null for an anonymous default export', () => {
  74. expect(defaultExportName('export default () => null')).toBeNull();
  75. expect(defaultExportName('export default function () {}')).toBeNull();
  76. });
  77. });
  78. // =============================================================================
  79. // Reading the href argument
  80. // =============================================================================
  81. describe('expo-router: readHrefArgument', () => {
  82. const read = (src: string, method = 'push', line = 1, column = 0) =>
  83. readHrefArgument(src.split('\n'), line, column, method);
  84. it('reads a plain string', () => {
  85. expect(read("router.push('/capture-queue')")).toEqual({
  86. path: '/capture-queue',
  87. display: '/capture-queue',
  88. });
  89. });
  90. it('drops the query string and hash from the path but keeps them for display', () => {
  91. expect(read('router.push("/sheets/setup-guide?kind=lighting")')).toEqual({
  92. path: '/sheets/setup-guide',
  93. display: '/sheets/setup-guide?kind=lighting',
  94. });
  95. });
  96. it('reads a template literal, keeping the static prefix and marking holes', () => {
  97. const src =
  98. 'router.navigate(\n' +
  99. ' `/object-detail?detectionItem=${encodeParam(JSON.stringify(item))}${folderParam}` as any\n' +
  100. ')';
  101. expect(read(src, 'navigate')).toEqual({
  102. path: '/object-detail',
  103. display: '/object-detail?detectionItem=${…}${…}',
  104. });
  105. });
  106. it('keeps a hole that sits in the path itself', () => {
  107. const r = read('router.push(`/terms-of-service/term/${id}`)');
  108. expect(r?.display).toBe('/terms-of-service/term/${…}');
  109. expect(r?.path.startsWith('/terms-of-service/term/')).toBe(true);
  110. });
  111. it('reads pathname out of an Href object', () => {
  112. const src =
  113. "router.push({\n pathname: '/detection/result/[id]',\n params: { id: result.id },\n})";
  114. expect(read(src)).toEqual({
  115. path: '/detection/result/[id]',
  116. display: '/detection/result/[id]',
  117. });
  118. });
  119. it('reads both arms of a conditional argument', () => {
  120. const src =
  121. 'router.navigate(\n' +
  122. ' (folder.id\n' +
  123. " ? `/sheets/create-detection-item?folderId=${folder.id}`\n" +
  124. " : '/sheets/create-detection-item') as any\n" +
  125. ')';
  126. const r = read(src, 'navigate');
  127. expect(r?.path).toBe('/sheets/create-detection-item');
  128. expect(r?.display).toBe('/sheets/create-detection-item?folderId=${…}');
  129. expect(r?.alternate?.path).toBe('/sheets/create-detection-item');
  130. });
  131. it('returns null when one arm of a conditional is not a literal', () => {
  132. expect(read("router.push(ready ? '/home' : fallback)")).toBeNull();
  133. });
  134. it('reads only the first argument', () => {
  135. expect(read("router.push('/home', { withAnchor: true })")?.path).toBe('/home');
  136. });
  137. it('starts scanning at the column so an earlier call on the line is skipped', () => {
  138. const src = "list.push(x); router.push('/home')";
  139. expect(read(src, 'push', 1, src.indexOf('router'))?.path).toBe('/home');
  140. });
  141. it('returns null for a non-literal argument', () => {
  142. expect(read('router.push(href)')).toBeNull();
  143. expect(read('router.push(buildHref(item))')).toBeNull();
  144. expect(read('router.push({ pathname, params })')).toBeNull();
  145. expect(read('router.push()')).toBeNull();
  146. });
  147. it('does not run past the call: a later literal is not this call\'s argument', () => {
  148. expect(read("router.back()\nrouter.push('/home')", 'back')).toBeNull();
  149. });
  150. });
  151. describe('expo-router: readHrefViaLocal', () => {
  152. const viaLocal = (src: string, method = 'navigate') => {
  153. const lines = src.split('\n');
  154. const line = lines.findIndex((l) => l.includes(`.${method}(`)) + 1;
  155. return readHrefViaLocal(lines, line, 0, method, 1);
  156. };
  157. it('reads a local const assigned a literal', () => {
  158. expect(viaLocal("function f() {\n const href = '/home'\n router.navigate(href as any)\n}")?.path).toBe('/home');
  159. });
  160. it('reads a multi-line ternary initializer whose arms are literals', () => {
  161. const src =
  162. 'function f(params) {\n' +
  163. ' const href = params.length\n' +
  164. ' ? `/barcode-scan?${params.join("&")}`\n' +
  165. " : '/barcode-scan'\n" +
  166. ' if (options?.replace) {\n' +
  167. ' router.navigate(href as any)\n' +
  168. ' }\n}';
  169. const r = viaLocal(src);
  170. expect(r?.path).toBe('/barcode-scan');
  171. expect(r?.alternate?.path).toBe('/barcode-scan');
  172. });
  173. it('reads a typed declaration and an Href object initializer', () => {
  174. expect(viaLocal("const href: Href = '/home'\nrouter.navigate(href)")?.path).toBe('/home');
  175. expect(viaLocal("const href = { pathname: '/item/[id]', params: { id } }\nrouter.navigate(href)")?.path).toBe('/item/[id]');
  176. });
  177. it('refuses a computed initializer, a reassignment, and a non-identifier argument', () => {
  178. expect(viaLocal("const href = build()\nrouter.navigate(href)")).toBeNull();
  179. expect(viaLocal("const href = '/home'\nhref = other\nrouter.navigate(href)")).toBeNull();
  180. expect(viaLocal("router.navigate(a.b)")).toBeNull();
  181. });
  182. it('is not confused by ?. and ?? in an initializer', () => {
  183. expect(viaLocal("const href = options?.href ?? '/home'\nrouter.navigate(href)")).toBeNull();
  184. });
  185. });
  186. // =============================================================================
  187. // Href normalization
  188. // =============================================================================
  189. describe('expo-router: normalizeHrefPath', () => {
  190. it('strips trailing slash and group segments, decodes segments', () => {
  191. expect(normalizeHrefPath('/capture/', 'src/services/nav.ts')).toEqual(['capture']);
  192. expect(normalizeHrefPath('/(tabs)/home', 'src/services/nav.ts')).toEqual(['home']);
  193. expect(normalizeHrefPath('/a%20b', 'src/services/nav.ts')).toEqual(['a b']);
  194. expect(normalizeHrefPath('/', 'src/services/nav.ts')).toEqual([]);
  195. });
  196. it('resolves a relative href against the screen the call is in', () => {
  197. expect(normalizeHrefPath('./review', 'src/app/capture/index.tsx')).toEqual(['capture', 'review']);
  198. expect(normalizeHrefPath('review', 'src/app/capture/index.tsx')).toEqual(['capture', 'review']);
  199. expect(normalizeHrefPath('../home', 'src/app/capture/review.tsx')).toEqual(['home']);
  200. });
  201. it('refuses a relative href from a non-screen file', () => {
  202. expect(normalizeHrefPath('./review', 'src/services/nav.ts')).toBeNull();
  203. });
  204. });
  205. // =============================================================================
  206. // extract(): route node + screen ref
  207. // =============================================================================
  208. describe('expo-router: extract', () => {
  209. it('emits a route node named by path and a calls ref to the default export', () => {
  210. const src = "import React from 'react'\n\nexport default function ObjectDetail() {\n return null\n}\n";
  211. const { nodes, references } = expoRouterResolver.extract!('src/app/object-detail.tsx', src);
  212. expect(nodes).toHaveLength(1);
  213. expect(nodes[0]!.kind).toBe('route');
  214. expect(nodes[0]!.name).toBe('/object-detail');
  215. expect(nodes[0]!.language).toBe('tsx');
  216. expect(references).toHaveLength(1);
  217. expect(references[0]!.fromNodeId).toBe(nodes[0]!.id);
  218. expect(references[0]!.referenceName).toBe('ObjectDetail');
  219. expect(references[0]!.referenceKind).toBe('calls');
  220. expect(references[0]!.line).toBe(3);
  221. });
  222. it('emits nothing for a layout or a non-app file', () => {
  223. expect(expoRouterResolver.extract!('src/app/_layout.tsx', 'export default function L() {}')).toEqual({
  224. nodes: [],
  225. references: [],
  226. });
  227. expect(expoRouterResolver.extract!('src/services/nav.ts', 'export default function x() {}')).toEqual({
  228. nodes: [],
  229. references: [],
  230. });
  231. });
  232. });
  233. // =============================================================================
  234. // resolve(): a navigation call → the route node, as a navigates edge
  235. // =============================================================================
  236. describe('expo-router: resolve', () => {
  237. const route = (filePath: string): Node => expoRouterResolver.extract!(filePath, '').nodes[0]!;
  238. const routes = [
  239. route('src/app/index.tsx'),
  240. route('src/app/object-detail.tsx'),
  241. route('src/app/capture/index.tsx'),
  242. route('src/app/item/[id].tsx'),
  243. route('src/app/docs/[...slug].tsx'),
  244. ];
  245. const files: Record<string, string> = {
  246. 'src/services/nav.ts':
  247. "import { router } from 'expo-router'\n" +
  248. "export function openDetail(item) {\n" +
  249. ' router.navigate(\n' +
  250. ' `/object-detail?detectionItem=${encode(item)}` as any\n' +
  251. ' )\n' +
  252. '}\n' +
  253. "export function openItem(id) { router.push(`/item/${id}`) }\n" +
  254. "export function openDoc() { router.push({ pathname: '/docs/[...slug]', params: { slug: ['a'] } }) }\n" +
  255. "export function openCapture() { router.replace('/capture/') }\n" +
  256. "export function missing() { router.push('/nowhere') }\n" +
  257. "export function computed(h) { router.push(h) }\n" +
  258. "export function notNav(list) { list.push('/capture') }\n" +
  259. "export function fork(x) { router.push(x ? '/capture' : '/object-detail') }\n" +
  260. "export function sameScreen(x) { router.push(x ? '/capture?x=1' : '/capture/') }\n" +
  261. "export function viaWrapper() { safePush('/capture/') }\n",
  262. };
  263. const context = {
  264. getNodesByKind: (kind: Node['kind']) => (kind === 'route' ? routes : []),
  265. getProjectRoot: () => '/proj',
  266. readFile: (p: string) => files[p] ?? null,
  267. getFileLines: (p: string) => files[p]?.split('\n') ?? null,
  268. getAllFiles: () => Object.keys(files),
  269. getNodesInFile: () => [],
  270. getNodesByName: () => [],
  271. getNodesByQualifiedName: () => [],
  272. getNodesByLowerName: () => [],
  273. fileExists: () => true,
  274. getImportMappings: () => [],
  275. } as unknown as ResolutionContext;
  276. const ref = (referenceName: string, line: number, column = 0): UnresolvedRef => ({
  277. fromNodeId: 'function:src',
  278. referenceName,
  279. referenceKind: 'calls',
  280. line,
  281. column,
  282. filePath: 'src/services/nav.ts',
  283. language: 'typescript',
  284. });
  285. it('claims router navigation method names through the name pre-filter', () => {
  286. expect(expoRouterResolver.claimsReference!('router.push')).toBe(true);
  287. expect(expoRouterResolver.claimsReference!('nav.navigate')).toBe(true);
  288. expect(expoRouterResolver.claimsReference!('safePush')).toBe(true);
  289. expect(expoRouterResolver.claimsReference!('guardedNavigate')).toBe(true);
  290. expect(expoRouterResolver.claimsReference!('router.back')).toBe(false);
  291. expect(expoRouterResolver.claimsReference!('fetch')).toBe(false);
  292. expect(expoRouterResolver.claimsReference!('Push')).toBe(false);
  293. });
  294. it('binds a project wrapper named for the verb, remembering the wrapper', () => {
  295. const r = expoRouterResolver.resolve(ref('safePush', 15, 27), context);
  296. expect(r?.targetNodeId).toBe(routes[2]!.id);
  297. expect(r?.metadata).toEqual({ href: '/capture/', navMethod: 'push', via: 'safePush' });
  298. });
  299. it('binds a multi-line template href to its route as a navigates edge with the href', () => {
  300. const r = expoRouterResolver.resolve(ref('router.navigate', 3, 2), context);
  301. expect(r).not.toBeNull();
  302. expect(r!.targetNodeId).toBe(routes[1]!.id);
  303. expect(r!.edgeKind).toBe('navigates');
  304. expect(r!.resolvedBy).toBe('framework');
  305. expect(r!.metadata).toEqual({ href: '/object-detail?detectionItem=${…}', navMethod: 'navigate' });
  306. });
  307. it('matches an interpolated segment against a [param] route', () => {
  308. const r = expoRouterResolver.resolve(ref('router.push', 7, 29), context);
  309. expect(r?.targetNodeId).toBe(routes[3]!.id);
  310. });
  311. it('matches a pathname object against a catch-all route', () => {
  312. const r = expoRouterResolver.resolve(ref('router.push', 8, 28), context);
  313. expect(r?.targetNodeId).toBe(routes[4]!.id);
  314. });
  315. it('normalizes a trailing slash onto an index route', () => {
  316. const r = expoRouterResolver.resolve(ref('router.replace', 9, 32), context);
  317. expect(r?.targetNodeId).toBe(routes[2]!.id);
  318. });
  319. it('returns null for a path with no screen and for a computed href', () => {
  320. expect(expoRouterResolver.resolve(ref('router.push', 10, 28), context)).toBeNull();
  321. expect(expoRouterResolver.resolve(ref('router.push', 11, 30), context)).toBeNull();
  322. });
  323. it('gates on the string naming a real screen, not on the receiver being called router', () => {
  324. // `const nav = useRouter(); nav.push('/x')` must bind, so the receiver is
  325. // not consulted; a non-router `push` of a real screen path binds too.
  326. expect(expoRouterResolver.resolve(ref('list.push', 12, 32), context)?.targetNodeId).toBe(routes[2]!.id);
  327. });
  328. it('binds a conditional whose arms name the same screen, refuses one that forks', () => {
  329. expect(expoRouterResolver.resolve(ref('router.push', 14, 33), context)?.targetNodeId).toBe(routes[2]!.id);
  330. expect(expoRouterResolver.resolve(ref('router.push', 13, 27), context)).toBeNull();
  331. });
  332. it('ignores refs that are not calls or not JS/TS', () => {
  333. expect(
  334. expoRouterResolver.resolve({ ...ref('router.push', 9, 32), referenceKind: 'references' }, context)
  335. ).toBeNull();
  336. expect(expoRouterResolver.resolve({ ...ref('router.push', 9, 32), language: 'swift' }, context)).toBeNull();
  337. });
  338. });
  339. // =============================================================================
  340. // End to end: index a small Expo app and walk tap → screen
  341. // =============================================================================
  342. describe('expo-router: end-to-end', () => {
  343. beforeAll(async () => {
  344. await initGrammars();
  345. await loadAllGrammars();
  346. });
  347. let tmpDir: string | undefined;
  348. afterEach(() => {
  349. if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
  350. tmpDir = undefined;
  351. });
  352. function write(rel: string, content: string) {
  353. const full = path.join(tmpDir!, rel);
  354. fs.mkdirSync(path.dirname(full), { recursive: true });
  355. fs.writeFileSync(full, content);
  356. }
  357. it('connects a component tap to the screen it navigates to', async () => {
  358. tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-expo-router-'));
  359. write(
  360. 'package.json',
  361. JSON.stringify({ name: 'app', dependencies: { expo: '52', 'expo-router': '4', react: '18' } })
  362. );
  363. write('src/app/_layout.tsx', "export default function Layout() { return null }\n");
  364. write(
  365. 'src/app/index.tsx',
  366. "import { ItemCard } from '../components/item-card'\n" +
  367. 'export default function Home() {\n' +
  368. " return <ItemCard item={{ id: '1' }} collected />\n" +
  369. '}\n'
  370. );
  371. write(
  372. 'src/app/object-detail.tsx',
  373. "export default function ObjectDetail() {\n return null\n}\n"
  374. );
  375. write('src/app/item/[id].tsx', "export default function Item() { return null }\n");
  376. write(
  377. 'src/services/nav.ts',
  378. "import { router } from 'expo-router'\n" +
  379. 'export function openObjectDetail(item: { id: string }) {\n' +
  380. ' router.navigate(\n' +
  381. ' `/object-detail?detectionItem=${JSON.stringify(item)}` as any\n' +
  382. ' )\n' +
  383. '}\n' +
  384. 'export function openItem(id: string) {\n' +
  385. " router.push({ pathname: '/item/[id]', params: { id } })\n" +
  386. '}\n'
  387. );
  388. write(
  389. 'src/app/welcome.tsx',
  390. "export default function Welcome() { return null }\n"
  391. );
  392. write(
  393. 'src/services/post-login.ts',
  394. 'export const resolvePostLoginRoute = async (): Promise<string> => {\n' +
  395. " return (await seen()) ? '/' : '/welcome/'\n" +
  396. '}\n' +
  397. 'async function seen() { return true }\n' +
  398. "export function apiPath() { return '/api/users' }\n"
  399. );
  400. write(
  401. 'src/services/login.ts',
  402. "import { router } from 'expo-router'\n" +
  403. "import { resolvePostLoginRoute, apiPath } from './post-login'\n" +
  404. 'export async function finishLogin() {\n' +
  405. ' router.replace(await resolvePostLoginRoute())\n' +
  406. '}\n' +
  407. 'export function fetchUsers() { return fetch(apiPath()) }\n'
  408. );
  409. write(
  410. 'src/components/item-card.tsx',
  411. "import { openObjectDetail } from '../services/nav'\n" +
  412. 'export function ItemCard(props: { item: { id: string }; collected: boolean }) {\n' +
  413. ' const handlePress = () => {\n' +
  414. ' if (props.collected) openObjectDetail(props.item)\n' +
  415. ' }\n' +
  416. ' return handlePress\n' +
  417. '}\n'
  418. );
  419. const cg = CodeGraph.initSync(tmpDir);
  420. await cg.indexAll();
  421. const routes = cg.getNodesByKind('route');
  422. expect(routes.map((r) => r.name).sort()).toEqual(['/', '/item/[id]', '/object-detail', '/welcome']);
  423. const detailRoute = routes.find((r) => r.name === '/object-detail')!;
  424. // route → its screen component
  425. const screen = cg.getNodesByName('ObjectDetail').find((n) => n.kind !== 'route')!;
  426. expect(screen).toBeDefined();
  427. const toScreen = cg.getOutgoingEdges(detailRoute.id).find((e) => e.target === screen.id);
  428. expect(toScreen?.kind).toBe('calls');
  429. // navigation call → route, as a navigates edge that remembers the href
  430. const opener = cg.getNodesByName('openObjectDetail')[0]!;
  431. const nav = cg.getOutgoingEdges(opener.id).find((e) => e.target === detailRoute.id);
  432. expect(nav?.kind).toBe('navigates');
  433. expect(nav?.metadata?.href).toBe('/object-detail?detectionItem=${…}');
  434. expect(nav?.metadata?.navMethod).toBe('navigate');
  435. expect(nav?.metadata?.refKind).toBe('calls');
  436. // the pathname-object form binds the dynamic route
  437. const itemRoute = routes.find((r) => r.name === '/item/[id]')!;
  438. const openItem = cg.getNodesByName('openItem')[0]!;
  439. expect(cg.getOutgoingEdges(openItem.id).some((e) => e.target === itemRoute.id && e.kind === 'navigates')).toBe(true);
  440. // the route's callers are the navigators — what "who opens this screen" asks
  441. const callers = cg.getCallers(detailRoute.id);
  442. expect(callers.map((c) => c.node.name)).toContain('openObjectDetail');
  443. // `router.replace(await resolvePostLoginRoute())`: the helper's return
  444. // literals become heuristic navigates edges FROM THE HELPER, one per screen,
  445. // remembering the push site; the plain `calls` edge from the pusher closes
  446. // the chain. A helper nothing navigates with (`apiPath`) is never read.
  447. const helper = cg.getNodesByName('resolvePostLoginRoute')[0]!;
  448. const fromHelper = cg.getOutgoingEdges(helper.id).filter((e) => e.kind === 'navigates');
  449. expect(fromHelper.map((e) => routes.find((r) => r.id === e.target)?.name).sort()).toEqual(['/', '/welcome']);
  450. expect(fromHelper.every((e) => e.provenance === 'heuristic')).toBe(true);
  451. expect(fromHelper[0]!.metadata?.synthesizedBy).toBe('expo-router-return');
  452. expect(fromHelper[0]!.metadata?.registeredAt).toBe('src/services/login.ts:4');
  453. const finishLogin = cg.getNodesByName('finishLogin')[0]!;
  454. expect(cg.getOutgoingEdges(finishLogin.id).some((e) => e.target === helper.id && e.kind === 'calls')).toBe(true);
  455. const apiPath = cg.getNodesByName('apiPath')[0]!;
  456. expect(cg.getOutgoingEdges(apiPath.id).some((e) => e.kind === 'navigates')).toBe(false);
  457. // The Screens payload: the tap on ItemCard is attributed back to the Home
  458. // screen through the JSX-render hop, with the chain and its condition.
  459. const screens = await buildScreens(cg, tmpDir);
  460. expect(screens.routed).toBe(true);
  461. const home = screens.screens.find((s) => s.path === '/')!;
  462. expect(screens.entry).toBe(home.id);
  463. const detail = screens.screens.find((s) => s.path === '/object-detail')!;
  464. const tap = screens.links.find((l) => l.from === home.id && l.to === detail.id)!;
  465. expect(tap).toBeDefined();
  466. expect(tap.via.map((v) => v.name)).toEqual(['ItemCard', 'openObjectDetail']);
  467. expect(tap.when).toBe('props.collected');
  468. expect(tap.sites[0]!.href).toBe('/object-detail?detectionItem=${…}');
  469. // Navigation nothing on a screen reaches is an origin, not dropped: the
  470. // post-login helper, and `openItem`, which the fixture never calls.
  471. const fromOrigins = screens.links.filter((l) => l.fromOrigin);
  472. expect(fromOrigins.map((l) => screens.screens.find((s) => s.id === l.to)!.path).sort()).toEqual(['/', '/item/[id]', '/welcome']);
  473. expect(screens.origins.map((o) => o.node.name)).toEqual(['openItem', 'resolvePostLoginRoute']);
  474. expect(screens.dropped).toBe(0);
  475. cg.close();
  476. });
  477. });