vue-router.test.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301
  1. /**
  2. * Vue Router as a Screens app (`src/resolution/frameworks/vue-router.ts`,
  3. * `src/resolution/vue-router-synthesizer.ts`): routes read out of
  4. * `createRouter({ routes: [...] })` and bound to the `.vue` view each names,
  5. * and the navigation between them — which in Vue is usually written by route
  6. * NAME rather than by path.
  7. *
  8. * The fixture is vue-realworld's shape: a `src/router/index.js` table of lazy
  9. * views, `router.push({ name })` from the script, `<router-link :to>` from the
  10. * template. Mirrors `react-router.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 { buildScreens } from '../src/ui-server/api/screens';
  19. import { parseVueRoutes, vueNavVerb, routeNameInExpression } from '../src/resolution/frameworks/vue-router';
  20. import type { Node } from '../src/types';
  21. // =============================================================================
  22. // Reading the routes array
  23. // =============================================================================
  24. const ROUTER_SOURCE =
  25. 'import { createRouter, createWebHistory } from "vue-router"\n' +
  26. 'const router = createRouter({\n' +
  27. ' history: createWebHistory(),\n' +
  28. ' routes: [\n' +
  29. ' {\n' +
  30. ' name: "home",\n' +
  31. ' path: "/",\n' +
  32. ' component: () => import("@/views/Home")\n' +
  33. ' },\n' +
  34. ' {\n' +
  35. ' name: "login",\n' +
  36. ' path: "/login",\n' +
  37. ' component: () => import("@/views/Login")\n' +
  38. ' },\n' +
  39. ' {\n' +
  40. ' name: "settings",\n' +
  41. ' path: "/settings",\n' +
  42. ' component: () => import("@/views/Settings"),\n' +
  43. ' meta: { requiresAuth: true }\n' +
  44. ' },\n' +
  45. ' {\n' +
  46. ' name: "profile",\n' +
  47. ' path: "/profile/:username",\n' +
  48. ' component: Profile,\n' +
  49. ' children: [\n' +
  50. ' { path: "favorites", component: Favorites }\n' +
  51. ' ]\n' +
  52. ' }\n' +
  53. ' ]\n' +
  54. '})\n' +
  55. 'export default router\n';
  56. describe('vue-router: parseVueRoutes', () => {
  57. const entries = parseVueRoutes(ROUTER_SOURCE);
  58. it('gives every entry its OWN name — the name is written above the path it belongs to', () => {
  59. expect(entries.map((e) => [e.name, e.path])).toEqual([
  60. ['home', '/'],
  61. ['login', '/login'],
  62. ['settings', '/settings'],
  63. ['profile', '/profile/:username'],
  64. ]);
  65. });
  66. it('reads the component from a lazy import and from an identifier', () => {
  67. expect(entries.map((e) => e.component)).toEqual(['Home', 'Login', 'Settings', 'Profile']);
  68. });
  69. it('skips a child route, whose path is relative to a parent this does not compose', () => {
  70. expect(entries.some((e) => e.path === 'favorites')).toBe(false);
  71. });
  72. it('is nothing on a file that declares no routes', () => {
  73. expect(parseVueRoutes('export const paths = [{ path: "/x" }]\n')).toEqual([]);
  74. expect(parseVueRoutes('const x = 1\n')).toEqual([]);
  75. });
  76. });
  77. describe('vue-router: navigation call names', () => {
  78. it.each([
  79. ['router.push', 'push'],
  80. ['router.replace', 'replace'],
  81. ['$router.push', 'push'],
  82. ['navigateTo', 'navigateTo'],
  83. ])('%s → %s', (name, verb) => {
  84. expect(vueNavVerb(name)).toBe(verb);
  85. });
  86. it.each(['push', 'replace', 'paths.push', 'list.replace', 'go', 'back'])(
  87. '%s is not a navigation — an unqualified push is an array’s',
  88. (name) => {
  89. expect(vueNavVerb(name)).toBeNull();
  90. }
  91. );
  92. it('reads the route name out of an object destination, and nothing out of a path one', () => {
  93. expect(routeNameInExpression('{ name: "login" }')).toBe('login');
  94. expect(routeNameInExpression("{ name: 'profile', params: { username } }")).toBe('profile');
  95. expect(routeNameInExpression('{ path: "/", query }')).toBeNull();
  96. expect(routeNameInExpression("'/login'")).toBeNull();
  97. });
  98. });
  99. // =============================================================================
  100. // The whole picture, indexed
  101. // =============================================================================
  102. describe('vue-router: a routed app end to end', () => {
  103. let tmpDir: string;
  104. let cg: CodeGraph;
  105. function write(rel: string, content: string): void {
  106. const full = path.join(tmpDir, rel);
  107. fs.mkdirSync(path.dirname(full), { recursive: true });
  108. fs.writeFileSync(full, content);
  109. }
  110. beforeAll(async () => {
  111. await initGrammars();
  112. await loadAllGrammars();
  113. tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-vue-router-'));
  114. write('package.json', JSON.stringify({ name: 'conduit', dependencies: { vue: '3', 'vue-router': '4' } }));
  115. write(
  116. 'src/router/index.js',
  117. 'import { createRouter, createWebHistory } from "vue-router"\n' +
  118. 'const router = createRouter({\n' +
  119. ' history: createWebHistory(),\n' +
  120. ' routes: [\n' +
  121. ' { name: "home", path: "/", component: () => import("@/views/Home") },\n' +
  122. ' { name: "login", path: "/login", component: () => import("@/views/Login") },\n' +
  123. ' { name: "register", path: "/register", component: () => import("@/views/Register") },\n' +
  124. ' { name: "settings", path: "/settings", component: () => import("@/views/Settings") },\n' +
  125. ' { name: "profile", path: "/profile/:username", component: () => import("@/views/Profile") }\n' +
  126. ' ]\n' +
  127. '})\n' +
  128. 'export default router\n'
  129. );
  130. write(
  131. 'src/views/Home.vue',
  132. '<template>\n' +
  133. ' <div><TheHeader /></div>\n' +
  134. '</template>\n' +
  135. '<script setup>\n' +
  136. 'import { useRouter } from "vue-router"\n' +
  137. 'import TheHeader from "@/components/TheHeader.vue"\n' +
  138. 'const router = useRouter()\n' +
  139. 'function goTo(tag) {\n' +
  140. ' router.push({ path: "/", query: { tag } })\n' +
  141. '}\n' +
  142. '</script>\n'
  143. );
  144. write(
  145. 'src/views/Login.vue',
  146. '<template>\n' +
  147. ' <form @submit="submit"><router-link :to="{ name: \'register\' }">Need an account?</router-link></form>\n' +
  148. '</template>\n' +
  149. '<script setup>\n' +
  150. 'import { useRouter } from "vue-router"\n' +
  151. 'const router = useRouter()\n' +
  152. 'function submit() {\n' +
  153. ' login().then(() => router.push({ name: "home" }))\n' +
  154. '}\n' +
  155. '</script>\n'
  156. );
  157. write(
  158. 'src/views/Register.vue',
  159. '<template>\n' +
  160. ' <router-link to="/login">Have an account?</router-link>\n' +
  161. '</template>\n' +
  162. '<script setup>\n' +
  163. 'const nothing = 1\n' +
  164. '</script>\n'
  165. );
  166. write(
  167. 'src/views/Settings.vue',
  168. '<template>\n' +
  169. ' <button @click="save">Save</button>\n' +
  170. '</template>\n' +
  171. '<script setup>\n' +
  172. 'import { useRouter } from "vue-router"\n' +
  173. 'const router = useRouter()\n' +
  174. 'const target = "/nowhere"\n' +
  175. 'function save(user) {\n' +
  176. ' router.push({ name: "profile", params: { username: user.username } })\n' +
  177. '}\n' +
  178. 'function bail() {\n' +
  179. ' router.push(target)\n' +
  180. '}\n' +
  181. '</script>\n'
  182. );
  183. write(
  184. 'src/views/Profile.vue',
  185. '<template>\n <div>Profile</div>\n</template>\n<script setup>\nconst x = 1\n</script>\n'
  186. );
  187. write(
  188. 'src/components/TheHeader.vue',
  189. '<template>\n' +
  190. ' <nav>\n' +
  191. ' <router-link :to="{ name: \'home\' }">Home</router-link>\n' +
  192. ' <router-link to="/settings">Settings</router-link>\n' +
  193. ' <a href="https://example.com">Elsewhere</a>\n' +
  194. ' </nav>\n' +
  195. '</template>\n' +
  196. '<script setup>\nconst y = 1\n</script>\n'
  197. );
  198. // The precision floor: an array's `push` with a string that IS a route.
  199. write('src/utils/trail.js', 'export function trail() {\n const paths = []\n paths.push("/login")\n return paths\n}\n');
  200. cg = CodeGraph.initSync(tmpDir);
  201. await cg.indexAll();
  202. });
  203. afterAll(() => {
  204. cg?.close();
  205. if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
  206. });
  207. const route = (name: string): Node => {
  208. const r = cg.getNodesByKind('route').find((r) => r.name === name);
  209. if (!r) throw new Error(`no route ${name}: ${cg.getNodesByKind('route').map((r) => r.name).join(', ')}`);
  210. return r;
  211. };
  212. const sym = (name: string): Node => {
  213. const n = cg.getNodesByName(name).find((n) => n.kind !== 'route' && n.kind !== 'file' && n.kind !== 'import');
  214. if (!n) throw new Error(`no symbol ${name}`);
  215. return n;
  216. };
  217. const navs = (from: Node) => cg.getOutgoingEdges(from.id).filter((e) => e.kind === 'navigates');
  218. const hrefs = (from: Node) =>
  219. navs(from)
  220. .map((e) => (e.metadata as Record<string, unknown>).href as string)
  221. .sort();
  222. it('names every route in the table and binds it to the .vue view it names', () => {
  223. expect(cg.getNodesByKind('route').map((r) => r.name).sort()).toEqual([
  224. '/',
  225. '/login',
  226. '/profile/:username',
  227. '/register',
  228. '/settings',
  229. ]);
  230. // The binding is a `calls` edge to the component, never the same-named
  231. // symbol a `references` edge would have found in the JS half of the app.
  232. const bound = cg.getOutgoingEdges(route('/login').id).find((e) => e.kind === 'calls');
  233. expect(cg.getNode(bound!.target)).toMatchObject({ name: 'Login', kind: 'component', filePath: 'src/views/Login.vue' });
  234. });
  235. it('router.push({ name }) reaches the route with that name', () => {
  236. const login = navs(sym('submit'));
  237. expect(login).toHaveLength(1);
  238. expect(login[0]!.target).toBe(route('/').id);
  239. expect(login[0]!.metadata).toMatchObject({ href: 'home', navMethod: 'push', by: 'name' });
  240. const save = navs(sym('save'));
  241. expect(save[0]!.target).toBe(route('/profile/:username').id);
  242. expect(save[0]!.metadata).toMatchObject({ href: 'profile', by: 'name' });
  243. });
  244. it('router.push({ path }) reaches the route with that path', () => {
  245. const goTo = navs(sym('goTo'));
  246. expect(goTo).toHaveLength(1);
  247. expect(goTo[0]!.target).toBe(route('/').id);
  248. expect(goTo[0]!.metadata).toMatchObject({ href: '/', navMethod: 'push' });
  249. expect((goTo[0]!.metadata as Record<string, unknown>).by).toBeUndefined();
  250. });
  251. it('a <router-link> navigates from the component that renders it, by name or by path', () => {
  252. expect(hrefs(sym('TheHeader'))).toEqual(['/settings', 'home']);
  253. const byHref = new Map(navs(sym('TheHeader')).map((e) => [(e.metadata as Record<string, unknown>).href, e]));
  254. expect(byHref.get('home')!.target).toBe(route('/').id);
  255. expect(byHref.get('home')!.provenance).toBe('heuristic');
  256. expect(byHref.get('home')!.metadata).toMatchObject({ synthesizedBy: 'vue-router-link', navMethod: 'link', by: 'name' });
  257. expect(byHref.get('/settings')!.target).toBe(route('/settings').id);
  258. expect(hrefs(sym('Register'))).toEqual(['/login']);
  259. });
  260. it('a destination nothing declares is left unresolved, and an array’s push is never claimed', () => {
  261. // `router.push(target)` where target is "/nowhere" — a real string, no route.
  262. expect(navs(sym('bail'))).toEqual([]);
  263. expect(navs(sym('trail'))).toEqual([]);
  264. });
  265. it('lands on the Screens tab as transitions between screens', async () => {
  266. const screens = await buildScreens(cg, tmpDir);
  267. expect(screens.routed).toBe(true);
  268. expect(screens.screens.map((s) => s.path).sort()).toEqual(['/', '/login', '/profile/:username', '/register', '/settings']);
  269. const at = (p: string) => screens.screens.find((s) => s.path === p)!;
  270. expect(at('/login').component?.name).toBe('Login');
  271. const toProfile = screens.links.find((l) => l.from === at('/settings').id && l.to === at('/profile/:username').id)!;
  272. expect(toProfile).toBeDefined();
  273. expect(toProfile.via.map((v) => v.name)).toEqual(['save']);
  274. expect(toProfile.sites[0]).toMatchObject({ href: 'profile', method: 'push' });
  275. expect(screens.links.find((l) => l.from === at('/login').id && l.to === at('/register').id)).toBeDefined();
  276. expect(screens.dropped).toBe(0);
  277. });
  278. });