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'; /** * End-to-end synthesizer test: write a fixture project with a native ObjC * `sendEventWithName:` site and a JS `addListener('x', fn)` subscriber, * index it, and verify the synthesized cross-language event edge. */ describe('RN event channel synthesizer', () => { let dir: string; beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'rn-event-fixture-')); }); afterEach(() => { fs.rmSync(dir, { recursive: true, force: true }); }); it('synthesizes an edge from ObjC sendEventWithName: to JS addListener handler', async () => { // package.json so the RN detector / general resolver sees the project as RN. fs.writeFileSync( path.join(dir, 'package.json'), '{"name":"x","dependencies":{"react-native":"^0.73"}}' ); fs.writeFileSync( path.join(dir, 'Emitter.m'), ` @implementation Emitter - (void)reportLocation { [self sendEventWithName:@"locationUpdate" body:@{}]; } @end ` ); fs.writeFileSync( path.join(dir, 'App.js'), ` function onLocation(payload) { console.log(payload); } emitter.addListener('locationUpdate', onLocation); ` ); 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, s.language sl, t.name target_name, t.language tl, json_extract(e.metadata,'$.event') event 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'` ) .all(); cg.close?.(); expect(rows.length).toBeGreaterThanOrEqual(1); // The edge should point from the ObjC method that emits to the JS handler. const edge = rows.find((r: any) => r.event === 'locationUpdate'); expect(edge).toBeDefined(); expect(edge.sl).toBe('objc'); expect(edge.tl).toBe('javascript'); expect(edge.target_name).toBe('onLocation'); }); it('falls back to enclosing JS function when addListener handler is a parameter (wrapper-API pattern)', async () => { // Matches the real RNFirebase shape: `messaging().onMessage(listener)` // is a subscribe-wrapper whose body does // `addListener('messaging_message_received', listener)` where `listener` // is the parameter — not a globally-named symbol. Synthesizer should // still produce an edge, attributed to the enclosing wrapper function. fs.writeFileSync( path.join(dir, 'package.json'), '{"dependencies":{"react-native":"^0.73"}}' ); fs.writeFileSync( path.join(dir, 'Native.m'), ` @implementation MyEmitter - (void)pushMessage { [[Shared shared] sendEventWithName:@"messaging_message_received" body:@{}]; } @end ` ); fs.writeFileSync( path.join(dir, 'messaging.ts'), ` import { NativeEventEmitter } from 'react-native'; const emitter = new NativeEventEmitter(); export function onMessage(listener: (m: any) => void) { return emitter.addListener('messaging_message_received', listener); } ` ); 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, t.kind target_kind, t.language tl, json_extract(e.metadata,'$.event') event 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'` ) .all(); cg.close?.(); const edge = rows.find((r: any) => r.event === 'messaging_message_received'); expect(edge).toBeDefined(); // Target should be the wrapper function `onMessage` — the enclosing // function of the addListener call, not a bareword named handler. expect(edge.target_name).toBe('onMessage'); expect(['function', 'method']).toContain(edge.target_kind); }); it('synthesizes an edge from a Java sendEvent(ctx, "X", body) wrapper to a JS handler', async () => { fs.writeFileSync(path.join(dir, 'package.json'), '{"dependencies":{"react-native":"^0.74.0"}}'); // The literal event name lives in the WRAPPER CALL, not in `.emit` (whose // first arg is the `eventName` VARIABLE) — the common react-native-device-info // shape that RN_JVM_EMIT_RE alone misses. fs.writeFileSync(path.join(dir, 'BatteryModule.java'), 'public class BatteryModule extends ReactContextBaseJavaModule {\n' + ' @Override public String getName() { return "BatteryModule"; }\n' + ' public void onBatteryChanged() {\n' + ' sendEvent(getReactApplicationContext(),\n' + ' "myWrapperBatteryEvent", null);\n' + ' }\n' + ' private void sendEvent(ReactContext ctx, String eventName, Object data) {\n' + ' ctx.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class).emit(eventName, data);\n' + ' }\n' + '}\n'); fs.writeFileSync(path.join(dir, 'index.ts'), "function onBattery() {}\n" + "emitter.addListener('myWrapperBatteryEvent', onBattery);\n"); 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, s.language sl, t.name target_name 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' AND json_extract(e.metadata,'$.event')='myWrapperBatteryEvent'" ).all(); cg.close?.(); expect(rows.length).toBeGreaterThanOrEqual(1); expect(rows[0].sl).toBe('java'); expect(rows[0].source_name).toBe('onBatteryChanged'); expect(rows[0].target_name).toBe('onBattery'); }); });