expo-modules.test.ts 4.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154
  1. import { describe, it, expect, beforeEach, afterEach } from 'vitest';
  2. import * as fs from 'node:fs';
  3. import * as path from 'node:path';
  4. import * as os from 'node:os';
  5. import { CodeGraph } from '../src';
  6. import { expoModulesResolver } from '../src/resolution/frameworks/expo-modules';
  7. describe('Expo Modules framework extractor', () => {
  8. it('extracts AsyncFunction / Function / Property literals as method nodes', () => {
  9. const source = `
  10. import ExpoModulesCore
  11. public class HapticsModule: Module {
  12. public func definition() -> ModuleDefinition {
  13. Name("ExpoHaptics")
  14. AsyncFunction("notificationAsync") { (notificationType: NotificationType) in
  15. // body
  16. }
  17. AsyncFunction("impactAsync") { (style: ImpactStyle) in
  18. // body
  19. }
  20. Function("synchronousThing") {
  21. return 1
  22. }
  23. Property("isAvailable") {
  24. return true
  25. }
  26. }
  27. }
  28. `;
  29. const result = expoModulesResolver.extract?.('ios/HapticsModule.swift', source);
  30. expect(result).toBeDefined();
  31. const names = result!.nodes.map((n) => n.name);
  32. expect(names).toEqual(
  33. expect.arrayContaining(['notificationAsync', 'impactAsync', 'synchronousThing', 'isAvailable'])
  34. );
  35. expect(result!.nodes.every((n) => n.kind === 'method')).toBe(true);
  36. expect(result!.nodes.every((n) => n.qualifiedName.includes('ExpoHaptics.'))).toBe(true);
  37. });
  38. it('falls back to the class name when the Module has no Name("X") literal', () => {
  39. const source = `
  40. public class BareModule: Module {
  41. public func definition() -> ModuleDefinition {
  42. Function("doX") { return 1 }
  43. }
  44. }
  45. `;
  46. const result = expoModulesResolver.extract?.('ios/BareModule.swift', source);
  47. // BareModule is used as the qualifier since there's no Name() literal.
  48. expect(result!.nodes[0]?.qualifiedName).toContain('BareModule.doX');
  49. });
  50. it('returns no nodes for a Swift file that is not an Expo Module', () => {
  51. const source = `
  52. class Helper {
  53. func doX() { }
  54. }
  55. `;
  56. const result = expoModulesResolver.extract?.('Helper.swift', source);
  57. expect(result?.nodes).toHaveLength(0);
  58. });
  59. it('also extracts from Kotlin module files', () => {
  60. const source = `
  61. class FooModule : Module() {
  62. override fun definition() = ModuleDefinition {
  63. Name("ExpoFoo")
  64. AsyncFunction("doAsync") { name: String -> name.uppercase() }
  65. Function("doSync") { 42 }
  66. }
  67. }
  68. `;
  69. const result = expoModulesResolver.extract?.('FooModule.kt', source);
  70. expect(result?.nodes.length).toBe(2);
  71. expect(result?.nodes.map((n) => n.name).sort()).toEqual(['doAsync', 'doSync']);
  72. expect(result?.nodes.every((n) => n.language === 'kotlin')).toBe(true);
  73. });
  74. });
  75. describe('Expo Modules end-to-end — JS caller → native AsyncFunction', () => {
  76. let dir: string;
  77. beforeEach(() => {
  78. dir = fs.mkdtempSync(path.join(os.tmpdir(), 'expo-modules-fixture-'));
  79. });
  80. afterEach(() => {
  81. fs.rmSync(dir, { recursive: true, force: true });
  82. });
  83. it('JS callsite of a literal AsyncFunction("name") resolves to the native impl node', async () => {
  84. fs.writeFileSync(
  85. path.join(dir, 'package.json'),
  86. '{"dependencies":{"expo-modules-core":"^1.0.0"}}'
  87. );
  88. fs.mkdirSync(path.join(dir, 'ios'));
  89. fs.writeFileSync(
  90. path.join(dir, 'ios', 'HapticsModule.swift'),
  91. `
  92. import ExpoModulesCore
  93. public class HapticsModule: Module {
  94. public func definition() -> ModuleDefinition {
  95. Name("ExpoHaptics")
  96. AsyncFunction("uniqueExpoHapticCall") { in /* … */ }
  97. }
  98. }
  99. `
  100. );
  101. fs.mkdirSync(path.join(dir, 'src'));
  102. fs.writeFileSync(
  103. path.join(dir, 'src', 'index.ts'),
  104. `
  105. import { requireNativeModule } from 'expo-modules-core';
  106. const Haptics = requireNativeModule('ExpoHaptics');
  107. export async function impactAsync() {
  108. return await Haptics.uniqueExpoHapticCall();
  109. }
  110. `
  111. );
  112. const cg = await CodeGraph.init(dir, { silent: true });
  113. await cg.indexAll();
  114. const db = (cg as any).db.db;
  115. // The native method node should exist.
  116. const native = db
  117. .prepare(
  118. "SELECT * FROM nodes WHERE kind='method' AND name='uniqueExpoHapticCall' AND id LIKE 'expo-module:%'"
  119. )
  120. .all();
  121. expect(native).toHaveLength(1);
  122. // And the JS callsite should produce a call edge targeting it.
  123. const callEdge = db
  124. .prepare(
  125. `SELECT t.name target, t.id target_id
  126. FROM edges e
  127. JOIN nodes s ON s.id = e.source
  128. JOIN nodes t ON t.id = e.target
  129. WHERE e.kind = 'calls'
  130. AND s.file_path LIKE '%index.ts'
  131. AND t.name = 'uniqueExpoHapticCall'`
  132. )
  133. .all();
  134. cg.close?.();
  135. expect(callEdge.length).toBeGreaterThanOrEqual(1);
  136. expect(callEdge[0].target_id.startsWith('expo-module:')).toBe(true);
  137. });
  138. });