feat(expo-router): add Expo Router support for screens and navigates

Introduce Expo Router integration: a new framework resolver, route-based screen nodes, and navigates edges, plus a /api/screens endpoint and a Screens UI view. Adds branch-guard-driven labeling of edges, resolution logic, and tests to cover extraction, resolution, and end-to-end flow. This enables CodeGraph UI to surface screens and transitions from Expo Router apps.
This commit is contained in:
Colby McHenry committed 2026-08-27 22:52:18 -05:00
1 parent ac9580544b
commit 70fd5fefc2
42 files changed
+4257 -33

No files matched your search

+240
View File
@@ -0,0 +1,240 @@
import { describe, it, expect, beforeAll, afterEach } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { CodeGraph } from '../src';
import { initGrammars } from '../src/extraction/grammars';
import { guardsInSource, guardLabel, supportsBranchGuards } from '../src/graph/branch-guards';
import { buildNode } from '../src/ui-server/api/node';
import { buildFlow } from '../src/ui-server/api/flow';
beforeAll(async () => {
await initGrammars();
});
/** Line (1-based) of the first line containing `needle`. */
function lineOf(src: string, needle: string): number {
const i = src.split('\n').findIndex((l) => l.includes(needle));
if (i < 0) throw new Error(`no line contains ${needle}`);
return i + 1;
}
async function labelAt(src: string, needle: string, language: 'tsx' | 'typescript' | 'swift' = 'tsx') {
const line = lineOf(src, needle);
const column = src.split('\n')[line - 1]!.indexOf(needle);
return guardLabel(await guardsInSource(src, language, line, column));
}
describe('branch guards: JS/TS', () => {
const handlePress = `
export function ItemCard(props) {
const handlePress = useCallback(() => {
if (isUploading) return
if (isCollected) {
openObjectDetail(item, folderName)
return
}
if (queueHasItems) {
handleAddToQueue()
return
}
handleStartCapture()
}, [])
return null
}
`;
it('reads an if branch and the early-return guards before it', async () => {
expect(await labelAt(handlePress, 'openObjectDetail(')).toBe('!isUploading && isCollected');
});
it('turns each earlier early-return into a negated guard, in source order', async () => {
expect(await labelAt(handlePress, 'handleAddToQueue(')).toBe('!isUploading && !isCollected && queueHasItems');
expect(await labelAt(handlePress, 'handleStartCapture(')).toBe('!isUploading && !isCollected && !queueHasItems');
});
it('does not climb past a function that is declared or assigned to a name', async () => {
const src = `
function outer() {
if (outerCond) {
const cb = () => {
if (inner) run()
}
function named() { if (deep) walk() }
}
}`;
expect(await labelAt(src, 'run()')).toBe('inner');
expect(await labelAt(src, 'walk()')).toBe('deep');
});
it('an inline callback inherits the conditions its definition sits under', async () => {
const src = `
function verify(total) {
if (selectedHasBarcode) {
if (total > 1) {
return { proceed: () => router.navigate('/barcode-matches') }
}
return { ok: true, proceed: () => captureObject(item) }
}
list.forEach((x) => { if (x.ok) keep(x) })
}`;
expect(await labelAt(src, 'captureObject(item)')).toBe('selectedHasBarcode && !(total > 1)');
expect(await labelAt(src, "router.navigate(")).toBe('selectedHasBarcode && total > 1');
expect(await labelAt(src, 'keep(x)')).toBe('!selectedHasBarcode && x.ok');
});
it('reads else, else-if, and the arms of a ternary', async () => {
const src = `
function f() {
if (a) { one() } else if (b) { two() } else { three() }
const x = ready ? go() : wait()
}`;
expect(await labelAt(src, 'one()')).toBe('a');
expect(await labelAt(src, 'two()')).toBe('!a && b');
expect(await labelAt(src, 'three()')).toBe('!a && !b');
expect(await labelAt(src, 'go()')).toBe('ready');
expect(await labelAt(src, 'wait()')).toBe('!ready');
});
it('reads switch cases, && / || short-circuits, and catch', async () => {
const src = `
function f() {
switch (mode) {
case 'verify': scan(); break
default: capture()
}
ok && fire()
ok || fallback()
try { risky() } catch (e) { report(e) }
}`;
expect(await labelAt(src, 'scan()')).toBe("mode === 'verify'");
expect(await labelAt(src, 'capture()')).toBe('mode: default');
expect(await labelAt(src, 'fire()')).toBe('ok');
expect(await labelAt(src, 'fallback()')).toBe('!ok');
expect(await labelAt(src, 'report(e)')).toBe('on error');
expect(await labelAt(src, 'risky()')).toBe('');
});
it('negates readably: a bare !x guard reads as x, a compound one is parenthesised', async () => {
const src = `
function f() {
if (!ready) return
if (a && b) { } else { alt() }
if (count > 0) go()
if (options?.verify !== false && (item.barcodes?.length ?? 0) > 0) verify()
}`;
expect(await labelAt(src, 'alt()')).toBe('ready && !(a && b)');
expect(await labelAt(src, 'go()')).toBe('ready && count > 0');
expect(await labelAt(src, 'verify()')).toBe('ready && options?.verify !== false && (item.barcodes?.length ?? 0) > 0');
});
it('a call inside a condition is not guarded by that condition', async () => {
const src = `
function f() {
if (isReady()) run()
}`;
expect(await labelAt(src, 'isReady()')).toBe('');
expect(await labelAt(src, 'run()')).toBe('isReady()');
});
it('an if whose body does not always exit is not a guard', async () => {
const src = `
function f() {
if (x) { log() }
go()
}`;
expect(await labelAt(src, 'go()')).toBe('');
});
it('caps a very long condition', async () => {
const cond = 'a'.repeat(120);
const src = `function f() {\n if (${cond}) go()\n}`;
const label = await labelAt(src, 'go()');
expect(label.length).toBeLessThan(90);
expect(label.endsWith('…')).toBe(true);
});
});
describe('branch guards: Swift', () => {
it('reads guard, if/else, ternary and switch', async () => {
const src = `
func decide() {
guard ready else { bail(); return }
if isCollected { open() } else if other { two() } else { close() }
let x = flag ? a() : b()
switch mode { case .verify: scan() default: capture() }
}`;
expect(await labelAt(src, 'bail()', 'swift')).toBe('!ready');
expect(await labelAt(src, 'open()', 'swift')).toBe('ready && isCollected');
expect(await labelAt(src, 'two()', 'swift')).toBe('ready && !isCollected && other');
expect(await labelAt(src, 'close()', 'swift')).toBe('ready && !isCollected && !other');
expect(await labelAt(src, 'a()', 'swift')).toBe('ready && flag');
expect(await labelAt(src, 'b()', 'swift')).toBe('ready && !flag');
expect(await labelAt(src, 'scan()', 'swift')).toBe('ready && mode == .verify');
expect(await labelAt(src, 'capture()', 'swift')).toBe('ready && mode: default');
});
it('joins multi-clause conditions and treats an early return as a guard', async () => {
const src = `
func f() {
if let item = current, item.count > 0 { use(item) }
if busy { return }
go()
}`;
expect(await labelAt(src, 'use(item)', 'swift')).toBe('let item = current, item.count > 0');
expect(await labelAt(src, 'go()', 'swift')).toBe('!busy');
});
});
describe('branch guards: unsupported', () => {
it('reports no guards for a language without rules', async () => {
expect(supportsBranchGuards('python')).toBe(false);
expect(await guardsInSource('def f():\n if x:\n go()\n', 'python', 3, 4)).toEqual([]);
});
});
describe('branch guards: on the wire', () => {
let dir: string | undefined;
afterEach(() => {
if (dir) fs.rmSync(dir, { recursive: true, force: true });
dir = undefined;
});
it('labels symbol-view rails and flow connectors with the call site\'s conditions', async () => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-when-'));
fs.mkdirSync(path.join(dir, 'src'));
fs.writeFileSync(
path.join(dir, 'src', 'app.ts'),
'export function helper() { return 1 }\n' +
'export function other() { return 2 }\n' +
'export function run(ready: boolean, busy: boolean) {\n' +
' if (busy) return\n' +
' if (ready) {\n' +
' helper()\n' +
' } else {\n' +
' other()\n' +
' }\n' +
'}\n'
);
const cg = CodeGraph.initSync(dir);
await cg.indexAll();
const run = cg.getNodesByName('run')[0]!;
const helper = cg.getNodesByName('helper')[0]!;
type Rel = { node: { name: string }; edges: Array<{ when?: string }> };
const view = (await buildNode(cg, dir, run.id)) as { outgoing: { items: Rel[] } };
const byName = new Map(view.outgoing.items.map((r) => [r.node.name, r]));
expect(byName.get('helper')?.edges[0]?.when).toBe('!busy && ready');
expect(byName.get('other')?.edges[0]?.when).toBe('!busy && !ready');
const callee = (await buildNode(cg, dir, helper.id)) as { incoming: { items: Rel[] } };
expect(callee.incoming.items.find((r) => r.node.name === 'run')?.edges[0]?.when).toBe('!busy && ready');
const flow = await buildFlow(cg, dir, new URLSearchParams('from=run&to=helper'));
const hop = flow.flows[0]!.hops[1]!;
expect(hop.edge?.when).toBe('!busy && ready');
expect(hop.edge?.label).toBe('calls · when !busy && ready');
cg.close();
});
});
+536
View File
@@ -0,0 +1,536 @@
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 {
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?.alternate?.path).toBe('/sheets/create-detection-item');
});
it('returns null when one arm of a conditional is not a literal', () => {
expect(read("router.push(ready ? '/home' : fallback)")).toBeNull();
});
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?.alternate?.path).toBe('/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, refuses one that forks', () => {
expect(expoRouterResolver.resolve(ref('router.push', 14, 33), context)?.targetNodeId).toBe(routes[2]!.id);
expect(expoRouterResolver.resolve(ref('router.push', 13, 27), context)).toBeNull();
});
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',
'export const resolvePostLoginRoute = async (): Promise<string> => {\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');
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);
cg.close();
});
});