feat(expo-router): add Expo Router support for Screens and navigations and introduce Steps API

Introduce Expo Router integration with a new Screens view and API to surface screens and transitions, plus a new Steps API and UI to depict typed steps from anchors or symbols. Extend codegraph’s extraction and resolution to handle namespace objects (export default NAME, two-statement forms, and default bindings) and React hook bindings for handlers, improving accuracy of flows across JS ↔ native boundaries. Add Swift/React Native bridge receiver evidence (RCT_EXTERN_MODULE, RCT_EXTERN_METHOD) and related resolution logic, with tests covering namespace-object resolution, useCallback-driven handlers, and inline RN event listeners. Update UI to include a Steps tab and associated components (StepsView, StepNode, ScreenEdge) and wire navigation to expose steps-based exploration via /api/steps and UI routes. Documentation and changelog reflect the new Expo Router integration and steps surface capabilities.
This commit is contained in:
Colby McHenry
2026-08-28 09:46:50 -05:00
parent f0eafe31f9
commit 873f133c96
36 changed files with 3711 additions and 73 deletions
@@ -0,0 +1,78 @@
/**
* The default-export namespace object — `const UploadApi = { uploadARCapture };
* export default UploadApi` — and a call through it from another file. Two
* things have to hold for `handleZipComplete → uploadARCapture` to exist:
* the default import must find the constant the `export default` statement
* names (it is not exported at its declaration), and the member must resolve
* to the binding the shorthand property carries, through the object's own
* imports.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { CodeGraph } from '../src';
describe('namespace object default exports', () => {
let dir: string;
beforeEach(() => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-namespace-object-'));
});
afterEach(() => {
fs.rmSync(dir, { recursive: true, force: true });
});
function write(rel: string, content: string): void {
const full = path.join(dir, rel);
fs.mkdirSync(path.dirname(full), { recursive: true });
fs.writeFileSync(full, content);
}
it('resolves Api.member() to the function the shorthand property names', async () => {
write('package.json', '{"name":"app"}');
write('src/api/frames.ts', 'export async function uploadARCapture(uri: string) {\n return uri\n}\n');
write('src/api/folders.ts', 'export function createFolder(name: string) {\n return name\n}\n');
write(
'src/api/index.ts',
"import { uploadARCapture } from './frames'\n" +
"import { createFolder } from './folders'\n" +
'function localHelper() {\n return 1\n}\n' +
'const UploadApi = {\n uploadARCapture,\n makeFolder: createFolder,\n localHelper,\n}\n' +
'export default UploadApi\n'
);
write(
'src/hooks.ts',
"import UploadApi from './api'\n" +
'export function handleZipComplete(uri: string) {\n' +
' UploadApi.makeFolder(uri)\n' +
' UploadApi.localHelper()\n' +
' return UploadApi.uploadARCapture(uri)\n' +
'}\n'
);
const cg = await CodeGraph.init(dir, { silent: true });
await cg.indexAll();
const handler = cg.getNodesByName('handleZipComplete')[0]!;
const callees = cg.getCallees(handler.id).map((c) => c.node.name).sort();
cg.close();
expect(callees).toEqual(['createFolder', 'localHelper', 'uploadARCapture']);
});
it('a default import of a later-exported const finds that const, and a method inside it', async () => {
write('package.json', '{"name":"app"}');
write(
'src/store.ts',
'const useStore = {\n read() {\n return 1\n },\n}\nexport function unrelated() {\n return 2\n}\nexport default useStore\n'
);
write('src/use.ts', "import store from './store'\nexport function consume() {\n return store.read()\n}\n");
const cg = await CodeGraph.init(dir, { silent: true });
await cg.indexAll();
const consume = cg.getNodesByName('consume')[0]!;
const callees = cg.getCallees(consume.id).map((c) => c.node.name);
cg.close();
// Without the `export default NAME` binding the default import guessed the
// first exported function (`unrelated`); now it is the object, and the
// member resolves inside it.
expect(callees).toEqual(['read']);
});
});
+136
View File
@@ -0,0 +1,136 @@
/**
* React handler hooks name the function they wrap.
*
* `const handleSubmit = useCallback(() => {…}, [])` is how nearly every
* handler in a React / React Native component is written, and the arrow is
* anonymous only syntactically — the declarator is the name every
* `onPress={handleSubmit}` and `addListener('x', handleSubmit)` uses. Without
* a node the handler's calls attribute to the component and the trigger of a
* flow (the tap, the native event) has nothing to resolve to.
*/
import { describe, it, expect, beforeAll } from 'vitest';
import { extractFromSource } from '../src/extraction';
import { initGrammars, loadAllGrammars } from '../src/extraction/grammars';
beforeAll(async () => {
await initGrammars();
await loadAllGrammars();
});
const refsFrom = (result: ReturnType<typeof extractFromSource>, id: string) =>
result.unresolvedReferences.filter((r) => r.fromNodeId === id).map((r) => r.referenceName);
describe('useCallback handlers', () => {
it('extracts the wrapped arrow as a function named by the declarator, inside the component', () => {
const code = `
import { useCallback, useMemo, useEffect } from 'react'
import { finalize, upload, log } from './api'
export default function ReviewScreen() {
const handleApprove = useCallback(() => {
finalize()
}, [])
const handleZip = useCallback(async (data: { uri: string }) => {
await upload(data.uri)
}, [])
const total = useMemo(() => 1 + 1, [])
useEffect(() => {
log('mounted')
}, [])
return <Button onPress={handleApprove} />
}
`;
const result = extractFromSource('src/app/review.tsx', code);
const fns = result.nodes.filter((n) => n.kind === 'function');
const names = fns.map((n) => n.name);
expect(names).toEqual(expect.arrayContaining(['ReviewScreen', 'handleApprove', 'handleZip']));
// A memo is a value and an effect is anonymous: neither becomes a function.
expect(names).not.toContain('total');
expect(names.filter((n) => n === '<anonymous>')).toEqual([]);
const screen = fns.find((n) => n.name === 'ReviewScreen')!;
const handleZip = fns.find((n) => n.name === 'handleZip')!;
expect(handleZip.qualifiedName).toBe('ReviewScreen::handleZip');
expect(handleZip.startLine).toBe(8);
// The handler's calls are its own; the component keeps only what it does itself.
expect(refsFrom(result, handleZip.id)).toContain('upload');
expect(refsFrom(result, screen.id)).not.toContain('upload');
expect(refsFrom(result, screen.id)).toContain('log');
// Containment: the component contains its handlers.
expect(
result.edges.some((e) => e.kind === 'contains' && e.source === screen.id && e.target === handleZip.id)
).toBe(true);
// `onPress={handleApprove}` is a function-as-value site: the tap's handler
// is referenced from the component, which is how a Steps picture knows
// the handler is a trigger.
const handleApprove = fns.find((n) => n.name === 'handleApprove')!;
expect(
result.unresolvedReferences.some(
(r) => r.fromNodeId === screen.id && r.referenceKind === 'function_ref' && r.referenceName === 'handleApprove'
)
).toBe(true);
expect(handleApprove.startLine).toBe(5);
});
it('accepts React.useCallback, function expressions, and useEffectEvent', () => {
const code = `
import React from 'react'
export function Screen() {
const onOpen = React.useCallback(function () { open() }, [])
const onLog = useEffectEvent((url: string) => { track(url) })
return null
}
`;
const result = extractFromSource('src/screen.tsx', code);
const names = result.nodes.filter((n) => n.kind === 'function').map((n) => n.name);
expect(names).toEqual(expect.arrayContaining(['Screen', 'onOpen', 'onLog']));
});
it('leaves a hook whose first argument is not the bound function alone', () => {
const code = `
export function Screen() {
const value = useState(() => compute())
const cb = useCallback(existingHandler, [])
const [x] = useReducer((s) => s, 0)
return null
}
`;
const result = extractFromSource('src/screen.tsx', code);
const names = result.nodes.filter((n) => n.kind === 'function').map((n) => n.name);
expect(names).toEqual(['Screen']);
});
it('a handler a hook returns in an object is a function-as-value of the hook', () => {
const code = `
import { useCallback } from 'react'
export function useReviewHandlers() {
const handleApprove = useCallback(() => { finalize() }, [])
const handleRetake = useCallback(() => { retake() }, [])
const count = 1
return { handleApprove, handleRetake, count, extra: helper }
}
function helper() {}
`;
const result = extractFromSource('src/hooks.ts', code);
const hook = result.nodes.find((n) => n.name === 'useReviewHandlers')!;
const fnRefs = result.unresolvedReferences
.filter((r) => r.fromNodeId === hook.id && r.referenceKind === 'function_ref')
.map((r) => r.referenceName)
.sort();
// `count` is a value, not a function defined here: gated out.
expect(fnRefs).toEqual(['handleApprove', 'handleRetake', 'helper']);
});
it('does nothing outside the JS family', () => {
const code = `
func screen() {
let handle = useCallback({ () in finalize() }, [])
}
`;
const result = extractFromSource('Screen.swift', code);
const names = result.nodes.filter((n) => n.kind === 'function').map((n) => n.name);
expect(names).toEqual(['screen']);
});
});
+127
View File
@@ -340,3 +340,130 @@ describe('React Native cross-platform pairing — end to end', () => {
expect(pair.c).toBeGreaterThanOrEqual(2); // java<->objc both directions
});
});
// =============================================================================
// Swift modules via RCT_EXTERN_MODULE, and receiver evidence
// =============================================================================
import { parseObjcRNExterns, collectNativeModuleAliases } from '../src/resolution/frameworks/react-native';
function swiftMethod(name: string, owner: string, filePath: string, startLine: number): Node {
return {
id: `swift:${filePath}:${name}:${startLine}`,
kind: 'method',
name,
qualifiedName: `${owner}::${name}`,
filePath,
language: 'swift',
startLine,
endLine: startLine + 4,
startColumn: 0,
endColumn: 0,
updatedAt: Date.now(),
} as Node;
}
const SHIM = `
#import <React/RCTBridgeModule.h>
#import <React/RCTViewManager.h>
@interface RCT_EXTERN_MODULE(CaptureView, RCTViewManager)
RCT_EXTERN_METHOD(syncSettings:(NSDictionary *)settings)
RCT_EXTERN_METHOD(finalizeCaptureSession)
RCT_EXTERN_REMAP_METHOD(pause, pauseInferenceNow)
@end
`;
describe('React Native bridge resolver — RCT_EXTERN (Swift) modules', () => {
const finalize = swiftMethod('finalizeCaptureSession', 'CaptureView', 'ios/CaptureView+ReactBridge.swift', 26);
const sync = swiftMethod('syncSettings', 'CaptureView', 'ios/CaptureView.swift', 40);
const pause = swiftMethod('pauseInferenceNow', 'CaptureView', 'ios/CaptureView.swift', 60);
// Same method name on another Swift type — never the bridge target.
const decoy = swiftMethod('syncSettings', 'CaptureSettings', 'ios/CaptureSettings.swift', 12);
const files = {
'package.json': '{"name":"app","dependencies":{"react-native":"0.76"}}',
'ios/CaptureView.m': SHIM,
'src/components/capture/capture-view.tsx':
"import { NativeModules, NativeEventEmitter } from 'react-native'\n" +
'export const { CaptureEvents } = NativeModules\n' +
'export const captureView = NativeModules.CaptureView\n',
};
const ctx = makeContext([finalize, sync, pause, decoy], files);
it('parses the shim: module, class, first keyword, remap', () => {
expect(parseObjcRNExterns(SHIM).map((e) => [e.moduleName, e.className, e.jsName, e.nativeSelectorFirstKw])).toEqual([
['CaptureView', 'CaptureView', 'syncSettings', 'syncSettings'],
['CaptureView', 'CaptureView', 'finalizeCaptureSession', 'finalizeCaptureSession'],
['CaptureView', 'CaptureView', 'pause', 'pauseInferenceNow'],
]);
const remapped = parseObjcRNExterns('@interface RCT_EXTERN_REMAP_MODULE(Camera, CameraModule, NSObject)\nRCT_EXTERN_METHOD(snap)');
expect(remapped).toEqual([
{ moduleName: 'Camera', className: 'CameraModule', jsName: 'snap', nativeSelectorFirstKw: 'snap', line: 2 },
]);
});
it('collects the local names bound to NativeModules', () => {
const aliases = new Map<string, string>();
const ambiguous = new Set<string>();
collectNativeModuleAliases(
'const captureView = NativeModules.CaptureView\n' +
'export const { CaptureEvents, Geo: geolocation } = NativeModules\n' +
'let typed: Spec = NativeModules.Typed\n',
aliases,
ambiguous
);
// Direct bindings first (one pass), then the destructured ones.
expect([...aliases]).toEqual([
['captureView', 'CaptureView'],
['typed', 'Typed'],
['CaptureEvents', 'CaptureEvents'],
['geolocation', 'Geo'],
]);
// The same name bound to two modules is dropped, not guessed.
collectNativeModuleAliases('const captureView = NativeModules.Other', aliases, ambiguous);
expect(aliases.has('captureView')).toBe(false);
expect(ambiguous.has('captureView')).toBe(true);
});
it('detects a project from the RCT_EXTERN_MODULE marker alone', () => {
expect(reactNativeBridgeResolver.detect(makeContext([], { 'ios/CaptureView.m': SHIM }))).toBe(true);
});
it('resolves an aliased receiver to the Swift method of the named class at 0.95', () => {
const r = reactNativeBridgeResolver.resolve(
ref('captureView.finalizeCaptureSession', 'tsx', 'src/hooks/use-review-handlers.ts'),
ctx
);
expect(r?.targetNodeId).toBe(finalize.id);
expect(r?.confidence).toBe(0.95);
expect(r?.metadata).toEqual({ bridge: 'react-native', module: 'CaptureView' });
});
it('resolves NativeModules.Module.method the same way, class-scoped past a same-named decoy', () => {
const r = reactNativeBridgeResolver.resolve(ref('NativeModules.CaptureView.syncSettings', 'tsx', 'src/a.tsx'), ctx);
expect(r?.targetNodeId).toBe(sync.id);
expect(r?.confidence).toBe(0.95);
});
it('follows RCT_EXTERN_REMAP_METHOD to the Swift implementation under the JS name', () => {
const r = reactNativeBridgeResolver.resolve(ref('captureView.pause', 'tsx', 'src/a.tsx'), ctx);
expect(r?.targetNodeId).toBe(pause.id);
});
it('keeps a bare method name at the by-name confidence, and refuses a named module that lacks the method', () => {
const bare = reactNativeBridgeResolver.resolve(ref('syncSettings', 'tsx', 'src/a.tsx'), ctx);
expect(bare?.targetNodeId).toBe(sync.id);
expect(bare?.confidence).toBe(0.6);
expect(reactNativeBridgeResolver.resolve(ref('captureView.nothingHere', 'tsx', 'src/a.tsx'), ctx)).toBeNull();
// A receiver that is NOT a module alias falls back to by-name evidence.
const other = reactNativeBridgeResolver.resolve(ref('somethingElse.syncSettings', 'tsx', 'src/a.tsx'), ctx);
expect(other?.confidence).toBe(0.6);
});
it('never redirects a native caller', () => {
expect(reactNativeBridgeResolver.resolve(ref('captureView.finalizeCaptureSession', 'swift', 'ios/x.swift'), ctx)).toBeNull();
});
});
+68
View File
@@ -158,3 +158,71 @@ export function onMessage(listener: (m: any) => void) {
expect(rows[0].target_name).toBe('onBattery');
});
});
describe('RN event channel synthesizer — inline listeners', () => {
let dir: string;
beforeEach(() => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'rn-event-inline-'));
});
afterEach(() => {
fs.rmSync(dir, { recursive: true, force: true });
});
it('attributes an inline arrow listener to the enclosing component, from a Swift sendEvent(withName:)', async () => {
fs.writeFileSync(path.join(dir, 'package.json'), '{"name":"x","dependencies":{"react-native":"^0.76"}}');
fs.writeFileSync(
path.join(dir, 'CaptureEvents.swift'),
`import Foundation
class CaptureEvents: RCTEventEmitter {
func emitZipComplete() {
sendEvent(withName: "onZipComplete", body: ["ok": true])
}
func emitProgress() {
sendEvent(withName: "onCaptureProgress", body: nil)
}
}
`
);
fs.writeFileSync(
path.join(dir, 'App.tsx'),
`import { useEffect } from 'react'
export default function ReviewScreen() {
useEffect(() => {
const zip = nativeEmitter.addListener('onZipComplete', (data) => {
upload(data)
})
const progress = nativeEmitter.addListener('onCaptureProgress', async function () {
await tick()
})
return () => {
zip.remove()
progress.remove()
}
}, [])
return null
}
function upload(d: unknown) {}
function tick() {}
`
);
const cg = await CodeGraph.init(dir, { silent: true });
await cg.indexAll();
const db = (cg as any).db.db;
const rows = db
.prepare(
`SELECT s.name source_name, t.name target_name, json_extract(e.metadata,'$.event') event,
json_extract(e.metadata,'$.registeredAt') registered_at
FROM edges e JOIN nodes s ON s.id = e.source JOIN nodes t ON t.id = e.target
WHERE json_extract(e.metadata,'$.synthesizedBy') = 'rn-event-channel'
ORDER BY event`
)
.all();
cg.close?.();
expect(rows.map((r: any) => [r.source_name, r.target_name, r.event])).toEqual([
['emitProgress', 'ReviewScreen', 'onCaptureProgress'],
['emitZipComplete', 'ReviewScreen', 'onZipComplete'],
]);
expect(rows[1].registered_at).toBe('App.tsx:4');
});
});
+67
View File
@@ -0,0 +1,67 @@
/**
* A store exported by a LATER statement — `const useStore = create(…)` then
* `export default useStore` — is exported, and its actions are extracted like
* an `export const` store's (object-literal-methods.test.ts covers that
* form). The scope rule that keeps inline-object noise out still holds: a
* store nothing exports stays a constant.
*/
import { describe, it, expect, beforeAll } from 'vitest';
import { extractFromSource } from '../src/extraction';
import { initGrammars, loadAllGrammars } from '../src/extraction/grammars';
beforeAll(async () => {
await initGrammars();
await loadAllGrammars();
});
const fnNames = (code: string, file = 'store.ts') =>
extractFromSource(file, code)
.nodes.filter((n) => n.kind === 'function')
.map((n) => n.name);
describe('store actions on a later-exported const', () => {
it('export default NAME', () => {
const code = `
import { create } from 'zustand'
const useCaptureStorage = create<State>((set, get) => ({
object: null,
setSettings: (settings: Settings) => {
set({ settings })
},
reset: () => set({ object: null }),
}))
export default useCaptureStorage
`;
expect(fnNames(code)).toEqual(expect.arrayContaining(['setSettings', 'reset']));
});
it('export { NAME } and export { NAME as default }', () => {
const named = `
const useStore = create((set) => ({ bump: () => set({}) }))
export { useStore }
`;
const asDefault = `
const useStore = create((set) => ({ bump: () => set({}) }))
export { useStore as default }
`;
expect(fnNames(named)).toContain('bump');
expect(fnNames(asDefault)).toContain('bump');
});
it('a const nothing exports keeps its members out of the graph', () => {
const code = `
const useStore = create((set) => ({ bump: () => set({}) }))
export const other = 1
`;
expect(fnNames(code)).not.toContain('bump');
});
it('is not fooled by a different name in the export', () => {
const code = `
const useStoreInternal = create((set) => ({ bump: () => set({}) }))
const useStore = 1
export default useStore
`;
expect(fnNames(code)).not.toContain('bump');
});
});
+273
View File
@@ -0,0 +1,273 @@
/**
* `GET /api/steps` — what happens from a screen, as typed steps.
*
* Against a real index of a small Expo + React Native app, shaped to cross
* every boundary the endpoint classifies: a screen whose handler (a
* `useCallback`) calls a Swift method through an `RCT_EXTERN_MODULE` shim,
* the Swift side sending an event the screen listens to, the listener calling
* an API function that leaves the index (`client.post`), a store action in a
* store file, and a navigation to a second screen behind a condition. The
* pure layout is tested without an index in `ui-steps-model.test.ts`.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { CodeGraph } from '../src';
import { initGrammars, loadAllGrammars } from '../src/extraction/grammars';
import { buildSteps, crossing, effectCategory, isStoreFile } from '../src/ui-server/api/steps';
let tmpDir: string;
let cg: CodeGraph;
function write(rel: string, content: string): void {
const full = path.join(tmpDir, rel);
fs.mkdirSync(path.dirname(full), { recursive: true });
fs.writeFileSync(full, content);
}
beforeAll(async () => {
await initGrammars();
await loadAllGrammars();
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-ui-steps-'));
write('package.json', JSON.stringify({ name: 'app', dependencies: { expo: '52', 'expo-router': '4', 'react-native': '0.76' } }));
write('src/app/_layout.tsx', 'export default function Layout() { return null }\n');
write('src/app/index.tsx', "import { router } from 'expo-router'\nexport default function Home() {\n return null\n}\n");
write(
'src/components/capture/capture-view.tsx',
"import { NativeModules, NativeEventEmitter } from 'react-native'\n" +
'export const captureView = NativeModules.CaptureView\n' +
'export const nativeEmitter = new NativeEventEmitter(NativeModules.CaptureEvents)\n'
);
write('src/api/client.ts', "import axios from 'axios'\nexport const client = axios.create({ baseURL: 'x' })\n");
write(
'src/api/frames.ts',
"import { client } from './client'\n" +
'export async function uploadARCapture(uri: string) {\n' +
" await client.post('/frames', { uri })\n" +
" return client.get('/frames/status')\n" +
'}\n'
);
write(
'src/storage/capture.storage.ts',
"import { create } from 'zustand'\n" +
'const useCaptureStorage = create<State>((set) => ({\n' +
' zipUri: null,\n' +
' setZipUri: (zipUri: string) => set({ zipUri }),\n' +
'}))\n' +
'export default useCaptureStorage\n'
);
write(
'src/app/capture/review.tsx',
"import { useCallback, useEffect } from 'react'\n" +
"import { router } from 'expo-router'\n" +
"import { captureView, nativeEmitter } from '../../components/capture/capture-view'\n" +
"import { uploadARCapture } from '../../api/frames'\n" +
"import useCaptureStorage from '../../storage/capture.storage'\n" +
'export default function ReviewScreen({ unlimited }: { unlimited: boolean }) {\n' +
' const setZipUri = useCaptureStorage((s) => s.setZipUri)\n' +
' const handleApprove = useCallback(() => {\n' +
' captureView.finalizeCaptureSession()\n' +
' }, [])\n' +
' const handleZipComplete = useCallback(async (data: { uri: string }) => {\n' +
' setZipUri(data.uri)\n' +
' await uploadARCapture(data.uri)\n' +
" if (unlimited) router.replace('/')\n" +
' }, [unlimited])\n' +
' useEffect(() => {\n' +
" const sub = nativeEmitter.addListener('onZipComplete', handleZipComplete)\n" +
' return () => sub.remove()\n' +
' }, [handleZipComplete])\n' +
' return <Button onPress={handleApprove} />\n' +
'}\n'
);
write(
'src/app/capture/index.tsx',
"import { memo, useCallback } from 'react'\n" +
"import { captureView } from '../../components/capture/capture-view'\n" +
'function CaptureComponent() {\n' +
' const handleOpen = useCallback(() => {\n' +
' captureView.finalizeCaptureSession()\n' +
' }, [])\n' +
' return <Button onPress={handleOpen} />\n' +
'}\n' +
'const MemoizedCaptureComponent = memo(CaptureComponent)\n' +
'export default function CapturePage() {\n' +
' return <MemoizedCaptureComponent />\n' +
'}\n'
);
write(
'ios/CaptureView.m',
'#import <React/RCTViewManager.h>\n@interface RCT_EXTERN_MODULE(CaptureView, RCTViewManager)\nRCT_EXTERN_METHOD(finalizeCaptureSession)\n@end\n'
);
write(
'ios/CaptureView.swift',
'import Foundation\n' +
'class CaptureView: RCTViewManager {\n' +
' @objc func finalizeCaptureSession() {\n' +
' let result = zip()\n' +
' if result {\n' +
' CaptureEvents.shared.emitZipComplete()\n' +
' }\n' +
' }\n' +
' func zip() -> Bool { return true }\n' +
'}\n'
);
write(
'ios/CaptureEvents.swift',
'import Foundation\n' +
'class CaptureEvents: RCTEventEmitter {\n' +
' static let shared = CaptureEvents()\n' +
' func emitZipComplete() {\n' +
' sendEvent(withName: "onZipComplete", body: nil)\n' +
' }\n' +
'}\n'
);
cg = CodeGraph.initSync(tmpDir);
await cg.indexAll();
});
afterAll(() => {
cg?.close();
if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
});
const q = (params: Record<string, string>) => new URLSearchParams(params);
describe('classification helpers', () => {
it('crossing: JS → native is a bridge, native → JS an event, anything else nothing', () => {
expect(crossing('tsx', 'swift')).toBe('bridge');
expect(crossing('swift', 'tsx')).toBe('event');
expect(crossing('typescript', 'javascript')).toBeNull();
expect(crossing('swift', 'objc')).toBeNull();
});
it('store files', () => {
expect(isStoreFile('src/storage/capture.storage.ts')).toBe(true);
expect(isStoreFile('src/stores/user.ts')).toBe(true);
expect(isStoreFile('src/features/cart/cart.slice.ts')).toBe(true);
expect(isStoreFile('src/components/button.tsx')).toBe(false);
expect(isStoreFile('src/restore/thing.ts')).toBe(false);
});
it('effects: a curated table, by reference text', () => {
expect(effectCategory('client.post')).toBe('network');
expect(effectCategory('fetch')).toBe('network');
expect(effectCategory('AsyncStorage.setItem')).toBe('storage');
expect(effectCategory('Linking.openURL')).toBe('device');
expect(effectCategory('DdRum.addAction')).toBe('telemetry');
expect(effectCategory('Math.max')).toBeNull();
expect(effectCategory('i18n.t')).toBeNull();
});
});
describe('buildSteps', () => {
it('walks a screen through its handler, the bridge, the event, the store and the request', async () => {
const review = cg.getNodesByKind('route').find((r) => r.name === '/capture/review')!;
expect(review).toBeDefined();
const payload = await buildSteps(cg, tmpDir, q({ anchor: review.id }));
const byLabel = new Map(payload.steps.map((s) => [s.label, s]));
const kinds = Object.fromEntries(payload.steps.map((s) => [s.label, s.kind]));
expect(kinds['/capture/review']).toBe('screen');
expect(payload.steps.find((s) => s.anchor)?.label).toBe('/capture/review');
// The handler is wired to the tap, so it is a trigger; the call it makes
// crosses into Swift, so that is a bridge; the Swift side's event lands
// on the named listener; the listener writes the store, leaves the index
// through `client.post`, and navigates home behind `unlimited`.
expect(kinds['handleApprove']).toBe('trigger');
expect(kinds['finalizeCaptureSession']).toBe('bridge');
expect(kinds['handleZipComplete']).toBe('event');
expect(byLabel.get('handleZipComplete')?.event).toBe('onZipComplete');
expect(byLabel.get('handleZipComplete')?.events).toEqual(['onZipComplete']);
expect(kinds['setZipUri']).toBe('store');
// One box per (function, category): both calls the upload makes into the
// network, labelled by the first and counting the rest.
const network = payload.steps.find((s) => s.kind === 'effect' && s.effect?.category === 'network')!;
expect(network.label).toBe('client.post +1');
expect(network.effect?.apis).toEqual(['client.post', 'client.get']);
expect(network.effect?.by.name).toBe('uploadARCapture');
expect(kinds['/']).toBe('screen');
// Another screen is a boundary: drawn, marked, not entered.
expect(byLabel.get('/')?.cut).toBe('screen');
const link = (from: string, to: string) =>
payload.links.find((l) => l.from === byLabel.get(from)!.id && l.to === byLabel.get(to)!.id);
expect(link('/capture/review', 'handleApprove')?.kind).toBe('handler');
expect(link('handleApprove', 'finalizeCaptureSession')?.kind).toBe('bridge');
const evt = link('finalizeCaptureSession', 'handleZipComplete');
expect(evt?.kind).toBe('event');
expect(evt?.synthesized).toBe(true);
expect(evt?.via.map((v) => v.name)).toEqual(['emitZipComplete']);
expect(evt?.when).toBe('result');
expect(evt?.label).toContain('event onZipComplete');
expect(link('handleZipComplete', 'setZipUri')?.kind).toBe('store');
const req = link('handleZipComplete', 'client.post +1');
expect(req?.kind).toBe('effect');
expect(req?.via.map((v) => v.name)).toEqual(['uploadARCapture']);
const nav = link('handleZipComplete', '/');
expect(nav?.kind).toBe('navigates');
expect(nav?.when).toBe('unlimited');
expect(nav?.sites[0]?.text).toBe('replace /');
// Rows: the anchor on 0, then one more step away each. The listener is
// registered BY the screen (`addListener('onZipComplete', handleZipComplete)`),
// so it sits one step from the anchor as a handler and the native event
// arrives at it from further down — a link back up the picture — and
// names the event on the box.
expect(byLabel.get('/capture/review')?.depth).toBe(0);
expect(byLabel.get('handleApprove')?.depth).toBe(1);
expect(byLabel.get('finalizeCaptureSession')?.depth).toBe(2);
expect(byLabel.get('handleZipComplete')?.depth).toBe(1);
expect(link('/capture/review', 'handleZipComplete')?.kind).toBe('handler');
expect(network.depth).toBe(2);
expect(payload.through).toBe(false);
expect(payload.truncated).toEqual({ steps: 0, hubs: 0, chrome: 0 });
// No cap fired; the only thing not entered is the other screen.
expect(payload.steps.filter((s) => s.cut !== null).map((s) => [s.label, s.cut])).toEqual([['/', 'screen']]);
});
it('walks through a memo-wrapped component into the screen body', async () => {
const capture = cg.getNodesByKind('route').find((r) => r.name === '/capture')!;
const payload = await buildSteps(cg, tmpDir, q({ anchor: capture.id }));
const kinds = Object.fromEntries(payload.steps.map((s) => [s.label, s.kind]));
// The wrapper and the component are render hops, folded into the link;
// the handler is the first box, the native call the next.
expect(kinds['handleOpen']).toBe('trigger');
expect(kinds['finalizeCaptureSession']).toBe('bridge');
const toHandler = payload.links.find((l) => l.to === payload.steps.find((s) => s.label === 'handleOpen')!.id)!;
expect(toHandler.via.map((v) => v.name)).toEqual(['MemoizedCaptureComponent', 'CaptureComponent']);
expect(payload.steps.map((s) => s.label)).not.toContain('CaptureComponent');
});
it('enters other screens when asked to continue through them', async () => {
const review = cg.getNodesByKind('route').find((r) => r.name === '/capture/review')!;
const payload = await buildSteps(cg, tmpDir, q({ anchor: review.id, through: '1' }));
expect(payload.through).toBe(true);
expect(payload.steps.find((s) => s.label === '/')?.cut).toBeNull();
});
it('anchors by name, prefers the screen, and lists the rest as ambiguous', async () => {
const payload = await buildSteps(cg, tmpDir, q({ symbol: 'handleApprove' }));
expect(payload.anchor.name).toBe('handleApprove');
expect(payload.steps[0]?.kind).toBe('anchor');
expect(payload.steps.map((s) => s.label)).toContain('finalizeCaptureSession');
});
it('a depth cap is announced on the step it stopped at', async () => {
const review = cg.getNodesByKind('route').find((r) => r.name === '/capture/review')!;
const payload = await buildSteps(cg, tmpDir, q({ anchor: review.id, depth: '2' }));
// The bridge is two steps out: drawn, not explored — and says so.
const bridge = payload.steps.find((s) => s.label === 'finalizeCaptureSession')!;
expect(bridge.cut).toBe('depth');
expect(payload.links.some((l) => l.kind === 'event')).toBe(false);
// The listener still sits one step out, so the event step keeps its
// handler kind: nothing arrived at it from native within the cap.
expect(payload.steps.find((s) => s.label === 'handleZipComplete')?.kind).toBe('trigger');
});
it('refuses a missing anchor and an unknown id', async () => {
await expect(buildSteps(cg, tmpDir, q({}))).rejects.toThrow(/anchor/);
await expect(buildSteps(cg, tmpDir, q({ anchor: 'function:nope' }))).rejects.toThrow(/No symbol/);
await expect(buildSteps(cg, tmpDir, q({ symbol: 'nothingNamedThis' }))).rejects.toThrow(/Nothing/);
});
});
+110
View File
@@ -0,0 +1,110 @@
/**
* The Steps view's model, without a browser: rows by the server's depth, the
* words in a box by kind, one edge per pair with the Screens view's label
* rule, and the panel's two lists.
*/
import { describe, it, expect } from 'vitest';
import { buildStepsModel, kindWord, stepLabel, stepNeighbourhood, stepSub, stepViaText } from '../ui/src/lib/steps-model';
import { placeLabels } from '../ui/src/lib/screens-model';
import type { WireNodeRef, WireStep, WireStepLink, WireStepsPayload } from '../ui/src/lib/wire';
function ref(name: string, file = 'src/a.tsx', language: WireNodeRef['language'] = 'tsx'): WireNodeRef {
return { id: `function:${name}`, kind: 'function', name, qualifiedName: name, file, line: 1, endLine: 9, language, test: false };
}
function step(label: string, kind: WireStep['kind'], depth: number, extra: Partial<WireStep> = {}): WireStep {
const node = kind === 'effect' ? null : ref(label, extra.node?.file ?? 'src/a.tsx');
return { id: node?.id ?? `effect:fn:${label}`, kind, anchor: depth === 0, node, label, sub: 'src/a.tsx', depth, cut: null, ...extra };
}
function link(from: WireStep, to: WireStep, extra: Partial<WireStepLink> = {}): WireStepLink {
return { id: `${from.id} ${to.id}`, from: from.id, to: to.id, kind: 'calls', via: [], when: '', label: '', synthesized: false, uncertain: false, sites: [], ...extra };
}
function payload(steps: WireStep[], links: WireStepLink[]): WireStepsPayload {
return {
anchor: steps[0]!.node!,
ambiguous: [],
steps,
links,
depth: 8,
limit: 120,
through: false,
truncated: { steps: 0, hubs: 0, chrome: 0 },
index: { lastIndexedAt: null, edges: 0, files: 0 },
timing: { elapsedMs: 1 },
};
}
describe('steps model', () => {
const screen = step('/capture/review', 'screen', 0, { screen: { path: '/capture/review', component: ref('ReviewScreen') } });
const handler = step('handleApprove', 'trigger', 1);
const bridge = step('finalizeCaptureSession', 'bridge', 2, { node: ref('finalizeCaptureSession', 'ios/CaptureView.swift', 'swift') });
const event = step('handleZipComplete', 'event', 3, { event: 'onZipComplete' });
const effect = step('client.post', 'effect', 4, { sub: 'network · uploadARCapture', effect: { api: 'client.post', apis: ['client.post'], category: 'network', by: ref('uploadARCapture'), line: 3 } });
const store = step('setZipUri', 'store', 4, { node: ref('setZipUri', 'src/storage/capture.storage.ts') });
const home = step('/', 'screen', 4, { screen: { path: '/', component: null } });
const links = [
link(screen, handler, { kind: 'handler' }),
link(handler, bridge, { kind: 'bridge', when: '!busy' }),
link(bridge, event, { kind: 'event', synthesized: true, via: [ref('emitZipComplete', 'ios/CaptureEvents.swift', 'swift')], when: 'result', label: 'via rn-event-channel · event onZipComplete' }),
link(event, effect, { kind: 'effect', via: [ref('uploadARCapture')] }),
link(event, store, { kind: 'store' }),
link(event, home, { kind: 'navigates', when: 'unlimited' }),
// A second way from the event to the store, unconditional: the pair is one edge saying "2 ways".
{ ...link(event, store, { kind: 'store', when: 'retry' }), id: 'second' },
];
const model = buildStepsModel(payload([screen, handler, bridge, event, effect, store, home], links));
it('puts the anchor on top and each row one step further away', () => {
const y = (id: string) => model.layout.nodes.find((n) => n.id === id)!.y;
expect(y(screen.id)).toBeLessThan(y(handler.id));
expect(y(handler.id)).toBeLessThan(y(bridge.id));
expect(y(bridge.id)).toBeLessThan(y(event.id));
expect(y(event.id)).toBeLessThan(y(effect.id));
expect(y(effect.id)).toBe(y(store.id));
expect(y(effect.id)).toBe(y(home.id));
});
it('one edge per pair, labelled with the innermost condition or a count', () => {
const edges = [...model.edges.values()];
expect(edges).toHaveLength(6);
const toBridge = edges.find((e) => e.to === bridge.id)!;
expect(toBridge.label).toBe('!busy');
expect(toBridge.kind).toBe('bridge');
const toEvent = edges.find((e) => e.to === event.id)!;
expect(toEvent.synthesized).toBe(true);
expect(toEvent.label).toBe('result');
const toStore = edges.find((e) => e.to === store.id)!;
expect(toStore.links).toHaveLength(2);
expect(toStore.label).toBe('2 ways · 1 conditional');
expect(toStore.kind).toBe('store');
});
it('counts steps per kind', () => {
expect(model.counts).toEqual({ anchor: 0, screen: 2, trigger: 1, bridge: 1, event: 1, store: 1, effect: 1 });
});
it('words a box by its kind', () => {
expect(stepLabel(bridge)).toBe('⇢ finalizeCaptureSession');
expect(stepLabel(event)).toBe('⇠ onZipComplete');
expect(stepLabel({ ...event, events: ['onZipComplete', 'onZipError', 'onCameraReady'] })).toBe('⇠ onZipComplete +2');
expect(stepLabel(screen)).toBe('/capture/review');
expect(stepSub(event)).toBe('handleZipComplete · a.tsx');
expect(stepSub(bridge)).toBe('native · CaptureView.swift');
expect(stepSub(store)).toBe('store · capture.storage.ts');
expect(stepSub(effect)).toBe('network · uploadARCapture');
expect(kindWord('effect')).toBe('outside the index');
expect(stepViaText(links[2]!)).toBe('emitZipComplete');
});
it('labels a selected step at the far end of each line, and lists its links', () => {
const pills = placeLabels(model, event.id);
expect(pills.hidden).toBe(0);
const words = [...pills.pills.values()].map((p) => p.text).sort();
expect(words).toEqual(['← result', '→ 2 ways · 1 conditional', '→ unlimited']);
const lists = stepNeighbourhood(payload([screen, handler, bridge, event, effect, store, home], links), event.id);
expect(lists.arrivesFrom.map((l) => l.from)).toEqual([bridge.id]);
expect(lists.leadsTo.map((l) => l.to)).toEqual([effect.id, store.id, home.id, store.id]);
});
});