Adds multi-arm navigation support: when a destination is produced by a conditional, every arm is now drawn as its own edge. Introduces helpers (hrefArms, destinationsForHref) and updates framework resolvers and edge creation to emit multiple navigates edges (via alsoTargets) instead of a single one. Also introduces per-app rooted route tables to avoid cross-app crossings, and updates various resolvers (React Router, TanStack Router, Vue Router, SvelteKit, Vue, and SvelteKit’s linker) and the UI to reflect multiple possible destinations. Tests and docs updated to reflect the new behavior, ensuring the Screens tab shows all possible navigation paths from conditional destinations. This makes navigation visualization more accurate for forked destinations.
561 lines
24 KiB
TypeScript
561 lines
24 KiB
TypeScript
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?.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',
|
|
'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();
|
|
});
|
|
});
|