Files
colbymchenry__codegraph/__tests__/expo-modules.test.ts
Colby McHenry 6bf4ff5690 feat(resolution): Expo Modules — extract Function/AsyncFunction/Property nodes (Phase 4)
Closes the JS → native flow for Expo SDK packages. Expo's Swift / Kotlin
DSL declares JS-callable methods via literals inside a Module subclass:

  public class HapticsModule: Module {
    public func definition() -> ModuleDefinition {
      Name("ExpoHaptics")
      AsyncFunction("notificationAsync") { (notificationType: NotificationType) in … }
      AsyncFunction("impactAsync") { (style: ImpactStyle) in … }
      AsyncFunction("selectionAsync") { … }
    }
  }

Tree-sitter parses these as ordinary call_expressions with trailing
closures, so until now `Camera.takePictureAsync(...)` on the JS side
had nothing to resolve to. The new framework extractor walks the source
for the declarative literals and synthesizes `method` nodes named
`takePictureAsync` / `notificationAsync` / `width` / etc.,
attributed to the Swift or Kotlin file. The standard name-matcher then
resolves JS callsites to them via the existing `obj.method` →
method-name path — no separate resolve() branch needed.

Detection: package.json declares `expo-modules-core` OR any source
file matches `class X: Module` + at least one declarative literal
(both signals together — class-name match alone over-fires on unrelated
`: Module` references).

Validated on real Expo SDK packages:
- **expo-haptics**: 6 Expo method nodes (Swift + Kotlin) covering
  `notificationAsync`, `impactAsync`, `selectionAsync`,
  `performHapticsAsync`.
- **expo-camera**: 41 Expo method nodes covering the full surface
  (`takePictureAsync`, `record`, `resumePreview`,
  `scanFromURLAsync`, view properties `width`/`height`, …). 16
  internal swift+kotlin→expo edges within the package's own native
  side. The package's own JS wrappers (CameraView.tsx) shadow the
  native names with TS methods, so within-package JS callsites resolve
  to the TS wrapper first; external Expo apps consuming the package
  bridge through to the native method directly.

5 tests covering extraction + an end-to-end fixture that demonstrates
`Haptics.uniqueExpoHapticCall()` in JS resolves to the native Swift
`AsyncFunction("uniqueExpoHapticCall")` node.

924/926 existing tests still pass (2 skipped); +5 new Expo tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 01:42:21 -05:00

155 lines
4.7 KiB
TypeScript

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';
import { expoModulesResolver } from '../src/resolution/frameworks/expo-modules';
describe('Expo Modules framework extractor', () => {
it('extracts AsyncFunction / Function / Property literals as method nodes', () => {
const source = `
import ExpoModulesCore
public class HapticsModule: Module {
public func definition() -> ModuleDefinition {
Name("ExpoHaptics")
AsyncFunction("notificationAsync") { (notificationType: NotificationType) in
// body
}
AsyncFunction("impactAsync") { (style: ImpactStyle) in
// body
}
Function("synchronousThing") {
return 1
}
Property("isAvailable") {
return true
}
}
}
`;
const result = expoModulesResolver.extract?.('ios/HapticsModule.swift', source);
expect(result).toBeDefined();
const names = result!.nodes.map((n) => n.name);
expect(names).toEqual(
expect.arrayContaining(['notificationAsync', 'impactAsync', 'synchronousThing', 'isAvailable'])
);
expect(result!.nodes.every((n) => n.kind === 'method')).toBe(true);
expect(result!.nodes.every((n) => n.qualifiedName.includes('ExpoHaptics.'))).toBe(true);
});
it('falls back to the class name when the Module has no Name("X") literal', () => {
const source = `
public class BareModule: Module {
public func definition() -> ModuleDefinition {
Function("doX") { return 1 }
}
}
`;
const result = expoModulesResolver.extract?.('ios/BareModule.swift', source);
// BareModule is used as the qualifier since there's no Name() literal.
expect(result!.nodes[0]?.qualifiedName).toContain('BareModule.doX');
});
it('returns no nodes for a Swift file that is not an Expo Module', () => {
const source = `
class Helper {
func doX() { }
}
`;
const result = expoModulesResolver.extract?.('Helper.swift', source);
expect(result?.nodes).toHaveLength(0);
});
it('also extracts from Kotlin module files', () => {
const source = `
class FooModule : Module() {
override fun definition() = ModuleDefinition {
Name("ExpoFoo")
AsyncFunction("doAsync") { name: String -> name.uppercase() }
Function("doSync") { 42 }
}
}
`;
const result = expoModulesResolver.extract?.('FooModule.kt', source);
expect(result?.nodes.length).toBe(2);
expect(result?.nodes.map((n) => n.name).sort()).toEqual(['doAsync', 'doSync']);
expect(result?.nodes.every((n) => n.language === 'kotlin')).toBe(true);
});
});
describe('Expo Modules end-to-end — JS caller → native AsyncFunction', () => {
let dir: string;
beforeEach(() => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'expo-modules-fixture-'));
});
afterEach(() => {
fs.rmSync(dir, { recursive: true, force: true });
});
it('JS callsite of a literal AsyncFunction("name") resolves to the native impl node', async () => {
fs.writeFileSync(
path.join(dir, 'package.json'),
'{"dependencies":{"expo-modules-core":"^1.0.0"}}'
);
fs.mkdirSync(path.join(dir, 'ios'));
fs.writeFileSync(
path.join(dir, 'ios', 'HapticsModule.swift'),
`
import ExpoModulesCore
public class HapticsModule: Module {
public func definition() -> ModuleDefinition {
Name("ExpoHaptics")
AsyncFunction("uniqueExpoHapticCall") { in /* … */ }
}
}
`
);
fs.mkdirSync(path.join(dir, 'src'));
fs.writeFileSync(
path.join(dir, 'src', 'index.ts'),
`
import { requireNativeModule } from 'expo-modules-core';
const Haptics = requireNativeModule('ExpoHaptics');
export async function impactAsync() {
return await Haptics.uniqueExpoHapticCall();
}
`
);
const cg = await CodeGraph.init(dir, { silent: true });
await cg.indexAll();
const db = (cg as any).db.db;
// The native method node should exist.
const native = db
.prepare(
"SELECT * FROM nodes WHERE kind='method' AND name='uniqueExpoHapticCall' AND id LIKE 'expo-module:%'"
)
.all();
expect(native).toHaveLength(1);
// And the JS callsite should produce a call edge targeting it.
const callEdge = db
.prepare(
`SELECT t.name target, t.id target_id
FROM edges e
JOIN nodes s ON s.id = e.source
JOIN nodes t ON t.id = e.target
WHERE e.kind = 'calls'
AND s.file_path LIKE '%index.ts'
AND t.name = 'uniqueExpoHapticCall'`
)
.all();
cg.close?.();
expect(callEdge.length).toBeGreaterThanOrEqual(1);
expect(callEdge[0].target_id.startsWith('expo-module:')).toBe(true);
});
});