nextjs.test.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324
  1. /**
  2. * Next.js as a Screens app (`src/resolution/frameworks/nextjs.ts`,
  3. * `src/resolution/next-router-synthesizer.ts`): pages from files, route
  4. * handlers as endpoints, navigation from `<Link>`, `router.push`, `redirect`
  5. * and `NextResponse.redirect`, and the Screens / Steps pictures they make.
  6. * Mirrors `expo-router.test.ts`.
  7. */
  8. import { describe, it, expect, beforeAll, afterAll } from 'vitest';
  9. import * as fs from 'fs';
  10. import * as os from 'os';
  11. import * as path from 'path';
  12. import { CodeGraph } from '../src';
  13. import { initGrammars, loadAllGrammars } from '../src/extraction/grammars';
  14. import { buildScreens } from '../src/ui-server/api/screens';
  15. import { buildSteps } from '../src/ui-server/api/steps';
  16. import { nextjsResolver, nextRouteForFile, nextNavVerb } from '../src/resolution/frameworks/nextjs';
  17. import type { Node } from '../src/types';
  18. // =============================================================================
  19. // Route paths from file names
  20. // =============================================================================
  21. describe('nextjs: nextRouteForFile', () => {
  22. it.each([
  23. ['app/page.tsx', 'page', '/', ''],
  24. ['src/app/users/page.tsx', 'page', '/users', ''],
  25. ['apps/web/app/(marketing)/about/page.tsx', 'page', '/about', 'apps/web/'],
  26. ['app/blog/[slug]/page.tsx', 'page', '/blog/:slug', ''],
  27. ['app/docs/[...all]/page.tsx', 'page', '/docs/:all*', ''],
  28. ['app/docs/[[...all]]/page.jsx', 'page', '/docs/:all*', ''],
  29. ['app/api/users/route.ts', 'handler', '/api/users', ''],
  30. ['app/api/users/[id]/route.ts', 'handler', '/api/users/:id', ''],
  31. ['pages/index.tsx', 'page', '/', ''],
  32. ['pages/about.tsx', 'page', '/about', ''],
  33. ['src/pages/blog/[slug].tsx', 'page', '/blog/:slug', ''],
  34. ['pages/api/users.ts', 'api', '/api/users', ''],
  35. ['apps/web/pages/api/users/[id].ts', 'api', '/api/users/:id', 'apps/web/'],
  36. ])('%s → %s %s (root %s)', (file, kind, route, root) => {
  37. expect(nextRouteForFile(file)).toEqual({ kind, path: route, root });
  38. });
  39. it.each([
  40. 'app/layout.tsx',
  41. 'app/loading.tsx',
  42. 'app/users/error.tsx',
  43. 'app/@modal/photo/page.tsx',
  44. 'app/(.)photo/[id]/page.tsx',
  45. 'pages/_app.tsx',
  46. 'pages/_document.tsx',
  47. 'src/pages/vite.config.ts',
  48. 'apps/nextjs-pages/next.config.mjs',
  49. 'app/users/__tests__/page.tsx',
  50. 'src/components/button.tsx',
  51. ])('%s is not a route', (file) => {
  52. expect(nextRouteForFile(file)).toBeNull();
  53. });
  54. });
  55. describe('nextjs: extract', () => {
  56. it('a page is a route named by its path, calling its default export', () => {
  57. const { nodes, references } = nextjsResolver.extract!('app/users/page.tsx', "export default function UsersPage() {\n return null\n}\n");
  58. expect(nodes).toHaveLength(1);
  59. expect(nodes[0]).toMatchObject({ kind: 'route', name: '/users', language: 'tsx' });
  60. expect(references).toEqual([expect.objectContaining({ fromNodeId: nodes[0]!.id, referenceName: 'UsersPage', referenceKind: 'calls', line: 1 })]);
  61. });
  62. it('a route handler file is one endpoint per exported method, each naming its function', () => {
  63. const src = "import { NextResponse } from 'next/server'\nexport async function GET() {\n return NextResponse.json([])\n}\nexport const POST = async (req) => {\n return NextResponse.json({}, { status: 201 })\n}\n";
  64. const { nodes, references } = nextjsResolver.extract!('app/api/users/route.ts', src);
  65. expect(nodes.map((n) => n.name)).toEqual(['GET /api/users', 'POST /api/users']);
  66. expect(nodes.map((n) => n.startLine)).toEqual([2, 5]);
  67. expect(references.map((r) => [r.referenceName, r.referenceKind])).toEqual([
  68. ['GET', 'references'],
  69. ['POST', 'references'],
  70. ]);
  71. });
  72. it('a Pages Router API file is ANY on its path, bound to the default export', () => {
  73. const { nodes, references } = nextjsResolver.extract!('pages/api/users.ts', 'export default async function handler(req, res) {\n res.status(200).json([])\n}\n');
  74. expect(nodes.map((n) => n.name)).toEqual(['ANY /api/users']);
  75. expect(references[0]).toMatchObject({ referenceName: 'handler', referenceKind: 'references' });
  76. });
  77. it('emits nothing for a layout or a component file', () => {
  78. expect(nextjsResolver.extract!('app/layout.tsx', 'export default function L() {}').nodes).toHaveLength(0);
  79. expect(nextjsResolver.extract!('components/nav.tsx', 'export default function Nav() {}').nodes).toHaveLength(0);
  80. });
  81. it('claims the navigation calls and names their verb', () => {
  82. expect(nextNavVerb('router.push')).toBe('push');
  83. expect(nextNavVerb('router.replace')).toBe('replace');
  84. expect(nextNavVerb('redirect')).toBe('redirect');
  85. expect(nextNavVerb('permanentRedirect')).toBe('permanentRedirect');
  86. expect(nextNavVerb('NextResponse.redirect')).toBe('response.redirect');
  87. expect(nextNavVerb('router.back')).toBeNull();
  88. expect(nextNavVerb('fetch')).toBeNull();
  89. expect(nextjsResolver.claimsReference!('redirect')).toBe(true);
  90. expect(nextjsResolver.claimsReference!('Redirect')).toBe(false);
  91. });
  92. });
  93. // =============================================================================
  94. // End to end: a small App Router site
  95. // =============================================================================
  96. describe('nextjs: end to end', () => {
  97. let tmpDir: string;
  98. let cg: CodeGraph;
  99. function write(rel: string, content: string): void {
  100. const full = path.join(tmpDir, rel);
  101. fs.mkdirSync(path.dirname(full), { recursive: true });
  102. fs.writeFileSync(full, content);
  103. }
  104. beforeAll(async () => {
  105. await initGrammars();
  106. await loadAllGrammars();
  107. tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-nextjs-'));
  108. write('package.json', JSON.stringify({ name: 'site', dependencies: { next: '15', react: '19', '@prisma/client': '5' } }));
  109. write('lib/db.ts', "import { PrismaClient } from '@prisma/client'\nexport const prisma = new PrismaClient()\n");
  110. write('app/layout.tsx', 'export default function RootLayout({ children }) {\n return children\n}\n');
  111. write(
  112. 'app/page.tsx',
  113. "import Link from 'next/link'\n" +
  114. 'export default function Home() {\n' +
  115. ' return (\n' +
  116. ' <main>\n' +
  117. ' <Link href="/users">Users</Link>\n' +
  118. ' <a href="/login">Log in</a>\n' +
  119. ' <a href="https://example.com">Elsewhere</a>\n' +
  120. ' </main>\n' +
  121. ' )\n' +
  122. '}\n'
  123. );
  124. write('app/login/page.tsx', 'export default function LoginPage() {\n return null\n}\n');
  125. write(
  126. 'app/users/page.tsx',
  127. "import { NewUserForm } from '../../components/new-user-form'\n" +
  128. "import { prisma } from '../../lib/db'\n" +
  129. 'export default async function UsersPage() {\n' +
  130. ' const users = await prisma.user.findMany()\n' +
  131. ' return <NewUserForm count={users.length} />\n' +
  132. '}\n'
  133. );
  134. write('app/users/[id]/page.tsx', 'export default function UserPage({ params }) {\n return <a href="/users">Back</a>\n}\n');
  135. write(
  136. 'components/new-user-form.tsx',
  137. "'use client'\n" +
  138. "import { useCallback, useState } from 'react'\n" +
  139. "import { useRouter } from 'next/navigation'\n" +
  140. "import { createUserAction } from '../app/actions'\n" +
  141. 'export function NewUserForm({ count }) {\n' +
  142. " const [email, setEmail] = useState('')\n" +
  143. ' const router = useRouter()\n' +
  144. ' const handleSubmit = useCallback(async (e) => {\n' +
  145. ' e.preventDefault()\n' +
  146. ' const user = await createUserAction({ email })\n' +
  147. ' if (user.ok) router.push(`/users/${user.id}`)\n' +
  148. ' }, [email])\n' +
  149. ' return <form onSubmit={handleSubmit}><input value={email} onChange={(e) => setEmail(e.target.value)} /></form>\n' +
  150. '}\n'
  151. );
  152. write(
  153. 'app/actions.ts',
  154. "'use server'\n" +
  155. "import { redirect } from 'next/navigation'\n" +
  156. "import { prisma } from '../lib/db'\n" +
  157. 'export async function createUserAction(data) {\n' +
  158. ' const user = await prisma.user.create({ data })\n' +
  159. " if (!user.verified) redirect('/users')\n" +
  160. ' return { ok: true, id: user.id }\n' +
  161. '}\n'
  162. );
  163. write(
  164. 'app/api/users/route.ts',
  165. "import { NextResponse } from 'next/server'\n" +
  166. "import { prisma } from '../../../lib/db'\n" +
  167. 'export async function GET() {\n' +
  168. ' return NextResponse.json(await prisma.user.findMany())\n' +
  169. '}\n' +
  170. 'export async function POST(req) {\n' +
  171. ' const data = await req.json()\n' +
  172. ' const user = await prisma.user.create({ data })\n' +
  173. ' return NextResponse.json(user, { status: 201 })\n' +
  174. '}\n'
  175. );
  176. write(
  177. 'middleware.ts',
  178. "import { NextResponse } from 'next/server'\n" +
  179. 'export function middleware(req) {\n' +
  180. " if (!req.cookies.get('session')) {\n" +
  181. " return NextResponse.redirect(new URL('/login', req.url))\n" +
  182. ' }\n' +
  183. ' return NextResponse.next()\n' +
  184. '}\n'
  185. );
  186. cg = CodeGraph.initSync(tmpDir);
  187. await cg.indexAll();
  188. });
  189. afterAll(() => {
  190. cg?.close();
  191. if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
  192. });
  193. const route = (name: string): Node => {
  194. const r = cg.getNodesByKind('route').find((r) => r.name === name);
  195. if (!r) throw new Error(`no route ${name}: ${cg.getNodesByKind('route').map((r) => r.name).join(', ')}`);
  196. return r;
  197. };
  198. const sym = (name: string): Node => {
  199. const n = cg.getNodesByName(name).find((n) => n.kind !== 'route' && n.kind !== 'file' && n.kind !== 'import');
  200. if (!n) throw new Error(`no symbol ${name}`);
  201. return n;
  202. };
  203. const navs = (from: Node) => cg.getOutgoingEdges(from.id).filter((e) => e.kind === 'navigates');
  204. it('names every page and endpoint, and binds a page to its component and an endpoint to its function', () => {
  205. expect(cg.getNodesByKind('route').map((r) => r.name).sort()).toEqual(['/', '/login', '/users', '/users/:id', 'GET /api/users', 'POST /api/users']);
  206. const home = cg.getOutgoingEdges(route('/').id).find((e) => e.kind === 'calls');
  207. expect(cg.getNode(home!.target)?.name).toBe('Home');
  208. const post = cg.getOutgoingEdges(route('POST /api/users').id).find((e) => e.kind === 'references');
  209. expect(cg.getNode(post!.target)).toMatchObject({ name: 'POST', kind: 'function', filePath: 'app/api/users/route.ts' });
  210. });
  211. it('a <Link href> and an internal <a href> navigate from the component that renders them; an external one does not', () => {
  212. const fromHome = navs(sym('Home'));
  213. const byHref = new Map(fromHome.map((e) => [(e.metadata as Record<string, unknown>).href, e]));
  214. expect([...byHref.keys()].sort()).toEqual(['/login', '/users']);
  215. const users = byHref.get('/users')!;
  216. expect(users.target).toBe(route('/users').id);
  217. expect(users.provenance).toBe('heuristic');
  218. expect(users.metadata).toEqual({ synthesizedBy: 'next-link', href: '/users', navMethod: 'link', registeredAt: 'app/page.tsx:5' });
  219. expect((byHref.get('/login')!.metadata as Record<string, unknown>).navMethod).toBe('a');
  220. expect(navs(sym('UserPage')).map((e) => cg.getNode(e.target)?.name)).toEqual(['/users']);
  221. });
  222. it('router.push with a template hole reaches the [id] page; redirect() and NextResponse.redirect(new URL(…)) reach theirs', () => {
  223. const push = navs(sym('handleSubmit'));
  224. expect(push).toHaveLength(1);
  225. expect(push[0]!.target).toBe(route('/users/:id').id);
  226. expect(push[0]!.metadata).toMatchObject({ href: '/users/${…}', navMethod: 'push', refKind: 'calls' });
  227. const redirect = navs(sym('createUserAction'));
  228. expect(redirect).toHaveLength(1);
  229. expect(redirect[0]!.target).toBe(route('/users').id);
  230. expect(redirect[0]!.metadata).toMatchObject({ href: '/users', navMethod: 'redirect' });
  231. const guard = navs(sym('middleware'));
  232. expect(guard).toHaveLength(1);
  233. expect(guard[0]!.target).toBe(route('/login').id);
  234. expect(guard[0]!.metadata).toMatchObject({ href: '/login', navMethod: 'response.redirect' });
  235. });
  236. it('lands on the Screens tab: the entry page, its links, and the form’s push attributed back to its page with the condition', async () => {
  237. const screens = await buildScreens(cg, tmpDir);
  238. expect(screens.routed).toBe(true);
  239. const home = screens.screens.find((s) => s.path === '/')!;
  240. expect(screens.entry).toBe(home.id);
  241. expect(home.component?.name).toBe('Home');
  242. const users = screens.screens.find((s) => s.path === '/users')!;
  243. const user = screens.screens.find((s) => s.path === '/users/:id')!;
  244. const link = screens.links.find((l) => l.from === home.id && l.to === users.id)!;
  245. expect(link.via).toEqual([]);
  246. expect(link.synthesized).toBe(true);
  247. // Markup, not a return value: the destination is written right there, so
  248. // the site keeps its own verb rather than reading as a helper's return.
  249. expect(link.sites[0]).toMatchObject({ href: '/users', method: 'link' });
  250. const push = screens.links.find((l) => l.from === users.id && l.to === user.id)!;
  251. expect(push).toBeDefined();
  252. expect(push.via.map((v) => v.name)).toEqual(['NewUserForm', 'handleSubmit']);
  253. expect(push.when).toBe('user.ok');
  254. expect(push.sites[0]).toMatchObject({ href: '/users/${…}', method: 'push' });
  255. // The middleware's redirect starts from no page: an origin.
  256. expect(screens.origins.map((o) => o.node.name)).toContain('middleware');
  257. expect(screens.dropped).toBe(0);
  258. });
  259. it('an endpoint is not a screen — the Screens tab is pages, Entry points is every route', async () => {
  260. const screens = await buildScreens(cg, tmpDir);
  261. // `GET /api/users` and `POST /api/users` are routes, and they are on the
  262. // Entry points list — but a request is not somewhere a user can be.
  263. expect(screens.screens.map((s) => s.path).sort()).toEqual(['/', '/login', '/users', '/users/:id']);
  264. expect(cg.getNodesByKind('route').some((r) => r.name === 'POST /api/users')).toBe(true);
  265. });
  266. it('a page’s Steps picture fires from its load, crosses to the server action, and draws the pages it leads to as boundaries', async () => {
  267. const p = await buildSteps(cg, tmpDir, new URLSearchParams({ anchor: route('/users').id }));
  268. expect(p.project).toBe('web');
  269. const anchor = p.steps.find((s) => s.anchor)!;
  270. expect(anchor.kind).toBe('screen');
  271. expect(anchor.sub).toBe('UsersPage');
  272. expect(anchor.trigger).toEqual({ kind: 'load', name: 'GET', of: '/users', in: 'page.tsx' });
  273. const loadRead = p.steps.find((s) => s.kind === 'effect' && s.effect?.category === 'database' && s.effect.by.name === 'UsersPage')!;
  274. expect(loadRead.label).toBe('prisma.user.findMany()');
  275. const handler = p.steps.find((s) => s.kind === 'trigger' && s.node?.name === 'handleSubmit')!;
  276. expect(handler.trigger).toMatchObject({ kind: 'prop', name: 'onSubmit', of: 'form' });
  277. const action = p.steps.find((s) => s.node?.name === 'createUserAction')!;
  278. expect(action.kind).toBe('bridge');
  279. const toAction = p.links.find((l) => l.to === action.id)!;
  280. expect(toAction.label).toContain('server action');
  281. const write = p.steps.find((s) => s.kind === 'effect' && s.effect?.category === 'database' && s.effect.by.name === 'createUserAction')!;
  282. expect(write.label).toBe('prisma.user.create({ data })');
  283. const detail = p.steps.find((s) => s.kind === 'screen' && s.screen?.path === '/users/:id')!;
  284. expect(detail.cut).toBe('screen');
  285. const toDetail = p.links.find((l) => l.to === detail.id)!;
  286. expect(toDetail.kind).toBe('navigates');
  287. expect(toDetail.when).toBe('user.ok');
  288. expect(toDetail.sites[0]!.text).toBe('push /users/${…}');
  289. const back = p.links.find((l) => l.from === action.id && l.to === anchor.id)!;
  290. expect(back.sites[0]).toMatchObject({ text: 'redirect /users', when: '!user.verified' });
  291. });
  292. it('an endpoint anchors as any server route does', async () => {
  293. const p = await buildSteps(cg, tmpDir, new URLSearchParams({ anchor: route('POST /api/users').id }));
  294. const anchor = p.steps.find((s) => s.anchor)!;
  295. expect(anchor.sub).toBe('POST');
  296. expect(anchor.trigger).toEqual({ kind: 'request', name: 'POST', of: '/api/users', in: 'route.ts' });
  297. const db = p.steps.find((s) => s.kind === 'effect' && s.effect?.category === 'database')!;
  298. expect(db.effect).toMatchObject({ model: 'user', access: 'write', by: { name: 'POST' } });
  299. const res = p.steps.find((s) => s.kind === 'effect' && s.effect?.category === 'response')!;
  300. expect(res.label).toBe('201');
  301. });
  302. });