sveltekit-router.test.ts 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220
  1. /**
  2. * SvelteKit as a Screens app (`src/resolution/frameworks/sveltekit-router.ts`,
  3. * `src/resolution/sveltekit-link-synthesizer.ts`): the `+page.svelte` routes
  4. * `frameworks/svelte.ts` names, and the navigation between them — `goto` in
  5. * the browser, `redirect(status, path)` from a load or an action, and the
  6. * plain `<a href>` that IS a link in a SvelteKit app.
  7. *
  8. * The fixture is the SvelteKit realworld app's shape. Mirrors
  9. * `react-router.test.ts`.
  10. */
  11. import { describe, it, expect, beforeAll, afterAll } from 'vitest';
  12. import * as fs from 'fs';
  13. import * as os from 'os';
  14. import * as path from 'path';
  15. import { CodeGraph } from '../src';
  16. import { initGrammars, loadAllGrammars } from '../src/extraction/grammars';
  17. import { buildScreens } from '../src/ui-server/api/screens';
  18. import { svelteResolver } from '../src/resolution/frameworks/svelte';
  19. import { svelteKitHrefArgument } from '../src/resolution/frameworks/sveltekit-router';
  20. import type { Node } from '../src/types';
  21. // =============================================================================
  22. // Which file is a URL, and which argument is the destination
  23. // =============================================================================
  24. describe('sveltekit: only a +page.svelte is a route', () => {
  25. const routeNames = (filePath: string): string[] =>
  26. svelteResolver.extract!(filePath, '').nodes.filter((n) => n.kind === 'route').map((n) => n.name);
  27. it('a page is its directory', () => {
  28. expect(routeNames('src/routes/+page.svelte')).toEqual(['/']);
  29. expect(routeNames('src/routes/login/+page.svelte')).toEqual(['/login']);
  30. expect(routeNames('src/routes/article/[slug]/+page.svelte')).toEqual(['/article/:slug']);
  31. });
  32. it.each(['src/routes/+layout.svelte', 'src/routes/+error.svelte', 'src/routes/profile/+layout.svelte'])(
  33. '%s sits at a page’s address without being one',
  34. (file) => {
  35. expect(routeNames(file)).toEqual([]);
  36. }
  37. );
  38. });
  39. describe('sveltekit: which argument carries the path', () => {
  40. it('goto takes it first; redirect takes the status first', () => {
  41. expect(svelteKitHrefArgument('goto')).toBe(0);
  42. expect(svelteKitHrefArgument('redirect')).toBe(1);
  43. expect(svelteKitHrefArgument('push')).toBeNull();
  44. });
  45. });
  46. // =============================================================================
  47. // The whole picture, indexed
  48. // =============================================================================
  49. describe('sveltekit: a routed app end to end', () => {
  50. let tmpDir: string;
  51. let cg: CodeGraph;
  52. function write(rel: string, content: string): void {
  53. const full = path.join(tmpDir, rel);
  54. fs.mkdirSync(path.dirname(full), { recursive: true });
  55. fs.writeFileSync(full, content);
  56. }
  57. beforeAll(async () => {
  58. await initGrammars();
  59. await loadAllGrammars();
  60. tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-sveltekit-'));
  61. write('package.json', JSON.stringify({ name: 'conduit', devDependencies: { '@sveltejs/kit': '2', svelte: '5' } }));
  62. write(
  63. 'src/routes/+layout.svelte',
  64. '<script>\n export let data\n</script>\n' +
  65. '<nav>\n' +
  66. ' <a href="/">Home</a>\n' +
  67. ' <a href="/login">Sign in</a>\n' +
  68. ' <a href="/settings">Settings</a>\n' +
  69. ' <a href="https://example.com">Elsewhere</a>\n' +
  70. '</nav>\n' +
  71. '<slot />\n'
  72. );
  73. write(
  74. 'src/routes/+page.svelte',
  75. '<script>\n export let data\n</script>\n<h1>Conduit</h1>\n<a href="/register">Sign up</a>\n'
  76. );
  77. write('src/routes/login/+page.svelte', '<script>\n export let form\n</script>\n<a href="/register">Need an account?</a>\n');
  78. write(
  79. 'src/routes/login/+page.server.js',
  80. "import { redirect } from '@sveltejs/kit'\n" +
  81. 'export function load({ locals }) {\n' +
  82. " if (locals.user) redirect(307, '/')\n" +
  83. '}\n' +
  84. 'export const actions = {\n' +
  85. ' default: async ({ request, locals }) => {\n' +
  86. ' const user = await signIn(request)\n' +
  87. " if (!user) return { errors: ['bad login'] }\n" +
  88. " redirect(307, '/')\n" +
  89. ' }\n' +
  90. '}\n'
  91. );
  92. write('src/routes/register/+page.svelte', '<script>\n export let form\n</script>\n<a href="/login">Have an account?</a>\n');
  93. write('src/routes/settings/+page.svelte', '<script>\n export let data\n</script>\n<h1>Settings</h1>\n');
  94. write(
  95. 'src/routes/settings/+page.server.js',
  96. "import { redirect } from '@sveltejs/kit'\n" +
  97. 'export function load({ locals }) {\n' +
  98. " if (!locals.user) redirect(302, '/login')\n" +
  99. '}\n'
  100. );
  101. write(
  102. 'src/routes/editor/+page.svelte',
  103. '<script>\n' +
  104. " import { goto } from '$app/navigation'\n" +
  105. ' async function publish() {\n' +
  106. ' const article = await save()\n' +
  107. ' goto(`/article/${article.slug}`)\n' +
  108. ' }\n' +
  109. '</script>\n' +
  110. '<button on:click={publish}>Publish</button>\n'
  111. );
  112. write(
  113. 'src/routes/article/[slug]/+page.svelte',
  114. '<script>\n export let data\n</script>\n<a href="/editor">Edit</a>\n<a href="/profile/@{data.author}">Author</a>\n'
  115. );
  116. write('src/routes/profile/@[user]/+page.svelte', '<script>\n export let data\n</script>\n<h1>Profile</h1>\n');
  117. write('src/routes/profile/@[user]/+layout.svelte', '<script>\n export let data\n</script>\n<slot />\n');
  118. // The precision floor: a destination nothing serves, and a computed one.
  119. write(
  120. 'src/routes/nowhere/+page.server.js',
  121. "import { redirect } from '@sveltejs/kit'\n" +
  122. 'export function load({ url }) {\n' +
  123. " redirect(307, '/no-such-page')\n" +
  124. ' redirect(307, url.searchParams.get("next"))\n' +
  125. '}\n'
  126. );
  127. cg = CodeGraph.initSync(tmpDir);
  128. await cg.indexAll();
  129. });
  130. afterAll(() => {
  131. cg?.close();
  132. if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
  133. });
  134. const route = (name: string): Node => {
  135. const r = cg.getNodesByKind('route').find((r) => r.name === name);
  136. if (!r) throw new Error(`no route ${name}: ${cg.getNodesByKind('route').map((r) => r.name).join(', ')}`);
  137. return r;
  138. };
  139. const sym = (name: string, file?: string): Node => {
  140. const n = cg
  141. .getNodesByName(name)
  142. .find((n) => n.kind !== 'route' && n.kind !== 'file' && n.kind !== 'import' && (!file || n.filePath.includes(file)));
  143. if (!n) throw new Error(`no symbol ${name}${file ? ` in ${file}` : ''}`);
  144. return n;
  145. };
  146. const navs = (from: Node) => cg.getOutgoingEdges(from.id).filter((e) => e.kind === 'navigates');
  147. const hrefs = (from: Node) =>
  148. navs(from)
  149. .map((e) => (e.metadata as Record<string, unknown>).href as string)
  150. .sort();
  151. it('names one route per page, and a layout is not a second screen at the same address', () => {
  152. expect(cg.getNodesByKind('route').map((r) => r.name).sort()).toEqual([
  153. '/',
  154. '/article/:slug',
  155. '/editor',
  156. '/login',
  157. '/profile/@:user',
  158. '/register',
  159. '/settings',
  160. ]);
  161. });
  162. it('redirect takes its path from the SECOND argument, after the status', () => {
  163. const guard = navs(sym('load', 'settings'));
  164. expect(guard).toHaveLength(1);
  165. expect(guard[0]!.target).toBe(route('/login').id);
  166. expect(guard[0]!.metadata).toMatchObject({ href: '/login', navMethod: 'redirect' });
  167. expect(navs(sym('load', 'login'))[0]!.target).toBe(route('/').id);
  168. });
  169. it('goto with a template hole reaches the [slug] page', () => {
  170. const publish = navs(sym('publish'));
  171. expect(publish).toHaveLength(1);
  172. expect(publish[0]!.target).toBe(route('/article/:slug').id);
  173. expect(publish[0]!.metadata).toMatchObject({ href: '/article/${…}', navMethod: 'goto' });
  174. });
  175. it('an internal <a href> navigates from the component that renders it; an external one does not', () => {
  176. const article = sym('+page', 'article/[slug]');
  177. // `/profile/@{data.author}` is an interpolation, and reaches `/profile/@:user`.
  178. expect(hrefs(article)).toEqual(['/editor', '/profile/@${…}']);
  179. const link = navs(article).find((e) => (e.metadata as Record<string, unknown>).href === '/editor')!;
  180. expect(link.provenance).toBe('heuristic');
  181. expect(link.metadata).toMatchObject({ synthesizedBy: 'sveltekit-link', navMethod: 'a' });
  182. expect(navs(article).find((e) => e.target === route('/profile/@:user').id)).toBeDefined();
  183. // The layout's nav bar links out, and never to the external site.
  184. expect(hrefs(sym('+layout', 'routes/+layout'))).toEqual(['/', '/login', '/settings']);
  185. });
  186. it('a path no page serves and a computed one are left unresolved', () => {
  187. expect(navs(sym('load', 'nowhere'))).toEqual([]);
  188. });
  189. it('lands on the Screens tab as transitions between screens', async () => {
  190. const screens = await buildScreens(cg, tmpDir);
  191. expect(screens.routed).toBe(true);
  192. const at = (p: string) => screens.screens.find((s) => s.path === p)!;
  193. // One screen per address — a layout does not double them.
  194. expect(screens.screens.filter((s) => s.path === '/')).toHaveLength(1);
  195. expect(screens.links.find((l) => l.from === at('/settings').id && l.to === at('/login').id)).toBeDefined();
  196. const publish = screens.links.find((l) => l.from === at('/editor').id && l.to === at('/article/:slug').id)!;
  197. expect(publish).toBeDefined();
  198. expect(publish.sites[0]).toMatchObject({ href: '/article/${…}', method: 'goto' });
  199. expect(screens.links.find((l) => l.from === at('/article/:slug').id && l.to === at('/editor').id)).toBeDefined();
  200. expect(screens.dropped).toBe(0);
  201. });
  202. });