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:
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user