| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752 |
- import { describe, it, expect, beforeAll, afterEach } from 'vitest';
- import * as fs from 'fs';
- import * as path from 'path';
- import * as os from 'os';
- import { CodeGraph } from '../src';
- import { initGrammars, loadAllGrammars } from '../src/extraction/grammars';
- import { buildScreens } from '../src/ui-server/api/screens';
- import { buildSteps } from '../src/ui-server/api/steps';
- import {
- expoRouterResolver,
- routePathForFile,
- defaultExportName,
- readHrefArgument,
- readHrefViaLocal,
- normalizeHrefPath,
- } from '../src/resolution/frameworks/expo-router';
- import type { ResolutionContext, UnresolvedRef } from '../src/resolution/types';
- import type { Node } from '../src/types';
- // =============================================================================
- // Route paths from file names
- // =============================================================================
- describe('expo-router: routePathForFile', () => {
- it.each([
- ['app/index.tsx', '/'],
- ['src/app/index.tsx', '/'],
- ['src/app/object-detail.tsx', '/object-detail'],
- ['src/app/capture/index.tsx', '/capture'],
- ['src/app/capture/review/index.tsx', '/capture/review'],
- ['src/app/sheets/need-help.tsx', '/sheets/need-help'],
- ['src/app/item/[id].tsx', '/item/[id]'],
- ['src/app/docs/[...slug].tsx', '/docs/[...slug]'],
- ['src/app/(tabs)/home.tsx', '/home'],
- ['src/app/(auth)/(stack)/login.tsx', '/login'],
- ['src/app/+not-found.tsx', '/+not-found'],
- ['src/app/settings.ios.tsx', '/settings'],
- ['src/app/legacy.js', '/legacy'],
- ['apps/mobile/src/app/home.tsx', '/home'],
- ])('%s → %s', (file, route) => {
- expect(routePathForFile(file)).toBe(route);
- });
- it.each([
- 'src/app/_layout.tsx',
- 'src/app/(tabs)/_layout.tsx',
- 'src/app/+html.tsx',
- 'src/app/+native-intent.tsx',
- 'src/app/_private-helper.ts',
- 'src/app/home.test.tsx',
- 'src/app/__tests__/home.tsx',
- 'src/app/types.d.ts',
- 'src/app/styles.css',
- 'src/components/app/thing.tsx'.replace('components/app/', 'components/'), // no app dir
- 'src/appearance/theme.tsx',
- ])('%s is not a screen', (file) => {
- expect(routePathForFile(file)).toBeNull();
- });
- });
- // =============================================================================
- // Default export → screen name
- // =============================================================================
- describe('expo-router: defaultExportName', () => {
- it.each([
- ['export default function ObjectDetail() {}', 'ObjectDetail'],
- ['export default async function Screen() {}', 'Screen'],
- ['export default class Legacy extends React.Component {}', 'Legacy'],
- ['function Home() {}\nexport default Home', 'Home'],
- ['function Home() {}\nexport default Home;', 'Home'],
- ['export default memo(Home)', 'Home'],
- ['export default React.memo(Home)', 'Home'],
- ['export default observer(Home, opts)', 'Home'],
- ['export { Home as default }', 'Home'],
- ])('%s → %s', (src, name) => {
- expect(defaultExportName(src)?.name).toBe(name);
- });
- it('yields null for an anonymous default export', () => {
- expect(defaultExportName('export default () => null')).toBeNull();
- expect(defaultExportName('export default function () {}')).toBeNull();
- });
- });
- // =============================================================================
- // Reading the href argument
- // =============================================================================
- describe('expo-router: readHrefArgument', () => {
- const read = (src: string, method = 'push', line = 1, column = 0) =>
- readHrefArgument(src.split('\n'), line, column, method);
- it('reads a plain string', () => {
- expect(read("router.push('/capture-queue')")).toEqual({
- path: '/capture-queue',
- display: '/capture-queue',
- });
- });
- it('drops the query string and hash from the path but keeps them for display', () => {
- expect(read('router.push("/sheets/setup-guide?kind=lighting")')).toEqual({
- path: '/sheets/setup-guide',
- display: '/sheets/setup-guide?kind=lighting',
- });
- });
- it('reads a template literal, keeping the static prefix and marking holes', () => {
- const src =
- 'router.navigate(\n' +
- ' `/object-detail?detectionItem=${encodeParam(JSON.stringify(item))}${folderParam}` as any\n' +
- ')';
- expect(read(src, 'navigate')).toEqual({
- path: '/object-detail',
- display: '/object-detail?detectionItem=${…}${…}',
- });
- });
- it('keeps a hole that sits in the path itself', () => {
- const r = read('router.push(`/terms-of-service/term/${id}`)');
- expect(r?.display).toBe('/terms-of-service/term/${…}');
- expect(r?.path.startsWith('/terms-of-service/term/')).toBe(true);
- });
- it('reads pathname out of an Href object', () => {
- const src =
- "router.push({\n pathname: '/detection/result/[id]',\n params: { id: result.id },\n})";
- expect(read(src)).toEqual({
- path: '/detection/result/[id]',
- display: '/detection/result/[id]',
- });
- });
- it('reads both arms of a conditional argument', () => {
- const src =
- 'router.navigate(\n' +
- ' (folder.id\n' +
- " ? `/sheets/create-detection-item?folderId=${folder.id}`\n" +
- " : '/sheets/create-detection-item') as any\n" +
- ')';
- const r = read(src, 'navigate');
- expect(r?.path).toBe('/sheets/create-detection-item');
- expect(r?.display).toBe('/sheets/create-detection-item?folderId=${…}');
- expect(r?.alternates?.map((a) => a.path)).toEqual(['/sheets/create-detection-item']);
- });
- it('reads the literal arm when the other is not one — a place the code demonstrably goes', () => {
- // Both arms readable is a fork, and `pageForHref` resolves it only when
- // they name the same route. One arm readable is not a fork: `/home` is
- // somewhere this call goes, and reporting it is not a guess. Dropping it
- // cost every react-router app its post-login transition, which is written
- // `const redirect = search ? search.split('=')[1] : '/'`.
- const r = read("router.push(ready ? '/home' : fallback)");
- expect(r?.path).toBe('/home');
- expect(r?.alternate).toBeUndefined();
- expect(read("router.push(ready ? fallback : '/home')")?.path).toBe('/home');
- // Neither arm readable is still nothing.
- expect(read('router.push(ready ? a : b)')).toBeNull();
- });
- it('pairs the arms of a NESTED conditional, and keeps all three', () => {
- // Taking the first `:` split this between `keyword` and '/page', reading
- // '/page' — a real path, from the wrong arm of the wrong conditional. Paired
- // properly it is a paginator that goes to one of three places, and the
- // picture draws all three rather than none.
- const r = read("router.push(!isAdmin ? keyword ? '/search' : '/page' : '/admin')");
- expect([r?.path, ...(r?.alternates ?? []).map((a) => a.path)]).toEqual(['/search', '/page', '/admin']);
- });
- it('reads only the first argument', () => {
- expect(read("router.push('/home', { withAnchor: true })")?.path).toBe('/home');
- });
- it('starts scanning at the column so an earlier call on the line is skipped', () => {
- const src = "list.push(x); router.push('/home')";
- expect(read(src, 'push', 1, src.indexOf('router'))?.path).toBe('/home');
- });
- it('returns null for a non-literal argument', () => {
- expect(read('router.push(href)')).toBeNull();
- expect(read('router.push(buildHref(item))')).toBeNull();
- expect(read('router.push({ pathname, params })')).toBeNull();
- expect(read('router.push()')).toBeNull();
- });
- it('does not run past the call: a later literal is not this call\'s argument', () => {
- expect(read("router.back()\nrouter.push('/home')", 'back')).toBeNull();
- });
- });
- describe('expo-router: readHrefViaLocal', () => {
- const viaLocal = (src: string, method = 'navigate') => {
- const lines = src.split('\n');
- const line = lines.findIndex((l) => l.includes(`.${method}(`)) + 1;
- return readHrefViaLocal(lines, line, 0, method, 1);
- };
- it('reads a local const assigned a literal', () => {
- expect(viaLocal("function f() {\n const href = '/home'\n router.navigate(href as any)\n}")?.path).toBe('/home');
- });
- it('reads a multi-line ternary initializer whose arms are literals', () => {
- const src =
- 'function f(params) {\n' +
- ' const href = params.length\n' +
- ' ? `/barcode-scan?${params.join("&")}`\n' +
- " : '/barcode-scan'\n" +
- ' if (options?.replace) {\n' +
- ' router.navigate(href as any)\n' +
- ' }\n}';
- const r = viaLocal(src);
- expect(r?.path).toBe('/barcode-scan');
- expect(r?.alternates?.map((a) => a.path)).toEqual(['/barcode-scan']);
- });
- it('reads a typed declaration and an Href object initializer', () => {
- expect(viaLocal("const href: Href = '/home'\nrouter.navigate(href)")?.path).toBe('/home');
- expect(viaLocal("const href = { pathname: '/item/[id]', params: { id } }\nrouter.navigate(href)")?.path).toBe('/item/[id]');
- });
- it('refuses a computed initializer, a reassignment, and a non-identifier argument', () => {
- expect(viaLocal("const href = build()\nrouter.navigate(href)")).toBeNull();
- expect(viaLocal("const href = '/home'\nhref = other\nrouter.navigate(href)")).toBeNull();
- expect(viaLocal("router.navigate(a.b)")).toBeNull();
- });
- it('is not confused by ?. and ?? in an initializer', () => {
- expect(viaLocal("const href = options?.href ?? '/home'\nrouter.navigate(href)")).toBeNull();
- });
- });
- // =============================================================================
- // Href normalization
- // =============================================================================
- describe('expo-router: normalizeHrefPath', () => {
- it('strips trailing slash and group segments, decodes segments', () => {
- expect(normalizeHrefPath('/capture/', 'src/services/nav.ts')).toEqual(['capture']);
- expect(normalizeHrefPath('/(tabs)/home', 'src/services/nav.ts')).toEqual(['home']);
- expect(normalizeHrefPath('/a%20b', 'src/services/nav.ts')).toEqual(['a b']);
- expect(normalizeHrefPath('/', 'src/services/nav.ts')).toEqual([]);
- });
- it('resolves a relative href against the screen the call is in', () => {
- expect(normalizeHrefPath('./review', 'src/app/capture/index.tsx')).toEqual(['capture', 'review']);
- expect(normalizeHrefPath('review', 'src/app/capture/index.tsx')).toEqual(['capture', 'review']);
- expect(normalizeHrefPath('../home', 'src/app/capture/review.tsx')).toEqual(['home']);
- });
- it('refuses a relative href from a non-screen file', () => {
- expect(normalizeHrefPath('./review', 'src/services/nav.ts')).toBeNull();
- });
- });
- // =============================================================================
- // extract(): route node + screen ref
- // =============================================================================
- describe('expo-router: extract', () => {
- it('emits a route node named by path and a calls ref to the default export', () => {
- const src = "import React from 'react'\n\nexport default function ObjectDetail() {\n return null\n}\n";
- const { nodes, references } = expoRouterResolver.extract!('src/app/object-detail.tsx', src);
- expect(nodes).toHaveLength(1);
- expect(nodes[0]!.kind).toBe('route');
- expect(nodes[0]!.name).toBe('/object-detail');
- expect(nodes[0]!.language).toBe('tsx');
- expect(references).toHaveLength(1);
- expect(references[0]!.fromNodeId).toBe(nodes[0]!.id);
- expect(references[0]!.referenceName).toBe('ObjectDetail');
- expect(references[0]!.referenceKind).toBe('calls');
- expect(references[0]!.line).toBe(3);
- });
- it('emits nothing for a layout or a non-app file', () => {
- expect(expoRouterResolver.extract!('src/app/_layout.tsx', 'export default function L() {}')).toEqual({
- nodes: [],
- references: [],
- });
- expect(expoRouterResolver.extract!('src/services/nav.ts', 'export default function x() {}')).toEqual({
- nodes: [],
- references: [],
- });
- });
- });
- // =============================================================================
- // resolve(): a navigation call → the route node, as a navigates edge
- // =============================================================================
- describe('expo-router: resolve', () => {
- const route = (filePath: string): Node => expoRouterResolver.extract!(filePath, '').nodes[0]!;
- const routes = [
- route('src/app/index.tsx'),
- route('src/app/object-detail.tsx'),
- route('src/app/capture/index.tsx'),
- route('src/app/item/[id].tsx'),
- route('src/app/docs/[...slug].tsx'),
- ];
- const files: Record<string, string> = {
- 'src/services/nav.ts':
- "import { router } from 'expo-router'\n" +
- "export function openDetail(item) {\n" +
- ' router.navigate(\n' +
- ' `/object-detail?detectionItem=${encode(item)}` as any\n' +
- ' )\n' +
- '}\n' +
- "export function openItem(id) { router.push(`/item/${id}`) }\n" +
- "export function openDoc() { router.push({ pathname: '/docs/[...slug]', params: { slug: ['a'] } }) }\n" +
- "export function openCapture() { router.replace('/capture/') }\n" +
- "export function missing() { router.push('/nowhere') }\n" +
- "export function computed(h) { router.push(h) }\n" +
- "export function notNav(list) { list.push('/capture') }\n" +
- "export function fork(x) { router.push(x ? '/capture' : '/object-detail') }\n" +
- "export function sameScreen(x) { router.push(x ? '/capture?x=1' : '/capture/') }\n" +
- "export function viaWrapper() { safePush('/capture/') }\n",
- };
- const context = {
- getNodesByKind: (kind: Node['kind']) => (kind === 'route' ? routes : []),
- getProjectRoot: () => '/proj',
- readFile: (p: string) => files[p] ?? null,
- getFileLines: (p: string) => files[p]?.split('\n') ?? null,
- getAllFiles: () => Object.keys(files),
- getNodesInFile: () => [],
- getNodesByName: () => [],
- getNodesByQualifiedName: () => [],
- getNodesByLowerName: () => [],
- fileExists: () => true,
- getImportMappings: () => [],
- } as unknown as ResolutionContext;
- const ref = (referenceName: string, line: number, column = 0): UnresolvedRef => ({
- fromNodeId: 'function:src',
- referenceName,
- referenceKind: 'calls',
- line,
- column,
- filePath: 'src/services/nav.ts',
- language: 'typescript',
- });
- it('claims router navigation method names through the name pre-filter', () => {
- expect(expoRouterResolver.claimsReference!('router.push')).toBe(true);
- expect(expoRouterResolver.claimsReference!('nav.navigate')).toBe(true);
- expect(expoRouterResolver.claimsReference!('safePush')).toBe(true);
- expect(expoRouterResolver.claimsReference!('guardedNavigate')).toBe(true);
- expect(expoRouterResolver.claimsReference!('router.back')).toBe(false);
- expect(expoRouterResolver.claimsReference!('fetch')).toBe(false);
- expect(expoRouterResolver.claimsReference!('Push')).toBe(false);
- });
- it('binds a project wrapper named for the verb, remembering the wrapper', () => {
- const r = expoRouterResolver.resolve(ref('safePush', 15, 27), context);
- expect(r?.targetNodeId).toBe(routes[2]!.id);
- expect(r?.metadata).toEqual({ href: '/capture/', navMethod: 'push', via: 'safePush' });
- });
- it('binds a multi-line template href to its route as a navigates edge with the href', () => {
- const r = expoRouterResolver.resolve(ref('router.navigate', 3, 2), context);
- expect(r).not.toBeNull();
- expect(r!.targetNodeId).toBe(routes[1]!.id);
- expect(r!.edgeKind).toBe('navigates');
- expect(r!.resolvedBy).toBe('framework');
- expect(r!.metadata).toEqual({ href: '/object-detail?detectionItem=${…}', navMethod: 'navigate' });
- });
- it('matches an interpolated segment against a [param] route', () => {
- const r = expoRouterResolver.resolve(ref('router.push', 7, 29), context);
- expect(r?.targetNodeId).toBe(routes[3]!.id);
- });
- it('matches a pathname object against a catch-all route', () => {
- const r = expoRouterResolver.resolve(ref('router.push', 8, 28), context);
- expect(r?.targetNodeId).toBe(routes[4]!.id);
- });
- it('normalizes a trailing slash onto an index route', () => {
- const r = expoRouterResolver.resolve(ref('router.replace', 9, 32), context);
- expect(r?.targetNodeId).toBe(routes[2]!.id);
- });
- it('returns null for a path with no screen and for a computed href', () => {
- expect(expoRouterResolver.resolve(ref('router.push', 10, 28), context)).toBeNull();
- expect(expoRouterResolver.resolve(ref('router.push', 11, 30), context)).toBeNull();
- });
- it('gates on the string naming a real screen, not on the receiver being called router', () => {
- // `const nav = useRouter(); nav.push('/x')` must bind, so the receiver is
- // not consulted; a non-router `push` of a real screen path binds too.
- expect(expoRouterResolver.resolve(ref('list.push', 12, 32), context)?.targetNodeId).toBe(routes[2]!.id);
- });
- it('binds a conditional whose arms name the same screen, and draws BOTH when they fork', () => {
- const same = expoRouterResolver.resolve(ref('router.push', 14, 33), context);
- expect(same?.targetNodeId).toBe(routes[2]!.id);
- expect(same?.alsoTargets).toBeUndefined();
- // A fork reaches both screens, and each becomes an edge of its own.
- const forked = expoRouterResolver.resolve(ref('router.push', 13, 27), context);
- expect(forked).not.toBeNull();
- expect([forked!.targetNodeId, ...(forked!.alsoTargets ?? []).map((t) => t.targetNodeId)]).toHaveLength(2);
- });
- it('ignores refs that are not calls or not JS/TS', () => {
- expect(
- expoRouterResolver.resolve({ ...ref('router.push', 9, 32), referenceKind: 'references' }, context)
- ).toBeNull();
- expect(expoRouterResolver.resolve({ ...ref('router.push', 9, 32), language: 'swift' }, context)).toBeNull();
- });
- });
- // =============================================================================
- // End to end: index a small Expo app and walk tap → screen
- // =============================================================================
- describe('expo-router: end-to-end', () => {
- beforeAll(async () => {
- await initGrammars();
- await loadAllGrammars();
- });
- let tmpDir: string | undefined;
- afterEach(() => {
- if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
- tmpDir = undefined;
- });
- function write(rel: string, content: string) {
- const full = path.join(tmpDir!, rel);
- fs.mkdirSync(path.dirname(full), { recursive: true });
- fs.writeFileSync(full, content);
- }
- it('connects a component tap to the screen it navigates to', async () => {
- tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-expo-router-'));
- write(
- 'package.json',
- JSON.stringify({ name: 'app', dependencies: { expo: '52', 'expo-router': '4', react: '18' } })
- );
- write('src/app/_layout.tsx', "export default function Layout() { return null }\n");
- write(
- 'src/app/index.tsx',
- "import { ItemCard } from '../components/item-card'\n" +
- 'export default function Home() {\n' +
- " return <ItemCard item={{ id: '1' }} collected />\n" +
- '}\n'
- );
- write(
- 'src/app/object-detail.tsx',
- "export default function ObjectDetail() {\n return null\n}\n"
- );
- write('src/app/item/[id].tsx', "export default function Item() { return null }\n");
- write(
- 'src/services/nav.ts',
- "import { router } from 'expo-router'\n" +
- 'export function openObjectDetail(item: { id: string }) {\n' +
- ' router.navigate(\n' +
- ' `/object-detail?detectionItem=${JSON.stringify(item)}` as any\n' +
- ' )\n' +
- '}\n' +
- 'export function openItem(id: string) {\n' +
- " router.push({ pathname: '/item/[id]', params: { id } })\n" +
- '}\n'
- );
- write(
- 'src/app/welcome.tsx',
- "export default function Welcome() { return null }\n"
- );
- write(
- 'src/services/post-login.ts',
- // The literal-union return type is the trap: its routes are string
- // literals too, BEFORE the ternary — the scan must skip the signature
- // or the annotation's guardless positions win.
- 'export const resolvePostLoginRoute = async (): Promise<\n' +
- " '/welcome/' | '/'\n" +
- '> => {\n' +
- " return (await seen()) ? '/' : '/welcome/'\n" +
- '}\n' +
- 'async function seen() { return true }\n' +
- "export function apiPath() { return '/api/users' }\n"
- );
- write(
- 'src/services/login.ts',
- "import { router } from 'expo-router'\n" +
- "import { resolvePostLoginRoute, apiPath } from './post-login'\n" +
- 'export async function finishLogin() {\n' +
- ' router.replace(await resolvePostLoginRoute())\n' +
- '}\n' +
- 'export function fetchUsers() { return fetch(apiPath()) }\n'
- );
- write(
- 'src/components/item-card.tsx',
- "import { openObjectDetail } from '../services/nav'\n" +
- 'export function ItemCard(props: { item: { id: string }; collected: boolean }) {\n' +
- ' const handlePress = () => {\n' +
- ' if (props.collected) openObjectDetail(props.item)\n' +
- ' }\n' +
- ' return handlePress\n' +
- '}\n'
- );
- const cg = CodeGraph.initSync(tmpDir);
- await cg.indexAll();
- const routes = cg.getNodesByKind('route');
- expect(routes.map((r) => r.name).sort()).toEqual(['/', '/item/[id]', '/object-detail', '/welcome']);
- const detailRoute = routes.find((r) => r.name === '/object-detail')!;
- // route → its screen component
- const screen = cg.getNodesByName('ObjectDetail').find((n) => n.kind !== 'route')!;
- expect(screen).toBeDefined();
- const toScreen = cg.getOutgoingEdges(detailRoute.id).find((e) => e.target === screen.id);
- expect(toScreen?.kind).toBe('calls');
- // navigation call → route, as a navigates edge that remembers the href
- const opener = cg.getNodesByName('openObjectDetail')[0]!;
- const nav = cg.getOutgoingEdges(opener.id).find((e) => e.target === detailRoute.id);
- expect(nav?.kind).toBe('navigates');
- expect(nav?.metadata?.href).toBe('/object-detail?detectionItem=${…}');
- expect(nav?.metadata?.navMethod).toBe('navigate');
- expect(nav?.metadata?.refKind).toBe('calls');
- // the pathname-object form binds the dynamic route
- const itemRoute = routes.find((r) => r.name === '/item/[id]')!;
- const openItem = cg.getNodesByName('openItem')[0]!;
- expect(cg.getOutgoingEdges(openItem.id).some((e) => e.target === itemRoute.id && e.kind === 'navigates')).toBe(true);
- // the route's callers are the navigators — what "who opens this screen" asks
- const callers = cg.getCallers(detailRoute.id);
- expect(callers.map((c) => c.node.name)).toContain('openObjectDetail');
- // `router.replace(await resolvePostLoginRoute())`: the helper's return
- // literals become heuristic navigates edges FROM THE HELPER, one per screen,
- // remembering the push site; the plain `calls` edge from the pusher closes
- // the chain. A helper nothing navigates with (`apiPath`) is never read.
- const helper = cg.getNodesByName('resolvePostLoginRoute')[0]!;
- const fromHelper = cg.getOutgoingEdges(helper.id).filter((e) => e.kind === 'navigates');
- expect(fromHelper.map((e) => routes.find((r) => r.id === e.target)?.name).sort()).toEqual(['/', '/welcome']);
- expect(fromHelper.every((e) => e.provenance === 'heuristic')).toBe(true);
- expect(fromHelper[0]!.metadata?.synthesizedBy).toBe('expo-router-return');
- expect(fromHelper[0]!.metadata?.registeredAt).toBe('src/services/login.ts:4');
- // Each return literal carries its own POSITION: the two arms of
- // `return (await seen()) ? '/' : '/welcome/'` share a line, and only the
- // column lets the guard reader say which arm an edge is — without it both
- // navigations read as `always`.
- const welcomeEdge = fromHelper.find((e) => routes.find((r) => r.id === e.target)?.name === '/welcome')!;
- const rootEdge = fromHelper.find((e) => routes.find((r) => r.id === e.target)?.name === '/')!;
- expect(rootEdge.line).toBe(welcomeEdge.line);
- expect(typeof rootEdge.column).toBe('number');
- expect(welcomeEdge.column!).toBeGreaterThan(rootEdge.column!);
- const finishLogin = cg.getNodesByName('finishLogin')[0]!;
- expect(cg.getOutgoingEdges(finishLogin.id).some((e) => e.target === helper.id && e.kind === 'calls')).toBe(true);
- const apiPath = cg.getNodesByName('apiPath')[0]!;
- expect(cg.getOutgoingEdges(apiPath.id).some((e) => e.kind === 'navigates')).toBe(false);
- // The Screens payload: the tap on ItemCard is attributed back to the Home
- // screen through the JSX-render hop, with the chain and its condition.
- const screens = await buildScreens(cg, tmpDir);
- expect(screens.routed).toBe(true);
- const home = screens.screens.find((s) => s.path === '/')!;
- expect(screens.entry).toBe(home.id);
- const detail = screens.screens.find((s) => s.path === '/object-detail')!;
- const tap = screens.links.find((l) => l.from === home.id && l.to === detail.id)!;
- expect(tap).toBeDefined();
- expect(tap.via.map((v) => v.name)).toEqual(['ItemCard', 'openObjectDetail']);
- expect(tap.when).toBe('props.collected');
- expect(tap.sites[0]!.href).toBe('/object-detail?detectionItem=${…}');
- // Navigation nothing on a screen reaches is an origin, not dropped: the
- // post-login helper, and `openItem`, which the fixture never calls.
- const fromOrigins = screens.links.filter((l) => l.fromOrigin);
- expect(fromOrigins.map((l) => screens.screens.find((s) => s.id === l.to)!.path).sort()).toEqual(['/', '/item/[id]', '/welcome']);
- expect(screens.origins.map((o) => o.node.name)).toEqual(['openItem', 'resolvePostLoginRoute']);
- expect(screens.dropped).toBe(0);
- // The steps walk reads each arm's own condition off the literal's column:
- // where the app goes after login is a fork, not two `always`es.
- const steps = await buildSteps(cg, tmpDir, new URLSearchParams({ symbol: 'finishLogin' }));
- const stepByLabel = (label: string) => steps.steps.find((s) => s.label === label)!;
- const toRoot = steps.links.find((l) => l.to === stepByLabel('/').id)!;
- const toWelcome = steps.links.find((l) => l.to === stepByLabel('/welcome').id)!;
- expect(toRoot.when).toMatch(/await seen\(\)/);
- expect(toRoot.when).not.toMatch(/!/);
- expect(toWelcome.when).toMatch(/!\s*\(?\s*await seen\(\)/);
- // …and each site names the DECISION its condition belongs to, so the two
- // arms can be drawn as one choice rather than as two lines that happen to
- // read as each other's negation. Same branch, opposite arms, one `on`.
- const rootArm = toRoot.sites[0]!.decision!;
- const welcomeArm = toWelcome.sites[0]!.decision!;
- expect(rootArm.branch).toBe(welcomeArm.branch);
- expect(rootArm.branch).not.toBe('');
- expect(rootArm.form).toBe('ternary');
- expect(rootArm.on).toBe(welcomeArm.on);
- expect(rootArm.on).toMatch(/await seen\(\)/);
- expect(rootArm.on).not.toMatch(/^!/);
- expect(rootArm.not).toBeUndefined();
- expect(welcomeArm.not).toBe(true);
- expect(rootArm.arm).not.toBe(welcomeArm.arm);
- cg.close();
- });
- });
- // =============================================================================
- // The backward walk must not leave the app's own execution context
- // =============================================================================
- /**
- * A navigation written inside a component the graph can only reach BACKWARDS
- * through the native bridge belongs to the screen whose file it is written in
- * — not to whichever screen happened to start the round trip.
- *
- * The shape, from a real Expo app: `/capture` renders `ARCapturePage`, which
- * renders `memo(CaptureComponent)`; the `router.push` lives in an inline
- * listener inside `CaptureComponent`. Nothing points at `CaptureComponent`
- * except Swift emitters — the walk skips `file` nodes, and `memo(x)` leaves no
- * edge from the memo to the function — so before the guard the walk escaped
- * through `rn-event-channel`, came back down into `ReviewScreen` (which had
- * called the native module), and filed four of `/capture`'s navigations under
- * `/capture/review`, whose only remaining feed was itself. It also carried the
- * Swift guards home as conditions on a JavaScript navigation.
- */
- describe('expo-router screens: attribution stops at the native bridge', () => {
- let tmpDir: string | undefined;
- afterEach(() => {
- if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
- tmpDir = undefined;
- });
- function write(rel: string, content: string) {
- const full = path.join(tmpDir!, rel);
- fs.mkdirSync(path.dirname(full), { recursive: true });
- fs.writeFileSync(full, content);
- }
- it('files the push on the screen whose file holds it, not on the screen that started the round trip', async () => {
- tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-expo-bridge-'));
- write(
- 'package.json',
- JSON.stringify({
- name: 'app',
- dependencies: { expo: '52', 'expo-router': '4', react: '18', 'react-native': '0.76' },
- })
- );
- write('src/app/_layout.tsx', 'export default function Layout() { return null }\n');
- write('src/app/index.tsx', 'export default function Home() { return null }\n');
- // The Swift side: a method the JS calls, which ends in the event emit.
- write(
- 'ios/CaptureView.swift',
- `import Foundation
- @objc(CaptureView)
- class CaptureView: NSObject {
- @objc func startRetake() {
- CaptureEvents.shared.emitCaptureComplete()
- }
- }
- `
- );
- // The ObjC bridging shim, without which the JS side never reaches Swift.
- write(
- 'ios/CaptureView.m',
- `#import <React/RCTBridgeModule.h>
- @interface RCT_EXTERN_MODULE(CaptureView, NSObject)
- RCT_EXTERN_METHOD(startRetake)
- @end
- `
- );
- write(
- 'ios/CaptureEvents.swift',
- `import Foundation
- class CaptureEvents: RCTEventEmitter {
- func emitCaptureComplete() {
- guard Thread.isMainThread else { return }
- sendEvent(withName: "onCaptureComplete", body: nil)
- }
- }
- `
- );
- // /capture — the push is written HERE, in an inline listener inside a
- // sibling of the route's own default export.
- write(
- 'src/app/capture/index.tsx',
- `import { memo, useEffect } from 'react'
- import { router } from 'expo-router'
- const MemoizedCaptureComponent = memo(CaptureComponent)
- export default function ARCapturePage() {
- return <MemoizedCaptureComponent />
- }
- function CaptureComponent() {
- useEffect(() => {
- const sub = nativeEmitter.addListener('onCaptureComplete', (data) => {
- if (!isRetakeBatchActive) {
- router.push('/capture/review')
- }
- })
- return () => sub.remove()
- }, [])
- return null
- }
- `
- );
- // /capture/review — calls into the native module, which is what makes the
- // Swift emitter backwards-reachable from this screen.
- write(
- 'src/app/capture/review/index.tsx',
- `import { NativeModules } from 'react-native'
- const { CaptureView } = NativeModules
- export default function ReviewScreen() {
- function handleRetake() {
- CaptureView.startRetake()
- }
- return handleRetake
- }
- `
- );
- const cg = CodeGraph.initSync(tmpDir);
- await cg.indexAll();
- // The escape route the walk used to take really is in the graph.
- const capture = cg.getNodesByName('CaptureComponent').find((n) => n.kind !== 'route')!;
- const bridged = cg
- .getIncomingEdgesTo([capture.id], ['calls'])
- .filter((e) => (e.metadata as Record<string, unknown> | undefined)?.synthesizedBy === 'rn-event-channel');
- expect(bridged.length).toBeGreaterThan(0);
- // …and it really is a route back OUT to the other screen: without the
- // guard the walk runs handleRetake > startRetake > emitCaptureComplete >
- // CaptureComponent and lands the push on /capture/review.
- const startRetake = cg.getNodesByName('startRetake').find((n) => n.language === 'swift')!;
- expect(cg.getIncomingEdgesTo([startRetake.id], ['calls']).map((e) => cg.getNodesByIds([e.source]).get(e.source)?.name)).toContain(
- 'handleRetake'
- );
- const screens = await buildScreens(cg, tmpDir);
- const from = (path: string) => screens.screens.find((s) => s.path === path)!;
- const review = from('/capture/review');
- const links = screens.links.filter((l) => l.to === review.id);
- // One transition into /capture/review, and it comes from /capture.
- expect(links.map((l) => screens.screens.find((s) => s.id === l.from)?.path)).toEqual(['/capture']);
- // Written right there: no chain, and no Swift guard smuggled in.
- expect(links[0]!.via).toEqual([]);
- expect(links[0]!.when).toBe('!isRetakeBatchActive');
- expect(links[0]!.sites[0]!.file).toBe('src/app/capture/index.tsx');
- // …and /capture/review is not left feeding only itself.
- expect(screens.links.some((l) => l.from === review.id && l.to === review.id)).toBe(false);
- cg.close();
- });
- });
|