Files
codegraph/__tests__/nextjs.test.ts
T
Colby McHenryandClaude Fable 5 9c6bc23b21 feat(screens): Next.js as a Screens app — pages, route handlers, links and redirects
- frameworks/nextjs.ts (split out of react.ts): App Router app/**/page.tsx and Pages Router pages → routes named by path ((group) stripped, [slug] → :slug, [...all] → :all*), bound to the default export; app/**/route.ts exports → METHOD /api/… endpoints referencing their functions; pages/api → ANY; resolve() claims router.push/replace/prefetch, redirect/permanentRedirect and NextResponse.redirect(new URL(…)) into navigates edges via the Expo href readers, against a Next-only route table gated on the app's root
- next-router-synthesizer.ts: <Link href> and internal <a href> → dashed navigates edges from the component (next-link, registeredAt)
- expo-router.ts: href readers exported; matcher accepts :param / :all* segments
- steps.ts: a Next page's own work fires from page load; a Next page makes the project a web app; {status: 201} read off the call site (branch-guards CallSiteText.status) for response rows
- frameworks/package-deps.ts: nested package.json files probed on disk (getAllFiles lists only sources); Express/React/Expo/Nest detectors use it; routing manifest names constant handlers
- tests: nextjs.test.ts (file→route rules, extract, verbs, end to end with Screens and Steps); frameworks.test.ts Next cases moved to the Next resolver
- docs: CHANGELOG, spec §3.12 frameworks paragraph, CLAUDE.md, synthesis doc, plan P4 built, playbook rows for Next / MERN / Nest channels

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01REFyW9hmNrxhwN5wxRoAkC
2026-08-28 14:40:14 -05:00

