ui-steps-api.test.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287
  1. /**
  2. * `GET /api/steps` — what happens from a screen, as typed steps.
  3. *
  4. * Against a real index of a small Expo + React Native app, shaped to cross
  5. * every boundary the endpoint classifies: a screen whose handler (a
  6. * `useCallback`) calls a Swift method through an `RCT_EXTERN_MODULE` shim,
  7. * the Swift side sending an event the screen listens to, the listener calling
  8. * an API function that leaves the index (`client.post`), a store action in a
  9. * store file, and a navigation to a second screen behind a condition. The
  10. * pure layout is tested without an index in `ui-steps-model.test.ts`.
  11. */
  12. import { describe, it, expect, beforeAll, afterAll } from 'vitest';
  13. import * as fs from 'fs';
  14. import * as os from 'os';
  15. import * as path from 'path';
  16. import { CodeGraph } from '../src';
  17. import { initGrammars, loadAllGrammars } from '../src/extraction/grammars';
  18. import { buildSteps, crossing, effectCategory, isStoreFile } from '../src/ui-server/api/steps';
  19. let tmpDir: string;
  20. let cg: CodeGraph;
  21. function write(rel: string, content: string): void {
  22. const full = path.join(tmpDir, rel);
  23. fs.mkdirSync(path.dirname(full), { recursive: true });
  24. fs.writeFileSync(full, content);
  25. }
  26. beforeAll(async () => {
  27. await initGrammars();
  28. await loadAllGrammars();
  29. tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-ui-steps-'));
  30. write('package.json', JSON.stringify({ name: 'app', dependencies: { expo: '52', 'expo-router': '4', 'react-native': '0.76' } }));
  31. write('src/app/_layout.tsx', 'export default function Layout() { return null }\n');
  32. write('src/app/index.tsx', "import { router } from 'expo-router'\nexport default function Home() {\n return null\n}\n");
  33. write(
  34. 'src/components/capture/capture-view.tsx',
  35. "import { NativeModules, NativeEventEmitter } from 'react-native'\n" +
  36. 'export const captureView = NativeModules.CaptureView\n' +
  37. 'export const nativeEmitter = new NativeEventEmitter(NativeModules.CaptureEvents)\n'
  38. );
  39. write('src/api/client.ts', "import axios from 'axios'\nexport const client = axios.create({ baseURL: 'x' })\n");
  40. write(
  41. 'src/api/frames.ts',
  42. "import { client } from './client'\n" +
  43. 'export async function uploadARCapture(uri: string) {\n' +
  44. " await client.post('/frames', { uri })\n" +
  45. " return client.get('/frames/status')\n" +
  46. '}\n'
  47. );
  48. write(
  49. 'src/storage/capture.storage.ts',
  50. "import { create } from 'zustand'\n" +
  51. 'const useCaptureStorage = create<State>((set) => ({\n' +
  52. ' zipUri: null,\n' +
  53. ' setZipUri: (zipUri: string) => set({ zipUri }),\n' +
  54. '}))\n' +
  55. 'export default useCaptureStorage\n'
  56. );
  57. write(
  58. 'src/app/capture/review.tsx',
  59. "import { useCallback, useEffect } from 'react'\n" +
  60. "import { router } from 'expo-router'\n" +
  61. "import { captureView, nativeEmitter } from '../../components/capture/capture-view'\n" +
  62. "import { uploadARCapture } from '../../api/frames'\n" +
  63. "import useCaptureStorage from '../../storage/capture.storage'\n" +
  64. 'export default function ReviewScreen({ unlimited }: { unlimited: boolean }) {\n' +
  65. ' const setZipUri = useCaptureStorage((s) => s.setZipUri)\n' +
  66. ' const handleApprove = useCallback(() => {\n' +
  67. ' captureView.finalizeCaptureSession()\n' +
  68. ' }, [])\n' +
  69. ' const handleZipComplete = useCallback(async (data: { uri: string }) => {\n' +
  70. ' setZipUri(data.uri)\n' +
  71. ' await uploadARCapture(data.uri)\n' +
  72. " Alert.alert('Uploaded', data.uri, [{ text: 'OK' }])\n" +
  73. " if (unlimited) router.replace('/')\n" +
  74. ' }, [unlimited])\n' +
  75. ' useEffect(() => {\n' +
  76. " const sub = nativeEmitter.addListener('onZipComplete', handleZipComplete)\n" +
  77. ' return () => sub.remove()\n' +
  78. ' }, [handleZipComplete])\n' +
  79. ' return <Button onPress={handleApprove} />\n' +
  80. '}\n'
  81. );
  82. write(
  83. 'src/app/capture/index.tsx',
  84. "import { memo, useCallback } from 'react'\n" +
  85. "import { captureView } from '../../components/capture/capture-view'\n" +
  86. 'function CaptureComponent() {\n' +
  87. ' const handleOpen = useCallback(() => {\n' +
  88. ' captureView.finalizeCaptureSession()\n' +
  89. ' }, [])\n' +
  90. ' return <Button onPress={handleOpen} />\n' +
  91. '}\n' +
  92. 'const MemoizedCaptureComponent = memo(CaptureComponent)\n' +
  93. 'export default function CapturePage() {\n' +
  94. ' return <MemoizedCaptureComponent />\n' +
  95. '}\n'
  96. );
  97. write(
  98. 'ios/CaptureView.m',
  99. '#import <React/RCTViewManager.h>\n@interface RCT_EXTERN_MODULE(CaptureView, RCTViewManager)\nRCT_EXTERN_METHOD(finalizeCaptureSession)\n@end\n'
  100. );
  101. write(
  102. 'ios/CaptureView.swift',
  103. 'import Foundation\n' +
  104. 'class CaptureView: RCTViewManager {\n' +
  105. ' @objc func finalizeCaptureSession() {\n' +
  106. ' let result = zip()\n' +
  107. ' if result {\n' +
  108. ' CaptureEvents.shared.emitZipComplete()\n' +
  109. ' }\n' +
  110. ' }\n' +
  111. ' func zip() -> Bool { return true }\n' +
  112. '}\n'
  113. );
  114. write(
  115. 'ios/CaptureEvents.swift',
  116. 'import Foundation\n' +
  117. 'class CaptureEvents: RCTEventEmitter {\n' +
  118. ' static let shared = CaptureEvents()\n' +
  119. ' func emitZipComplete() {\n' +
  120. ' sendEvent(withName: "onZipComplete", body: nil)\n' +
  121. ' }\n' +
  122. '}\n'
  123. );
  124. cg = CodeGraph.initSync(tmpDir);
  125. await cg.indexAll();
  126. });
  127. afterAll(() => {
  128. cg?.close();
  129. if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
  130. });
  131. const q = (params: Record<string, string>) => new URLSearchParams(params);
  132. describe('classification helpers', () => {
  133. it('crossing: JS → native is a bridge, native → JS an event, anything else nothing', () => {
  134. expect(crossing('tsx', 'swift')).toBe('bridge');
  135. expect(crossing('swift', 'tsx')).toBe('event');
  136. expect(crossing('typescript', 'javascript')).toBeNull();
  137. expect(crossing('swift', 'objc')).toBeNull();
  138. });
  139. it('store files', () => {
  140. expect(isStoreFile('src/storage/capture.storage.ts')).toBe(true);
  141. expect(isStoreFile('src/stores/user.ts')).toBe(true);
  142. expect(isStoreFile('src/features/cart/cart.slice.ts')).toBe(true);
  143. expect(isStoreFile('src/components/button.tsx')).toBe(false);
  144. expect(isStoreFile('src/restore/thing.ts')).toBe(false);
  145. });
  146. it('effects: a curated table, by reference text', () => {
  147. expect(effectCategory('client.post')).toBe('network');
  148. expect(effectCategory('fetch')).toBe('network');
  149. expect(effectCategory('AsyncStorage.setItem')).toBe('storage');
  150. expect(effectCategory('Linking.openURL')).toBe('device');
  151. expect(effectCategory('DdRum.addAction')).toBe('telemetry');
  152. expect(effectCategory('Math.max')).toBeNull();
  153. expect(effectCategory('i18n.t')).toBeNull();
  154. });
  155. });
  156. describe('buildSteps', () => {
  157. it('walks a screen through its handler, the bridge, the event, the store and the request', async () => {
  158. const review = cg.getNodesByKind('route').find((r) => r.name === '/capture/review')!;
  159. expect(review).toBeDefined();
  160. const payload = await buildSteps(cg, tmpDir, q({ anchor: review.id }));
  161. const byLabel = new Map(payload.steps.map((s) => [s.label, s]));
  162. const kinds = Object.fromEntries(payload.steps.map((s) => [s.label, s.kind]));
  163. expect(kinds['/capture/review']).toBe('screen');
  164. expect(payload.steps.find((s) => s.anchor)?.label).toBe('/capture/review');
  165. // The handler is wired to the tap, so it is a trigger; the call it makes
  166. // crosses into Swift, so that is a bridge; the Swift side's event lands
  167. // on the named listener; the listener writes the store, leaves the index
  168. // through `client.post`, and navigates home behind `unlimited`.
  169. expect(kinds['handleApprove']).toBe('trigger');
  170. expect(kinds['finalizeCaptureSession']).toBe('bridge');
  171. expect(kinds['handleZipComplete']).toBe('event');
  172. expect(byLabel.get('handleZipComplete')?.event).toBe('onZipComplete');
  173. expect(byLabel.get('handleZipComplete')?.events).toEqual(['onZipComplete']);
  174. expect(kinds['setZipUri']).toBe('store');
  175. // One box per (function, category): both calls the upload makes into the
  176. // network, labelled by the first and counting the rest.
  177. const network = payload.steps.find((s) => s.kind === 'effect' && s.effect?.category === 'network')!;
  178. expect(network.label).toBe('client.post +1');
  179. expect(network.effect?.apis).toEqual(['client.post', 'client.get']);
  180. expect(network.effect?.by.name).toBe('uploadARCapture');
  181. expect(kinds['/']).toBe('screen');
  182. // Another screen is a boundary: drawn, marked, not entered.
  183. expect(byLabel.get('/')?.cut).toBe('screen');
  184. const link = (from: string, to: string) =>
  185. payload.links.find((l) => l.from === byLabel.get(from)!.id && l.to === byLabel.get(to)!.id);
  186. const req = link('handleZipComplete', 'client.post +1');
  187. expect(link('/capture/review', 'handleApprove')?.kind).toBe('handler');
  188. expect(link('handleApprove', 'finalizeCaptureSession')?.kind).toBe('bridge');
  189. const evt = link('finalizeCaptureSession', 'handleZipComplete');
  190. expect(evt?.kind).toBe('event');
  191. expect(evt?.synthesized).toBe(true);
  192. expect(evt?.via.map((v) => v.name)).toEqual(['emitZipComplete']);
  193. expect(evt?.when).toBe('result');
  194. expect(evt?.label).toContain('event onZipComplete');
  195. const storeLink = link('handleZipComplete', 'setZipUri');
  196. expect(storeLink?.kind).toBe('store');
  197. // Every call-shaped site says what it passes.
  198. expect(storeLink?.sites[0]?.args).toBe('data.uri');
  199. expect(link('handleApprove', 'finalizeCaptureSession')?.sites[0]?.args).toBe('');
  200. // One call behind an effect box: the box says it. Several: the panel does.
  201. const alert = payload.steps.find((s) => s.kind === 'effect' && s.effect?.category === 'device')!;
  202. expect(alert.label).toBe("Alert.alert('Uploaded', data.uri, […])");
  203. expect(network.label).toBe('client.post +1');
  204. expect(req?.sites.map((s) => `${s.text}(${s.args})`)).toEqual(["client.post('/frames', { uri })", "client.get('/frames/status')"]);
  205. expect(req?.kind).toBe('effect');
  206. expect(req?.via.map((v) => v.name)).toEqual(['uploadARCapture']);
  207. const nav = link('handleZipComplete', '/');
  208. expect(nav?.kind).toBe('navigates');
  209. expect(nav?.when).toBe('unlimited');
  210. expect(nav?.sites[0]?.text).toBe('replace /');
  211. // Every site carries the whole condition it runs under — one scenario each.
  212. expect(nav?.sites[0]?.when).toBe('unlimited');
  213. expect(evt?.sites[0]?.when).toBe('result');
  214. expect(storeLink?.sites[0]?.when).toBe('');
  215. // Rows: the anchor on 0, then one more step away each. The listener is
  216. // registered BY the screen (`addListener('onZipComplete', handleZipComplete)`),
  217. // so it sits one step from the anchor as a handler and the native event
  218. // arrives at it from further down — a link back up the picture — and
  219. // names the event on the box.
  220. expect(byLabel.get('/capture/review')?.depth).toBe(0);
  221. expect(byLabel.get('handleApprove')?.depth).toBe(1);
  222. expect(byLabel.get('finalizeCaptureSession')?.depth).toBe(2);
  223. expect(byLabel.get('handleZipComplete')?.depth).toBe(1);
  224. expect(link('/capture/review', 'handleZipComplete')?.kind).toBe('handler');
  225. expect(network.depth).toBe(2);
  226. expect(payload.through).toBe(false);
  227. expect(payload.truncated).toEqual({ steps: 0, hubs: 0, chrome: 0 });
  228. // No cap fired; the only thing not entered is the other screen.
  229. expect(payload.steps.filter((s) => s.cut !== null).map((s) => [s.label, s.cut])).toEqual([['/', 'screen']]);
  230. });
  231. it('walks through a memo-wrapped component into the screen body', async () => {
  232. const capture = cg.getNodesByKind('route').find((r) => r.name === '/capture')!;
  233. const payload = await buildSteps(cg, tmpDir, q({ anchor: capture.id }));
  234. const kinds = Object.fromEntries(payload.steps.map((s) => [s.label, s.kind]));
  235. // The wrapper and the component are render hops, folded into the link;
  236. // the handler is the first box, the native call the next.
  237. expect(kinds['handleOpen']).toBe('trigger');
  238. expect(kinds['finalizeCaptureSession']).toBe('bridge');
  239. const toHandler = payload.links.find((l) => l.to === payload.steps.find((s) => s.label === 'handleOpen')!.id)!;
  240. expect(toHandler.via.map((v) => v.name)).toEqual(['MemoizedCaptureComponent', 'CaptureComponent']);
  241. expect(payload.steps.map((s) => s.label)).not.toContain('CaptureComponent');
  242. });
  243. it('enters other screens when asked to continue through them', async () => {
  244. const review = cg.getNodesByKind('route').find((r) => r.name === '/capture/review')!;
  245. const payload = await buildSteps(cg, tmpDir, q({ anchor: review.id, through: '1' }));
  246. expect(payload.through).toBe(true);
  247. expect(payload.steps.find((s) => s.label === '/')?.cut).toBeNull();
  248. });
  249. it('anchors by name, prefers the screen, and lists the rest as ambiguous', async () => {
  250. const payload = await buildSteps(cg, tmpDir, q({ symbol: 'handleApprove' }));
  251. expect(payload.anchor.name).toBe('handleApprove');
  252. expect(payload.steps[0]?.kind).toBe('anchor');
  253. expect(payload.steps.map((s) => s.label)).toContain('finalizeCaptureSession');
  254. });
  255. it('a depth cap is announced on the step it stopped at', async () => {
  256. const review = cg.getNodesByKind('route').find((r) => r.name === '/capture/review')!;
  257. const payload = await buildSteps(cg, tmpDir, q({ anchor: review.id, depth: '2' }));
  258. // The bridge is two steps out: drawn, not explored — and says so.
  259. const bridge = payload.steps.find((s) => s.label === 'finalizeCaptureSession')!;
  260. expect(bridge.cut).toBe('depth');
  261. expect(payload.links.some((l) => l.kind === 'event')).toBe(false);
  262. // The listener still sits one step out, so the event step keeps its
  263. // handler kind: nothing arrived at it from native within the cap.
  264. expect(payload.steps.find((s) => s.label === 'handleZipComplete')?.kind).toBe('trigger');
  265. });
  266. it('refuses a missing anchor and an unknown id', async () => {
  267. await expect(buildSteps(cg, tmpDir, q({}))).rejects.toThrow(/anchor/);
  268. await expect(buildSteps(cg, tmpDir, q({ anchor: 'function:nope' }))).rejects.toThrow(/No symbol/);
  269. await expect(buildSteps(cg, tmpDir, q({ symbol: 'nothingNamedThis' }))).rejects.toThrow(/Nothing/);
  270. });
  271. });