315 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Next.js as a Screens app (`src/resolution/frameworks/nextjs.ts`,
* `src/resolution/next-router-synthesizer.ts`): pages from files, route
* handlers as endpoints, navigation from `<Link>`, `router.push`, `redirect`
* and `NextResponse.redirect`, and the Screens / Steps pictures they make.
* Mirrors `expo-router.test.ts`.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { CodeGraph } from '../src';
import { initGrammars, loadAllGrammars } from '../src/extraction/grammars';
import { buildScreens } from '../src/ui-server/api/screens';
import { buildSteps } from '../src/ui-server/api/steps';
import { nextjsResolver, nextRouteForFile, nextNavVerb } from '../src/resolution/frameworks/nextjs';
import type { Node } from '../src/types';
// =============================================================================
// Route paths from file names
// =============================================================================
describe('nextjs: nextRouteForFile', () => {
it.each([
['app/page.tsx', 'page', '/', ''],
['src/app/users/page.tsx', 'page', '/users', ''],
['apps/web/app/(marketing)/about/page.tsx', 'page', '/about', 'apps/web/'],
['app/blog/[slug]/page.tsx', 'page', '/blog/:slug', ''],
['app/docs/[...all]/page.tsx', 'page', '/docs/:all*', ''],
['app/docs/[[...all]]/page.jsx', 'page', '/docs/:all*', ''],
['app/api/users/route.ts', 'handler', '/api/users', ''],
['app/api/users/[id]/route.ts', 'handler', '/api/users/:id', ''],
['pages/index.tsx', 'page', '/', ''],
['pages/about.tsx', 'page', '/about', ''],
['src/pages/blog/[slug].tsx', 'page', '/blog/:slug', ''],
['pages/api/users.ts', 'api', '/api/users', ''],
['apps/web/pages/api/users/[id].ts', 'api', '/api/users/:id', 'apps/web/'],
])('%s → %s %s (root %s)', (file, kind, route, root) => {
expect(nextRouteForFile(file)).toEqual({ kind, path: route, root });
});
it.each([
'app/layout.tsx',
'app/loading.tsx',
'app/users/error.tsx',
'app/@modal/photo/page.tsx',
'app/(.)photo/[id]/page.tsx',
'pages/_app.tsx',
'pages/_document.tsx',
'src/pages/vite.config.ts',
'apps/nextjs-pages/next.config.mjs',
'app/users/__tests__/page.tsx',
'src/components/button.tsx',
])('%s is not a route', (file) => {
expect(nextRouteForFile(file)).toBeNull();
});
});
describe('nextjs: extract', () => {
it('a page is a route named by its path, calling its default export', () => {
const { nodes, references } = nextjsResolver.extract!('app/users/page.tsx', "export default function UsersPage() {\n return null\n}\n");
expect(nodes).toHaveLength(1);
expect(nodes[0]).toMatchObject({ kind: 'route', name: '/users', language: 'tsx' });
expect(references).toEqual([expect.objectContaining({ fromNodeId: nodes[0]!.id, referenceName: 'UsersPage', referenceKind: 'calls', line: 1 })]);
});
it('a route handler file is one endpoint per exported method, each naming its function', () => {
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";
const { nodes, references } = nextjsResolver.extract!('app/api/users/route.ts', src);
expect(nodes.map((n) => n.name)).toEqual(['GET /api/users', 'POST /api/users']);
expect(nodes.map((n) => n.startLine)).toEqual([2, 5]);
expect(references.map((r) => [r.referenceName, r.referenceKind])).toEqual([
['GET', 'references'],
['POST', 'references'],
]);
});
it('a Pages Router API file is ANY on its path, bound to the default export', () => {
const { nodes, references } = nextjsResolver.extract!('pages/api/users.ts', 'export default async function handler(req, res) {\n res.status(200).json([])\n}\n');
expect(nodes.map((n) => n.name)).toEqual(['ANY /api/users']);
expect(references[0]).toMatchObject({ referenceName: 'handler', referenceKind: 'references' });
});
it('emits nothing for a layout or a component file', () => {
expect(nextjsResolver.extract!('app/layout.tsx', 'export default function L() {}').nodes).toHaveLength(0);
expect(nextjsResolver.extract!('components/nav.tsx', 'export default function Nav() {}').nodes).toHaveLength(0);
});
it('claims the navigation calls and names their verb', () => {
expect(nextNavVerb('router.push')).toBe('push');
expect(nextNavVerb('router.replace')).toBe('replace');
expect(nextNavVerb('redirect')).toBe('redirect');
expect(nextNavVerb('permanentRedirect')).toBe('permanentRedirect');
expect(nextNavVerb('NextResponse.redirect')).toBe('response.redirect');
expect(nextNavVerb('router.back')).toBeNull();
expect(nextNavVerb('fetch')).toBeNull();
expect(nextjsResolver.claimsReference!('redirect')).toBe(true);
expect(nextjsResolver.claimsReference!('Redirect')).toBe(false);
});
});
// =============================================================================
// End to end: a small App Router site
// =============================================================================
describe('nextjs: end to end', () => {
let tmpDir: string;
let cg: CodeGraph;
function write(rel: string, content: string): void {
const full = path.join(tmpDir, rel);
fs.mkdirSync(path.dirname(full), { recursive: true });
fs.writeFileSync(full, content);
}
beforeAll(async () => {
await initGrammars();
await loadAllGrammars();
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-nextjs-'));
write('package.json', JSON.stringify({ name: 'site', dependencies: { next: '15', react: '19', '@prisma/client': '5' } }));
write('lib/db.ts', "import { PrismaClient } from '@prisma/client'\nexport const prisma = new PrismaClient()\n");
write('app/layout.tsx', 'export default function RootLayout({ children }) {\n return children\n}\n');
write(
'app/page.tsx',
"import Link from 'next/link'\n" +
'export default function Home() {\n' +
' return (\n' +
' <main>\n' +
' <Link href="/users">Users</Link>\n' +
' <a href="/login">Log in</a>\n' +
' <a href="https://example.com">Elsewhere</a>\n' +
' </main>\n' +
' )\n' +
'}\n'
);
write('app/login/page.tsx', 'export default function LoginPage() {\n return null\n}\n');
write(
'app/users/page.tsx',
"import { NewUserForm } from '../../components/new-user-form'\n" +
"import { prisma } from '../../lib/db'\n" +
'export default async function UsersPage() {\n' +
' const users = await prisma.user.findMany()\n' +
' return <NewUserForm count={users.length} />\n' +
'}\n'
);
write('app/users/[id]/page.tsx', 'export default function UserPage({ params }) {\n return <a href="/users">Back</a>\n}\n');
write(
'components/new-user-form.tsx',
"'use client'\n" +
"import { useCallback, useState } from 'react'\n" +
"import { useRouter } from 'next/navigation'\n" +
"import { createUserAction } from '../app/actions'\n" +
'export function NewUserForm({ count }) {\n' +
" const [email, setEmail] = useState('')\n" +
' const router = useRouter()\n' +
' const handleSubmit = useCallback(async (e) => {\n' +
' e.preventDefault()\n' +
' const user = await createUserAction({ email })\n' +
' if (user.ok) router.push(`/users/${user.id}`)\n' +
' }, [email])\n' +
' return <form onSubmit={handleSubmit}><input value={email} onChange={(e) => setEmail(e.target.value)} /></form>\n' +
'}\n'
);
write(
'app/actions.ts',
"'use server'\n" +
"import { redirect } from 'next/navigation'\n" +
"import { prisma } from '../lib/db'\n" +
'export async function createUserAction(data) {\n' +
' const user = await prisma.user.create({ data })\n' +
" if (!user.verified) redirect('/users')\n" +
' return { ok: true, id: user.id }\n' +
'}\n'
);
write(
'app/api/users/route.ts',
"import { NextResponse } from 'next/server'\n" +
"import { prisma } from '../../../lib/db'\n" +
'export async function GET() {\n' +
' return NextResponse.json(await prisma.user.findMany())\n' +
'}\n' +
'export async function POST(req) {\n' +
' const data = await req.json()\n' +
' const user = await prisma.user.create({ data })\n' +
' return NextResponse.json(user, { status: 201 })\n' +
'}\n'
);
write(
'middleware.ts',
"import { NextResponse } from 'next/server'\n" +
'export function middleware(req) {\n' +
" if (!req.cookies.get('session')) {\n" +
" return NextResponse.redirect(new URL('/login', req.url))\n" +
' }\n' +
' return NextResponse.next()\n' +
'}\n'
);
cg = CodeGraph.initSync(tmpDir);
await cg.indexAll();
});
afterAll(() => {
cg?.close();
if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
});
const route = (name: string): Node => {
const r = cg.getNodesByKind('route').find((r) => r.name === name);
if (!r) throw new Error(`no route ${name}: ${cg.getNodesByKind('route').map((r) => r.name).join(', ')}`);
return r;
};
const sym = (name: string): Node => {
const n = cg.getNodesByName(name).find((n) => n.kind !== 'route' && n.kind !== 'file' && n.kind !== 'import');
if (!n) throw new Error(`no symbol ${name}`);
return n;
};
const navs = (from: Node) => cg.getOutgoingEdges(from.id).filter((e) => e.kind === 'navigates');
it('names every page and endpoint, and binds a page to its component and an endpoint to its function', () => {
expect(cg.getNodesByKind('route').map((r) => r.name).sort()).toEqual(['/', '/login', '/users', '/users/:id', 'GET /api/users', 'POST /api/users']);
const home = cg.getOutgoingEdges(route('/').id).find((e) => e.kind === 'calls');
expect(cg.getNode(home!.target)?.name).toBe('Home');
const post = cg.getOutgoingEdges(route('POST /api/users').id).find((e) => e.kind === 'references');
expect(cg.getNode(post!.target)).toMatchObject({ name: 'POST', kind: 'function', filePath: 'app/api/users/route.ts' });
});
it('a <Link href> and an internal <a href> navigate from the component that renders them; an external one does not', () => {
const fromHome = navs(sym('Home'));
const byHref = new Map(fromHome.map((e) => [(e.metadata as Record<string, unknown>).href, e]));
expect([...byHref.keys()].sort()).toEqual(['/login', '/users']);
const users = byHref.get('/users')!;
expect(users.target).toBe(route('/users').id);
expect(users.provenance).toBe('heuristic');
expect(users.metadata).toEqual({ synthesizedBy: 'next-link', href: '/users', navMethod: 'link', registeredAt: 'app/page.tsx:5' });
expect((byHref.get('/login')!.metadata as Record<string, unknown>).navMethod).toBe('a');
expect(navs(sym('UserPage')).map((e) => cg.getNode(e.target)?.name)).toEqual(['/users']);
});
it('router.push with a template hole reaches the [id] page; redirect() and NextResponse.redirect(new URL(…)) reach theirs', () => {
const push = navs(sym('handleSubmit'));
expect(push).toHaveLength(1);
expect(push[0]!.target).toBe(route('/users/:id').id);
expect(push[0]!.metadata).toMatchObject({ href: '/users/${…}', navMethod: 'push', refKind: 'calls' });
const redirect = navs(sym('createUserAction'));
expect(redirect).toHaveLength(1);
expect(redirect[0]!.target).toBe(route('/users').id);
expect(redirect[0]!.metadata).toMatchObject({ href: '/users', navMethod: 'redirect' });
const guard = navs(sym('middleware'));
expect(guard).toHaveLength(1);
expect(guard[0]!.target).toBe(route('/login').id);
expect(guard[0]!.metadata).toMatchObject({ href: '/login', navMethod: 'response.redirect' });
});
it('lands on the Screens tab: the entry page, its links, and the forms push attributed back to its page with the condition', async () => {
const screens = await buildScreens(cg, tmpDir);
expect(screens.routed).toBe(true);
const home = screens.screens.find((s) => s.path === '/')!;
expect(screens.entry).toBe(home.id);
expect(home.component?.name).toBe('Home');
const users = screens.screens.find((s) => s.path === '/users')!;
const user = screens.screens.find((s) => s.path === '/users/:id')!;
const link = screens.links.find((l) => l.from === home.id && l.to === users.id)!;
expect(link.via).toEqual([]);
expect(link.synthesized).toBe(true);
expect(link.sites[0]).toMatchObject({ href: '/users', method: 'return' });
const push = screens.links.find((l) => l.from === users.id && l.to === user.id)!;
expect(push).toBeDefined();
expect(push.via.map((v) => v.name)).toEqual(['NewUserForm', 'handleSubmit']);
expect(push.when).toBe('user.ok');
expect(push.sites[0]).toMatchObject({ href: '/users/${…}', method: 'push' });
// The middleware's redirect starts from no page: an origin.
expect(screens.origins.map((o) => o.node.name)).toContain('middleware');
expect(screens.dropped).toBe(0);
});
it('a pages Steps picture fires from its load, crosses to the server action, and draws the pages it leads to as boundaries', async () => {
const p = await buildSteps(cg, tmpDir, new URLSearchParams({ anchor: route('/users').id }));
expect(p.project).toBe('web');
const anchor = p.steps.find((s) => s.anchor)!;
expect(anchor.kind).toBe('screen');
expect(anchor.sub).toBe('UsersPage');
expect(anchor.trigger).toEqual({ kind: 'load', name: 'GET', of: '/users', in: 'page.tsx' });
const loadRead = p.steps.find((s) => s.kind === 'effect' && s.effect?.category === 'database' && s.effect.by.name === 'UsersPage')!;
expect(loadRead.label).toBe('prisma.user.findMany()');
const handler = p.steps.find((s) => s.kind === 'trigger' && s.node?.name === 'handleSubmit')!;
expect(handler.trigger).toMatchObject({ kind: 'prop', name: 'onSubmit', of: 'form' });
const action = p.steps.find((s) => s.node?.name === 'createUserAction')!;
expect(action.kind).toBe('bridge');
const toAction = p.links.find((l) => l.to === action.id)!;
expect(toAction.label).toContain('server action');
const write = p.steps.find((s) => s.kind === 'effect' && s.effect?.category === 'database' && s.effect.by.name === 'createUserAction')!;
expect(write.label).toBe('prisma.user.create({ data })');
const detail = p.steps.find((s) => s.kind === 'screen' && s.screen?.path === '/users/:id')!;
expect(detail.cut).toBe('screen');
const toDetail = p.links.find((l) => l.to === detail.id)!;
expect(toDetail.kind).toBe('navigates');
expect(toDetail.when).toBe('user.ok');
expect(toDetail.sites[0]!.text).toBe('push /users/${…}');
const back = p.links.find((l) => l.from === action.id && l.to === anchor.id)!;
expect(back.sites[0]).toMatchObject({ text: 'redirect /users', when: '!user.verified' });
});
it('an endpoint anchors as any server route does', async () => {
const p = await buildSteps(cg, tmpDir, new URLSearchParams({ anchor: route('POST /api/users').id }));
const anchor = p.steps.find((s) => s.anchor)!;
expect(anchor.sub).toBe('POST');
expect(anchor.trigger).toEqual({ kind: 'request', name: 'POST', of: '/api/users', in: 'route.ts' });
const db = p.steps.find((s) => s.kind === 'effect' && s.effect?.category === 'database')!;
expect(db.effect).toMatchObject({ model: 'user', access: 'write', by: { name: 'POST' } });
const res = p.steps.find((s) => s.kind === 'effect' && s.effect?.category === 'response')!;
expect(res.label).toBe('201');
});
});