From 6f4887db805ba3832c4431da63f0af1e65655cff Mon Sep 17 00:00:00 2001 From: Colby McHenry Date: Mon, 31 Aug 2026 11:34:26 -0500 Subject: [PATCH] feat(steps): draw all arms of conditional navigations as separate edges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds multi-arm navigation support: when a destination is produced by a conditional, every arm is now drawn as its own edge. Introduces helpers (hrefArms, destinationsForHref) and updates framework resolvers and edge creation to emit multiple navigates edges (via alsoTargets) instead of a single one. Also introduces per-app rooted route tables to avoid cross-app crossings, and updates various resolvers (React Router, TanStack Router, Vue Router, SvelteKit, Vue, and SvelteKit’s linker) and the UI to reflect multiple possible destinations. Tests and docs updated to reflect the new behavior, ensuring the Screens tab shows all possible navigation paths from conditional destinations. This makes navigation visualization more accurate for forked destinations. --- CHANGELOG.md | 24 + CLAUDE.md | 1 + README.md | 18 +- __tests__/expo-router.test.ts | 38 +- __tests__/nextjs.test.ts | 12 +- __tests__/react-router.test.ts | 481 ++++++++++++++++++ __tests__/sveltekit-router.test.ts | 220 ++++++++ __tests__/tanstack-router.test.ts | 355 +++++++++++++ __tests__/vue-router.test.ts | 301 +++++++++++ docs/design/codegraph-ui-design-spec.md | 32 ++ docs/design/framework-coverage.md | 253 +++++++++ scripts/try-repo.sh | 9 +- src/resolution/callback-synthesizer.ts | 18 +- src/resolution/frameworks/expo-router.ts | 173 ++++++- src/resolution/frameworks/index.ts | 16 + src/resolution/frameworks/nextjs.ts | 55 +- src/resolution/frameworks/object-literal.ts | 123 +++++ src/resolution/frameworks/package-deps.ts | 21 +- src/resolution/frameworks/react-router.ts | 202 ++++++++ src/resolution/frameworks/svelte.ts | 7 +- src/resolution/frameworks/sveltekit-router.ts | 169 ++++++ src/resolution/frameworks/tanstack-router.ts | 470 +++++++++++++++++ src/resolution/frameworks/vue-router.ts | 411 +++++++++++++++ src/resolution/index.ts | 17 +- src/resolution/next-router-synthesizer.ts | 36 +- src/resolution/react-router-synthesizer.ts | 120 +++++ src/resolution/sveltekit-synthesizer.ts | 163 ++++++ src/resolution/tanstack-router-synthesizer.ts | 108 ++++ src/resolution/types.ts | 13 + src/resolution/vue-router-synthesizer.ts | 109 ++++ src/ui-server/api/screens.ts | 81 ++- 31 files changed, 3969 insertions(+), 87 deletions(-) create mode 100644 __tests__/react-router.test.ts create mode 100644 __tests__/sveltekit-router.test.ts create mode 100644 __tests__/tanstack-router.test.ts create mode 100644 __tests__/vue-router.test.ts create mode 100644 docs/design/framework-coverage.md create mode 100644 src/resolution/frameworks/object-literal.ts create mode 100644 src/resolution/frameworks/react-router.ts create mode 100644 src/resolution/frameworks/sveltekit-router.ts create mode 100644 src/resolution/frameworks/tanstack-router.ts create mode 100644 src/resolution/frameworks/vue-router.ts create mode 100644 src/resolution/react-router-synthesizer.ts create mode 100644 src/resolution/sveltekit-synthesizer.ts create mode 100644 src/resolution/tanstack-router-synthesizer.ts create mode 100644 src/resolution/vue-router-synthesizer.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a43dbc..3e21797 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,12 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - **A Next.js app lands on the Screens tab like a mobile app.** App Router pages (`app/(group)/blog/[slug]/page.tsx` → `/blog/:slug`) and Pages Router pages are screens bound to the component they export; ``, an internal ``, `router.push` / `router.replace` (`next/navigation` and `next/router`), `redirect()` / `permanentRedirect()` in a server action or a page, and the middleware's `NextResponse.redirect(new URL('/login', req.url))` are the transitions between them — each attributed back to the page it starts on with the plumbing folded and the condition on the arrow, a link written in markup drawn dashed as an inferred hop. `app/api/**/route.ts` exports (`GET`, `POST`, …) are endpoints bound to their functions, `pages/api/*` handlers are `ANY /api/…`, and a page's Steps picture fires from its load (`FIRES FROM page load · /users`), draws the data it reads, the handlers it wires, the server actions it crosses to and the pages it leads to as boundaries. A response's status written as `{ status: 201 }` is read too. Re-index after upgrading. +- **TanStack Router apps land on the Screens tab too.** Routes are read both ways a TanStack app declares them: file-based, where `createFileRoute('/posts/$postId')` carries the whole path as a literal, and code-based, where each `createRoute({ path, getParentRoute })` names a fragment that is composed through its parent into `/posts/$postId`. `navigate({ to })` from `useNavigate`, a thrown `redirect({ to })` from a loader or `beforeLoad`, and `` / `` are the transitions between them. TanStack is the one router here whose destination is the route PATTERN rather than a filled address — `` names the route and passes the values beside it — so a destination is read as a pattern and matched against the route it names. Addresses that are not pages are left off the map: a `_auth` segment is a pathless layout and never appears in the URL, a `(group)` folder is invisible, a `__root` route wraps everything without being a page, and a file that renders an `` is the layout around an address while the index route beside it is the page at it. A computed `to`, a pattern no route serves, and a `navigate({ search })` that only changes the query are left out rather than guessed. Re-index after upgrading. + +- **Vue Router and SvelteKit apps land on the Screens tab too.** Both drew their screens as islands with no transitions, so the tab stayed hidden; now the navigation between pages is read for each. **Vue:** the routes are read out of `createRouter({ routes: [...] })` — path, name, and the view each entry names, including a lazy `component: () => import('@/views/Login')` — and `router.push` / `router.replace` / `$router.push`, Nuxt's `navigateTo`, and `` / `` / `` are the transitions. Vue apps usually navigate by route NAME rather than by path, so `router.push({ name: 'profile' })` and `:to="{ name: 'profile' }"` resolve by name, and `router.push({ path: '/', query })` by path. **SvelteKit:** `goto('/login')`, `redirect(303, '/article/' + slug)` from a load or a form action — whose destination is its *second* argument, after the status — and the plain `` that is a link in a SvelteKit app. A SvelteKit page also opens with a body now — it is joined to the page file that serves it and to the `+page.server.js` beside it — so its Steps picture draws its loader's work, its form actions, and the auth guard the loader performs (`redirect(302, '/login')` under `if (!locals.user)`) as a transition to the sign-in page, with the condition on the arrow. A computed destination, a path or name nothing declares, a relative path in a nested route, and a conditional whose two arms go to different pages are left out rather than guessed. Re-index after upgrading. + +- **A React Router app lands on the Screens tab too.** `` (v5), `}>` (v6) and `createBrowserRouter([{ path, element }])` already named a project's screens; now the navigation between them is drawn as well. `history.push('/placeorder')` and `history.replace`, `navigate('/placeorder')` from `useNavigate`, `redirect()` in a loader or an action, and `` / `` / `` / react-router-bootstrap's `` each become a transition — attributed back to the screen it starts on, with the plumbing folded and the condition on the arrow, a link written in markup drawn dashed as an inferred hop. A route with an optional parameter (`/cart/:id?`) is reached by both `/cart` and `/cart/5`. Until now a React Router project's screens were drawn as islands with no transitions at all, and a screen's Steps picture left out every page it sends you to — a checkout step showed its saved payment method but not that it goes on to place the order. A computed destination (`history.push(redirect)`), a path no route serves, and a relative path inside a nested route are left out rather than guessed, and an ordinary `paths.push('/x')` on an array is never mistaken for navigation. Re-index after upgrading. + - **Double-click a box to go there.** On the Steps tab a double-click on any step starts the picture from it — the same as the panel's *Start here* — so an endpoint the page calls, or another screen drawn as a boundary, opens as its own chapter in one gesture; on the Screens tab a double-click on a screen opens what happens from it. A boundary's panel now says it is not entered rather than that nothing leaves it. - **The Steps tab follows a web app across its tiers.** A page's `fetch('/api/users', { method: 'POST' })` (or an `axios` / `ky` / `got` / `$fetch` call, including one through a project instance made with `axios.create({ baseURL })`) now reaches the route that serves it in the same index — drawn as a crossing to the server (`⇢ POST /api/users`) with the handler named on the box and the registration site in the panel, a boundary by default and entered with *Continue through*, so the picture reads page → handler → the endpoint → its database write → its response. A job put on a BullMQ / Bull queue lands on the `@Process` method, `Worker` or `queue.process` handler that consumes it; a NestJS `EventEmitter2` event lands on its `@OnEvent` listeners (globs included); a socket message crosses from a client's `socket.emit` to the gateway's `@SubscribeMessage` and back from the server's `emit` to the component that registered `socket.on`; and a Next.js server action called from a client file is a crossing to the server by its `'use server'` directive. Each of these is a synthesized hop — dashed, with where it was wired up — and `codegraph_explore`'s Flow section names them too. Only a literal path or event name pairs: a variable url, a path no route serves, or one two routes serve alike produce nothing. Re-index to pick the new edges up. @@ -34,6 +40,24 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixes +- **A link written under a condition says so on the Screens tab.** A checkout stepper whose tabs are each enabled by their own prop, and a navbar whose admin links only render for an admin, both read as **always** — every transition written in markup was drawn with no condition at all, while the ones written as calls carried theirs. They are read the same way now: a store's checkout tabs say `step1` … `step4`, its navbar says `userInfo && userInfo.isAdmin` for the admin links and `!userInfo` for sign-in, and 59 of that store's 74 transitions carry the condition they actually run under, up from 20. A template language with no condition rules of its own still says nothing rather than guessing. + +- **A link in markup no longer reads as a helper's return value.** `` was labelled `return /shipping`, which in this picture means the destination came back from somewhere else and was inferred. It is written right there, so it now reads `link /shipping` — and an internal `` reads `a`. Only a destination that genuinely arrives from elsewhere still says `return`. + +- **A link that goes to one of several places now draws all of them.** A destination written as a choice — `!isAdmin ? keyword ? \`/search/${keyword}/page/${x}\` : \`/page/${x}\` : \`/admin/productlist/${x}\`, which is how a paginator shared between a storefront and an admin list is written — used to draw nothing at all, because one edge carried one destination and picking an arm would have been a guess. Every arm is now its own transition, labelled with the path THAT arm takes, so a store's paginated addresses are on the map instead of sitting there unreachable. The same goes for `redirect(307, user ? \`/profile/@${user.username}\` : '/login')` in a loader, and for `router.push(cond ? '/a' : '/b')`. Arms that name the same route still make one transition, and an arm nothing can read still contributes nothing. + +- **API endpoints are no longer drawn on the Screens tab.** A store's thirty Express endpoints sat beside its nineteen pages as boxes nothing navigates to and nothing leaves — in a picture that is only about navigation — and they stretched the row of unreachable pages hundreds of boxes wide. A screen is now a route named by its path alone; a route named with the method that reaches it (`GET /api/orders`, `POST /api/users/login`, `ANY /api/users`) is a request, not somewhere a user can be. Every route still appears on Entry points, which is the list of everything a request or a user can arrive at. + +- **A screen you could reach but never leave.** Three separate things left a page's own navigation off the map, and a store's home and sign-in pages showed nothing leaving them. **One component, several addresses:** a screen rendered at more than one route — a listing page that is also the search and the paginated results — handed all of its navigation to whichever route happened to be written first, and the rest were drawn as dead ends; every address it serves now gets it. **A link written as a choice:** `` is how a link that carries state is written, and markup was read by a weaker reader than calls were, so it saw nothing; both now use the same one. **A destination whose other half is unknowable:** `const redirect = location.search ? location.search.split('=')[1] : '/'` followed by `history.push(redirect)` is how every app sends a user on after signing in — the `/` is where it lands by default, and reading neither half lost the whole transition. Where both halves ARE readable and disagree, it is still a fork and still nothing. + +- **A conditional inside a conditional no longer reads the wrong arm.** A paginator written `!isAdmin ? keyword ? '/search/…' : '/page/…' : '/admin/…'` was split at the first `:` rather than the matching one, so an admin's page links pointed at the storefront's pagination. The arms are paired properly now, and a three-way choice — which is more destinations than one link can name — is left alone. + +- **In a repository with several apps, a link no longer points into a different one.** Every app has a `/` and most have a `/login`, and the route table was built for the whole repository at once, so whichever app was indexed first claimed each address — a `` in one app resolved to another app's `/posts`. Measured on a monorepo of 477 apps: **82% of navigations pointed at a route belonging to a different app**, and all of them now point within their own. Screens transitions are also no longer attributed to an unrelated page when several routes are declared in one file, as a code-based route tree or an Express router file is. + +- **A SvelteKit layout is no longer a second screen at a page's address.** `+layout.svelte` and `+error.svelte` sit at the same path as the `+page.svelte` beside them and were each indexed as a route, so one address appeared in the index two and three times over. Only a page is a route now. + +- **A framework whose package lives in a subfolder is detected again.** In a project that keeps its dependencies one level down — a `frontend/` and a `backend/`, or an `apps/web/` — the framework check ran once before any file had been indexed, found no folders to look in, and remembered that empty answer for the rest of the run. Every React, React Router and Next.js behaviour that depends on knowing the framework is there silently did nothing for those projects. + - **A server action, or any handler written inside a wrapper, draws what it really does.** `const signIn = validatedAction(schema, async (data) => { … })` showed one call out of nine, because the constant held a reference to its schema and that counted as having a body of its own. Its picture is now whole — the lookup, the early returns, the `Promise.all`, the redirect. - **A call is no longer followed to a same-named method of its own class.** `crypto.createHash('sha256').update(…)` inside a service that happens to have an `update` method was followed into that method, so a login endpoint read as though it updated the user, extra replies and all. A method of your own class is written `this.update(…)`; a receiver that is not `this` now ends the walk instead of guessing. diff --git a/CLAUDE.md b/CLAUDE.md index 4232e88..f91f170 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -289,6 +289,7 @@ publish actions on shared state. Write the files, hand the user the commands. - The `0.7.x` line is in active multi-agent rollout. Any change to `src/installer/` (especially `targets/`) needs corresponding test coverage and a CHANGELOG entry — installer regressions break every new install silently. - When changing what the MCP tools do or how agents should use them, edit `src/mcp/server-instructions.ts` — it is the **single source of truth** for agent-facing tool guidance (issue #529). The installer no longer writes a duplicate instructions block into `CLAUDE.md` / `AGENTS.md` / `GEMINI.md` / `.cursor/rules/codegraph.mdc` / Kiro steering, so there's nothing to keep in sync anymore. (The repo's own checked-in `.cursor/rules/codegraph.mdc` is dogfooding config — update it too if you use Cursor on this repo, but it ships nowhere.) +- **Before adding or extending a router, a web framework, or a language's `WHEN` rules, read `docs/design/framework-coverage.md`.** It is the standing answer to "what is supported and what is left" across the three axes (route nodes → Entry points, `navigates` edges → Screens, branch-guard rules → the `WHEN` labels), with what each remaining item needs, the traps that have already cost debugging time, and the queries to re-verify it. Update it in the same change that moves a row. - CodeGraph provides **code context**, not product requirements. For new features, ask the user about UX, edge cases, and acceptance criteria — the graph won't tell you. - **When the user references issues, PR comments, or external reports, anchor them to a date and version before drawing conclusions.** Check the comment's `createdAt` against: - The **last released version** — `grep -m1 '^## \[' CHANGELOG.md` shows the top-of-file version (older releases follow). A comment dated before the latest `## [X.Y.Z] - YYYY-MM-DD` is reacting to *released* state — work that's only on `main` or on an unmerged branch doesn't apply. diff --git a/README.md b/README.md index 81c4fbb..a73d3b2 100644 --- a/README.md +++ b/README.md @@ -392,11 +392,23 @@ CodeGraph detects web-framework routing files and emits `route` nodes linked by | **Axum / actix / Rocket** | `.route("/x", get(handler))` | | **ASP.NET** | `[HttpGet("/x")]` attributes on action methods | | **Vapor** | `app.get("x", use: handler)` | -| **React Router** / **SvelteKit** | Route component nodes | -| **Expo Router** | Every screen file under `app/` (`app/item/[id].tsx` → `/item/[id]`, groups stripped) becomes a route node bound to its default-export component; `router.push/replace/navigate('/path')`, template hrefs, and `{ pathname }` objects become `navigates` edges to the screen — so "where does tapping this go" is one hop in the graph | -| **Vue Router** / **Nuxt** | `pages/` file-based routes, `server/api/` endpoints, route middleware | | **Astro** | `src/pages/` file-based routes (`.astro` pages + `.ts` endpoints, `[param]`/`[...rest]` syntax) | +### Routers — routes *and* the navigation between them + +These frameworks additionally emit **`navigates`** edges: the function that sends a user somewhere is linked to the screen it names, so "where does tapping this go" is one hop in the graph rather than a search. Each reads a literal destination — a computed one, or a path no route serves, is left unresolved rather than guessed — and a link written in markup is marked as inferred. + +| Router | Routes from | Navigation from | +|---|---|---| +| **Expo Router** | Every screen file under `app/` (`app/item/[id].tsx` → `/item/[id]`, groups stripped), bound to its default-export component | `router.push` / `replace` / `navigate`, template hrefs, `{ pathname }` objects, and a helper's returned href | +| **Next.js** | App Router `app/**/page.tsx` and Pages Router pages (`(group)` stripped, `[slug]` → `:slug`); `app/api/**/route.ts` exports and `pages/api/*` are endpoints, not screens | `router.push` / `replace` / `prefetch`, `redirect()` / `permanentRedirect()` in a server action or page, `NextResponse.redirect(new URL(…))` in middleware, `` and internal `` | +| **React Router** | `` (v5 and v6) and `createBrowserRouter([{ path, element }])` | `history.push` / `replace`, `useNavigate`'s `navigate`, a loader's `redirect`, `` / `` / `` / react-router-bootstrap's `` | +| **TanStack Router** | `createFileRoute('/posts/$postId')` (file-based) and `createRoute({ path, getParentRoute })` composed up its parent chain (code-based); `_pathless` segments, `(group)` folders, `__root` and `` layouts are not addresses | `navigate({ to })`, a thrown `redirect({ to })`, `` / `` — where `to` is the route PATTERN and the values ride beside it in `params` | +| **Vue Router** / **Nuxt** | `createRouter({ routes: [...] })` with the view each entry names, plus Nuxt `pages/` file-based routes, `server/api/` endpoints and route middleware | `router.push` / `replace`, `$router.push`, Nuxt's `navigateTo`, `` / `` / `` — **by route name** (`push({ name: 'profile' })`) as well as by path | +| **SvelteKit** | `src/routes/**/+page.svelte` (`[slug]` → `:slug`, `[[opt]]` → `:opt?`), joined to the `+page.server.js` beside it so a loader's guard belongs to its page | `goto('/x')`, `redirect(status, '/x')` from a load or form action, and the plain `` that is a link in a SvelteKit app | + +In a repository holding several apps, each app's routes are matched only against navigation written inside that app. + --- ## Mixed iOS / React Native / Expo bridging diff --git a/__tests__/expo-router.test.ts b/__tests__/expo-router.test.ts index 698bf76..939dc29 100644 --- a/__tests__/expo-router.test.ts +++ b/__tests__/expo-router.test.ts @@ -140,11 +140,30 @@ describe('expo-router: readHrefArgument', () => { const r = read(src, 'navigate'); expect(r?.path).toBe('/sheets/create-detection-item'); expect(r?.display).toBe('/sheets/create-detection-item?folderId=${…}'); - expect(r?.alternate?.path).toBe('/sheets/create-detection-item'); + expect(r?.alternates?.map((a) => a.path)).toEqual(['/sheets/create-detection-item']); }); - it('returns null when one arm of a conditional is not a literal', () => { - expect(read("router.push(ready ? '/home' : fallback)")).toBeNull(); + it('reads the literal arm when the other is not one — a place the code demonstrably goes', () => { + // Both arms readable is a fork, and `pageForHref` resolves it only when + // they name the same route. One arm readable is not a fork: `/home` is + // somewhere this call goes, and reporting it is not a guess. Dropping it + // cost every react-router app its post-login transition, which is written + // `const redirect = search ? search.split('=')[1] : '/'`. + const r = read("router.push(ready ? '/home' : fallback)"); + expect(r?.path).toBe('/home'); + expect(r?.alternate).toBeUndefined(); + expect(read("router.push(ready ? fallback : '/home')")?.path).toBe('/home'); + // Neither arm readable is still nothing. + expect(read('router.push(ready ? a : b)')).toBeNull(); + }); + + it('pairs the arms of a NESTED conditional, and keeps all three', () => { + // Taking the first `:` split this between `keyword` and '/page', reading + // '/page' — a real path, from the wrong arm of the wrong conditional. Paired + // properly it is a paginator that goes to one of three places, and the + // picture draws all three rather than none. + const r = read("router.push(!isAdmin ? keyword ? '/search' : '/page' : '/admin')"); + expect([r?.path, ...(r?.alternates ?? []).map((a) => a.path)]).toEqual(['/search', '/page', '/admin']); }); it('reads only the first argument', () => { @@ -190,7 +209,7 @@ describe('expo-router: readHrefViaLocal', () => { ' }\n}'; const r = viaLocal(src); expect(r?.path).toBe('/barcode-scan'); - expect(r?.alternate?.path).toBe('/barcode-scan'); + expect(r?.alternates?.map((a) => a.path)).toEqual(['/barcode-scan']); }); it('reads a typed declaration and an Href object initializer', () => { @@ -369,9 +388,14 @@ describe('expo-router: resolve', () => { expect(expoRouterResolver.resolve(ref('list.push', 12, 32), context)?.targetNodeId).toBe(routes[2]!.id); }); - it('binds a conditional whose arms name the same screen, refuses one that forks', () => { - expect(expoRouterResolver.resolve(ref('router.push', 14, 33), context)?.targetNodeId).toBe(routes[2]!.id); - expect(expoRouterResolver.resolve(ref('router.push', 13, 27), context)).toBeNull(); + it('binds a conditional whose arms name the same screen, and draws BOTH when they fork', () => { + const same = expoRouterResolver.resolve(ref('router.push', 14, 33), context); + expect(same?.targetNodeId).toBe(routes[2]!.id); + expect(same?.alsoTargets).toBeUndefined(); + // A fork reaches both screens, and each becomes an edge of its own. + const forked = expoRouterResolver.resolve(ref('router.push', 13, 27), context); + expect(forked).not.toBeNull(); + expect([forked!.targetNodeId, ...(forked!.alsoTargets ?? []).map((t) => t.targetNodeId)]).toHaveLength(2); }); it('ignores refs that are not calls or not JS/TS', () => { diff --git a/__tests__/nextjs.test.ts b/__tests__/nextjs.test.ts index 86ec3e4..b010ffd 100644 --- a/__tests__/nextjs.test.ts +++ b/__tests__/nextjs.test.ts @@ -263,7 +263,9 @@ describe('nextjs: end to end', () => { 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' }); + // Markup, not a return value: the destination is written right there, so + // the site keeps its own verb rather than reading as a helper's return. + expect(link.sites[0]).toMatchObject({ href: '/users', method: 'link' }); 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']); @@ -274,6 +276,14 @@ describe('nextjs: end to end', () => { expect(screens.dropped).toBe(0); }); + it('an endpoint is not a screen — the Screens tab is pages, Entry points is every route', async () => { + const screens = await buildScreens(cg, tmpDir); + // `GET /api/users` and `POST /api/users` are routes, and they are on the + // Entry points list — but a request is not somewhere a user can be. + expect(screens.screens.map((s) => s.path).sort()).toEqual(['/', '/login', '/users', '/users/:id']); + expect(cg.getNodesByKind('route').some((r) => r.name === 'POST /api/users')).toBe(true); + }); + 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 () => { const p = await buildSteps(cg, tmpDir, new URLSearchParams({ anchor: route('/users').id })); expect(p.project).toBe('web'); diff --git a/__tests__/react-router.test.ts b/__tests__/react-router.test.ts new file mode 100644 index 0000000..21d9505 --- /dev/null +++ b/__tests__/react-router.test.ts @@ -0,0 +1,481 @@ +/** + * React Router as a Screens app (`src/resolution/frameworks/react-router.ts`, + * `src/resolution/react-router-synthesizer.ts`): `` routes bound + * to their screens by `frameworks/react.ts`, and the navigation half — the + * `history.push` / `navigate` / `redirect` calls and the `` markup + * that carry a user from one screen to the next. + * + * The fixture is proshop's shape on purpose: a `frontend/` workspace whose + * routes live in `src/App.js` and whose screens live in `src/screens/`, which + * is what the app-root gate has to get right. Mirrors `nextjs.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 { reactRouterRoot, reactRouterNavVerb } from '../src/resolution/frameworks/react-router'; +import type { Node } from '../src/types'; + +// ============================================================================= +// The app root a route file owns +// ============================================================================= + +describe('react-router: reactRouterRoot', () => { + it.each([ + ['frontend/src/App.js', 'frontend/'], + ['src/App.tsx', ''], + ['apps/web/src/routes/index.tsx', 'apps/web/'], + ['client/App.jsx', 'client/'], + ['App.jsx', ''], + ])('%s → %s', (file, root) => { + expect(reactRouterRoot(file)).toBe(root); + }); +}); + +describe('react-router: reactRouterNavVerb', () => { + it.each([ + ['history.push', 'push'], + ['history.replace', 'replace'], + ['navigate', 'navigate'], + ['router.navigate', 'navigate'], + ['redirect', 'redirect'], + ])('%s → %s', (name, verb) => { + expect(reactRouterNavVerb(name)).toBe(verb); + }); + + it.each(['push', 'replace', 'paths.push', 'list.replace', 'items.navigate', 'go', 'goBack'])( + '%s is not a navigation — an unqualified push is an array’s', + (name) => { + expect(reactRouterNavVerb(name)).toBeNull(); + } + ); +}); + +// ============================================================================= +// The whole picture, indexed +// ============================================================================= + +describe('react-router: a routed app 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-react-router-')); + write('package.json', JSON.stringify({ name: 'shop', private: true })); + write( + 'frontend/package.json', + JSON.stringify({ + name: 'frontend', + dependencies: { react: '18', 'react-router-dom': '5', 'react-router-bootstrap': '0.26' }, + }) + ); + write( + 'frontend/src/App.js', + "import { BrowserRouter as Router, Route } from 'react-router-dom'\n" + + "import LoginScreen from './screens/LoginScreen'\n" + + "import ShippingScreen from './screens/ShippingScreen'\n" + + "import PaymentScreen from './screens/PaymentScreen'\n" + + "import PlaceOrderScreen from './screens/PlaceOrderScreen'\n" + + "import ProductScreen from './screens/ProductScreen'\n" + + "import CartScreen from './screens/CartScreen'\n" + + 'const App = () => (\n' + + ' \n' + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + ' \n' + + ')\n' + + 'export default App\n' + ); + // The screen the picture was wrong on: a guarded bounce out, and a push on + // submit after the store action. Both are `history.push` with a literal. + write( + 'frontend/src/screens/PaymentScreen.js', + "import React, { useState } from 'react'\n" + + "import { useDispatch, useSelector } from 'react-redux'\n" + + "import CheckoutSteps from '../components/CheckoutSteps'\n" + + "import { savePaymentMethod } from '../actions/cartActions'\n" + + 'const PaymentScreen = ({ history }) => {\n' + + ' const cart = useSelector((state) => state.cart)\n' + + ' const { shippingAddress } = cart\n' + + ' if (!shippingAddress.address) {\n' + + " history.push('/shipping')\n" + + ' }\n' + + " const [paymentMethod, setPaymentMethod] = useState('PayPal')\n" + + ' const dispatch = useDispatch()\n' + + ' const submitHandler = (e) => {\n' + + ' e.preventDefault()\n' + + ' dispatch(savePaymentMethod(paymentMethod))\n' + + " history.push('/placeorder')\n" + + ' }\n' + + ' return
\n' + + '}\n' + + 'export default PaymentScreen\n' + ); + // A computed destination is not a destination: `redirect` is read off the + // query string, so nothing static names a route. + write( + 'frontend/src/screens/LoginScreen.js', + "import React, { useEffect } from 'react'\n" + + "import { Link } from 'react-router-dom'\n" + + 'const LoginScreen = ({ location, history, userInfo }) => {\n' + + " const redirect = location.search ? location.search.split('=')[1] : '/'\n" + + ' useEffect(() => {\n' + + ' if (userInfo) {\n' + + ' history.push(redirect)\n' + + ' }\n' + + ' }, [history, userInfo, redirect])\n' + + " return Continue\n" + + '}\n' + + 'export default LoginScreen\n' + ); + write( + 'frontend/src/screens/ShippingScreen.js', + "import React from 'react'\n" + + 'const ShippingScreen = ({ history }) => {\n' + + ' const submitHandler = () => {\n' + + " history.replace('/payment')\n" + + ' }\n' + + ' return
\n' + + '}\n' + + 'export default ShippingScreen\n' + ); + write( + 'frontend/src/screens/PlaceOrderScreen.js', + "import React from 'react'\nconst PlaceOrderScreen = () =>
Order
\nexport default PlaceOrderScreen\n" + ); + // v6's hook, and a template hole that has to land on the `:id` route. + write( + 'frontend/src/screens/ProductScreen.js', + "import React from 'react'\n" + + "import { useNavigate } from 'react-router-dom'\n" + + 'const ProductScreen = ({ match }) => {\n' + + ' const navigate = useNavigate()\n' + + ' const addToCart = () => {\n' + + ' navigate(`/cart/${match.params.id}`)\n' + + ' }\n' + + ' return \n' + + '}\n' + + 'export default ProductScreen\n' + ); + write( + 'frontend/src/screens/CartScreen.js', + "import React from 'react'\nconst CartScreen = () =>
Cart
\nexport default CartScreen\n" + ); + // Navigation written as markup, including react-router-bootstrap's wrapper. + write( + 'frontend/src/components/CheckoutSteps.js', + "import React from 'react'\n" + + "import { NavLink } from 'react-router-dom'\n" + + "import { LinkContainer } from 'react-router-bootstrap'\n" + + 'const CheckoutSteps = ({ step1, step2 }) => (\n' + + '
\n' + + ')\n' + + 'export default CheckoutSteps\n' + ); + write( + 'frontend/src/actions/cartActions.js', + 'export const savePaymentMethod = (data) => (dispatch) => {\n' + + " dispatch({ type: 'CART_SAVE_PAYMENT_METHOD', payload: data })\n" + + " localStorage.setItem('paymentMethod', JSON.stringify(data))\n" + + '}\n' + ); + // The precision floor: an array's `push` with a string that IS a route. + write( + 'frontend/src/utils/breadcrumbs.js', + 'export const trail = () => {\n' + + ' const paths = []\n' + + " paths.push('/placeorder')\n" + + ' return paths\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'); + const hrefs = (from: Node) => + navs(from) + .map((e) => (e.metadata as Record).href as string) + .sort(); + + it('names every route and binds it to its screen', () => { + expect(cg.getNodesByKind('route').map((r) => r.name).sort()).toEqual([ + '/cart/:id?', + '/login', + '/payment', + '/placeorder', + '/product/:id', + '/shipping', + ]); + const bound = cg.getOutgoingEdges(route('/payment').id).find((e) => e.kind === 'references'); + expect(cg.getNode(bound!.target)?.name).toBe('PaymentScreen'); + }); + + it('the payment screen pushes to both pages it leads to — the bounce out and the one on submit', () => { + const payment = sym('PaymentScreen'); + expect(hrefs(payment)).toEqual(['/placeorder', '/shipping']); + const byHref = new Map(navs(payment).map((e) => [(e.metadata as Record).href, e])); + expect(byHref.get('/shipping')!.target).toBe(route('/shipping').id); + expect(byHref.get('/placeorder')!.target).toBe(route('/placeorder').id); + expect(byHref.get('/placeorder')!.metadata).toMatchObject({ navMethod: 'push' }); + }); + + it('history.replace navigates, and v6’s navigate() with a template hole reaches the :id route', () => { + expect(navs(sym('ShippingScreen'))[0]!.target).toBe(route('/payment').id); + expect(navs(sym('ShippingScreen'))[0]!.metadata).toMatchObject({ href: '/payment', navMethod: 'replace' }); + const product = navs(sym('ProductScreen')); + expect(product).toHaveLength(1); + expect(product[0]!.target).toBe(route('/cart/:id?').id); + expect(product[0]!.metadata).toMatchObject({ href: '/cart/${…}', navMethod: 'navigate' }); + }); + + it('a / / navigates from the component that renders it; an external does not', () => { + expect(hrefs(sym('LoginScreen'))).toEqual(['/shipping']); + const link = navs(sym('LoginScreen'))[0]!; + expect(link.provenance).toBe('heuristic'); + expect(link.metadata).toMatchObject({ synthesizedBy: 'react-router-link', href: '/shipping', navMethod: 'link' }); + // `/cart` reaches `/cart/:id?` — an optional parameter serves the bare path too. + expect(hrefs(sym('CheckoutSteps'))).toEqual(['/cart', '/login', '/placeorder']); + }); + + it('a computed destination is left unresolved, and an array’s push is never claimed', () => { + // `history.push(redirect)` — the path comes off the query string. + expect(navs(sym('LoginScreen')).every((e) => (e.metadata as Record).synthesizedBy === 'react-router-link')).toBe(true); + expect(navs(sym('trail'))).toEqual([]); + }); + + it('lands on the Screens tab as transitions between screens', async () => { + const screens = await buildScreens(cg, tmpDir); + expect(screens.routed).toBe(true); + const at = (p: string) => screens.screens.find((s) => s.path === p)!; + const link = screens.links.find((l) => l.from === at('/payment').id && l.to === at('/placeorder').id)!; + expect(link).toBeDefined(); + expect(link.sites[0]).toMatchObject({ href: '/placeorder', method: 'push' }); + expect(link.via).toEqual([]); + expect(screens.links.find((l) => l.from === at('/shipping').id && l.to === at('/payment').id)).toBeDefined(); + expect(screens.links.find((l) => l.from === at('/product/:id').id && l.to === at('/cart/:id?').id)).toBeDefined(); + }); + + it('the payment screen’s Steps picture draws the pages it leads to, not just its store write', async () => { + const p = await buildSteps(cg, tmpDir, new URLSearchParams({ anchor: route('/payment').id })); + const anchor = p.steps.find((s) => s.anchor)!; + expect(anchor.sub).toBe('PaymentScreen'); + const store = p.steps.find((s) => s.kind === 'effect' && s.effect?.category === 'storage')!; + expect(store.label).toContain("localStorage.setItem('paymentMethod'"); + // Its own two pushes, plus the link back to sign-in its checkout nav renders. + const to = p.steps.filter((s) => s.kind === 'screen' && !s.anchor).map((s) => s.screen?.path).sort(); + expect(to).toEqual(['/cart/:id?', '/login', '/placeorder', '/shipping']); + const placeorder = p.steps.find((s) => s.screen?.path === '/placeorder')!; + expect(placeorder.cut).toBe('screen'); + const push = p.links.find((l) => l.to === placeorder.id)!; + expect(push.kind).toBe('navigates'); + expect(push.sites.map((site) => site.text)).toContain('push /placeorder'); + // The bounce out is drawn with the condition that sends the user there. + const shipping = p.steps.find((s) => s.screen?.path === '/shipping')!; + const bounce = p.links.find((l) => l.to === shipping.id)!; + expect(bounce.sites[0]).toMatchObject({ text: 'push /shipping', when: '!shippingAddress.address' }); + }); +}); + +// ============================================================================= +// One component at several addresses, and the destinations a login writes +// ============================================================================= + +describe('react-router: the shapes proshop is written in', () => { + 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-rr-shapes-')); + write('package.json', JSON.stringify({ name: 'shop', dependencies: { react: '18', 'react-router-dom': '5' } })); + // One component, four addresses — proshop renders HomeScreen at all four. + write( + 'src/App.js', + "import { BrowserRouter as Router, Route } from 'react-router-dom'\n" + + "import HomeScreen from './screens/HomeScreen'\n" + + "import LoginScreen from './screens/LoginScreen'\n" + + "import RegisterScreen from './screens/RegisterScreen'\n" + + "import ProductScreen from './screens/ProductScreen'\n" + + 'const App = () => (\n' + + ' \n' + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + ' \n' + + ')\n' + + 'export default App\n' + ); + write( + 'src/screens/HomeScreen.js', + "import React from 'react'\n" + + "import { Link } from 'react-router-dom'\n" + + 'const HomeScreen = ({ match }) => {\n' + + ' const keyword = match.params.keyword\n' + + ' return A product\n' + + '}\n' + + 'export default HomeScreen\n' + ); + // The destination every react-router app writes for "where to after login". + write( + 'src/screens/LoginScreen.js', + "import React, { useEffect } from 'react'\n" + + "import { Link } from 'react-router-dom'\n" + + 'const LoginScreen = ({ location, history, userInfo }) => {\n' + + " const redirect = location.search ? location.search.split('=')[1] : '/'\n" + + ' useEffect(() => {\n' + + ' if (userInfo) {\n' + + ' history.push(redirect)\n' + + ' }\n' + + ' }, [history, userInfo, redirect])\n' + + ' return (\n' + + ' Register\n' + + ' )\n' + + '}\n' + + 'export default LoginScreen\n' + ); + write( + 'src/screens/RegisterScreen.js', + "import React from 'react'\nconst RegisterScreen = () =>
Register
\nexport default RegisterScreen\n" + ); + // proshop's paginator: one link, three destinations, chosen at runtime. + write( + 'src/components/Paginate.js', + "import React from 'react'\n" + + "import { Link } from 'react-router-dom'\n" + + 'const Paginate = ({ isAdmin, keyword, x }) => (\n' + + ' \n' + + ' {x}\n' + + ' \n' + + ')\n' + + 'export default Paginate\n' + ); + write( + 'src/screens/ProductScreen.js', + "import React from 'react'\nconst ProductScreen = () =>
Product
\nexport default ProductScreen\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}`); + 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('a `to={cond ? … : …}` is read, because markup uses the same reader a push does', () => { + const toRegister = navs(sym('LoginScreen')).find((e) => e.target === route('/register').id); + expect(toRegister).toBeDefined(); + // Both arms name `/register`; the href shows the one as written. + expect(toRegister!.metadata).toMatchObject({ synthesizedBy: 'react-router-link', href: '/register?redirect=${…}' }); + }); + + it('a destination whose other arm is computed still names where it goes', () => { + // `const redirect = location.search ? location.search.split('=')[1] : '/'` + // then `history.push(redirect)` — `/` is where this lands by default. + const home = navs(sym('LoginScreen')).find((e) => e.target === route('/').id); + expect(home).toBeDefined(); + expect(home!.metadata).toMatchObject({ href: '/', navMethod: 'push' }); + }); + + it('a destination written as a three-way choice draws all three, each with the arm it took', () => { + const from = navs(sym('Paginate')); + const byTarget = new Map(from.map((e) => [e.target, (e.metadata as Record).href])); + expect(byTarget.get(route('/search/:keyword').id)).toBe('/search/${…}'); + expect(byTarget.get(route('/page/:pageNumber').id)).toBe('/page/${…}'); + expect(byTarget.get(route('/register').id)).toBe('/register'); + // Each edge names the path it took, not the first arm's. + expect(from).toHaveLength(3); + }); + + it('a link written under a condition carries that condition, and reads as a link', async () => { + const screens = await buildScreens(cg, tmpDir); + const at = (p: string) => screens.screens.find((s) => s.path === p)!; + // `` is markup: the destination is + // written right there, so it is a `link`, not a helper's `return` value. + const toRegister = screens.links.find((l) => l.from === at('/login').id && l.to === at('/register').id)!; + expect(toRegister.sites[0]!.method).toBe('link'); + }); + + it('a component rendered at several addresses gives its navigation to EVERY one', async () => { + const screens = await buildScreens(cg, tmpDir); + const at = (p: string) => screens.screens.find((s) => s.path === p)!; + // HomeScreen serves three routes; all three lead to the product page. + for (const from of ['/', '/search/:keyword', '/page/:pageNumber']) { + expect(screens.links.find((l) => l.from === at(from).id && l.to === at('/product/:id').id)).toBeDefined(); + } + // …and none of them is left as a screen you can reach but never leave. + for (const s of screens.screens) { + if (s.path === '/product/:id' || s.path === '/register') continue; + expect(screens.links.some((l) => l.from === s.id)).toBe(true); + } + expect(screens.dropped).toBe(0); + }); +}); diff --git a/__tests__/sveltekit-router.test.ts b/__tests__/sveltekit-router.test.ts new file mode 100644 index 0000000..9033fe7 --- /dev/null +++ b/__tests__/sveltekit-router.test.ts @@ -0,0 +1,220 @@ +/** + * SvelteKit as a Screens app (`src/resolution/frameworks/sveltekit-router.ts`, + * `src/resolution/sveltekit-link-synthesizer.ts`): the `+page.svelte` routes + * `frameworks/svelte.ts` names, and the navigation between them — `goto` in + * the browser, `redirect(status, path)` from a load or an action, and the + * plain `
` that IS a link in a SvelteKit app. + * + * The fixture is the SvelteKit realworld app's shape. Mirrors + * `react-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 { svelteResolver } from '../src/resolution/frameworks/svelte'; +import { svelteKitHrefArgument } from '../src/resolution/frameworks/sveltekit-router'; +import type { Node } from '../src/types'; + +// ============================================================================= +// Which file is a URL, and which argument is the destination +// ============================================================================= + +describe('sveltekit: only a +page.svelte is a route', () => { + const routeNames = (filePath: string): string[] => + svelteResolver.extract!(filePath, '').nodes.filter((n) => n.kind === 'route').map((n) => n.name); + + it('a page is its directory', () => { + expect(routeNames('src/routes/+page.svelte')).toEqual(['/']); + expect(routeNames('src/routes/login/+page.svelte')).toEqual(['/login']); + expect(routeNames('src/routes/article/[slug]/+page.svelte')).toEqual(['/article/:slug']); + }); + + it.each(['src/routes/+layout.svelte', 'src/routes/+error.svelte', 'src/routes/profile/+layout.svelte'])( + '%s sits at a page’s address without being one', + (file) => { + expect(routeNames(file)).toEqual([]); + } + ); +}); + +describe('sveltekit: which argument carries the path', () => { + it('goto takes it first; redirect takes the status first', () => { + expect(svelteKitHrefArgument('goto')).toBe(0); + expect(svelteKitHrefArgument('redirect')).toBe(1); + expect(svelteKitHrefArgument('push')).toBeNull(); + }); +}); + +// ============================================================================= +// The whole picture, indexed +// ============================================================================= + +describe('sveltekit: a routed app 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-sveltekit-')); + write('package.json', JSON.stringify({ name: 'conduit', devDependencies: { '@sveltejs/kit': '2', svelte: '5' } })); + write( + 'src/routes/+layout.svelte', + '\n' + + '\n' + + '\n' + ); + write( + 'src/routes/+page.svelte', + '\n

Conduit

\nSign up\n' + ); + write('src/routes/login/+page.svelte', '\nNeed an account?\n'); + write( + 'src/routes/login/+page.server.js', + "import { redirect } from '@sveltejs/kit'\n" + + 'export function load({ locals }) {\n' + + " if (locals.user) redirect(307, '/')\n" + + '}\n' + + 'export const actions = {\n' + + ' default: async ({ request, locals }) => {\n' + + ' const user = await signIn(request)\n' + + " if (!user) return { errors: ['bad login'] }\n" + + " redirect(307, '/')\n" + + ' }\n' + + '}\n' + ); + write('src/routes/register/+page.svelte', '\nHave an account?\n'); + write('src/routes/settings/+page.svelte', '\n

Settings

\n'); + write( + 'src/routes/settings/+page.server.js', + "import { redirect } from '@sveltejs/kit'\n" + + 'export function load({ locals }) {\n' + + " if (!locals.user) redirect(302, '/login')\n" + + '}\n' + ); + write( + 'src/routes/editor/+page.svelte', + '\n' + + '\n' + ); + write( + 'src/routes/article/[slug]/+page.svelte', + '\nEdit\nAuthor\n' + ); + write('src/routes/profile/@[user]/+page.svelte', '\n

Profile

\n'); + write('src/routes/profile/@[user]/+layout.svelte', '\n\n'); + // The precision floor: a destination nothing serves, and a computed one. + write( + 'src/routes/nowhere/+page.server.js', + "import { redirect } from '@sveltejs/kit'\n" + + 'export function load({ url }) {\n' + + " redirect(307, '/no-such-page')\n" + + ' redirect(307, url.searchParams.get("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, file?: string): Node => { + const n = cg + .getNodesByName(name) + .find((n) => n.kind !== 'route' && n.kind !== 'file' && n.kind !== 'import' && (!file || n.filePath.includes(file))); + if (!n) throw new Error(`no symbol ${name}${file ? ` in ${file}` : ''}`); + return n; + }; + const navs = (from: Node) => cg.getOutgoingEdges(from.id).filter((e) => e.kind === 'navigates'); + const hrefs = (from: Node) => + navs(from) + .map((e) => (e.metadata as Record).href as string) + .sort(); + + it('names one route per page, and a layout is not a second screen at the same address', () => { + expect(cg.getNodesByKind('route').map((r) => r.name).sort()).toEqual([ + '/', + '/article/:slug', + '/editor', + '/login', + '/profile/@:user', + '/register', + '/settings', + ]); + }); + + it('redirect takes its path from the SECOND argument, after the status', () => { + const guard = navs(sym('load', 'settings')); + expect(guard).toHaveLength(1); + expect(guard[0]!.target).toBe(route('/login').id); + expect(guard[0]!.metadata).toMatchObject({ href: '/login', navMethod: 'redirect' }); + expect(navs(sym('load', 'login'))[0]!.target).toBe(route('/').id); + }); + + it('goto with a template hole reaches the [slug] page', () => { + const publish = navs(sym('publish')); + expect(publish).toHaveLength(1); + expect(publish[0]!.target).toBe(route('/article/:slug').id); + expect(publish[0]!.metadata).toMatchObject({ href: '/article/${…}', navMethod: 'goto' }); + }); + + it('an internal navigates from the component that renders it; an external one does not', () => { + const article = sym('+page', 'article/[slug]'); + // `/profile/@{data.author}` is an interpolation, and reaches `/profile/@:user`. + expect(hrefs(article)).toEqual(['/editor', '/profile/@${…}']); + const link = navs(article).find((e) => (e.metadata as Record).href === '/editor')!; + expect(link.provenance).toBe('heuristic'); + expect(link.metadata).toMatchObject({ synthesizedBy: 'sveltekit-link', navMethod: 'a' }); + expect(navs(article).find((e) => e.target === route('/profile/@:user').id)).toBeDefined(); + // The layout's nav bar links out, and never to the external site. + expect(hrefs(sym('+layout', 'routes/+layout'))).toEqual(['/', '/login', '/settings']); + }); + + it('a path no page serves and a computed one are left unresolved', () => { + expect(navs(sym('load', 'nowhere'))).toEqual([]); + }); + + it('lands on the Screens tab as transitions between screens', async () => { + const screens = await buildScreens(cg, tmpDir); + expect(screens.routed).toBe(true); + const at = (p: string) => screens.screens.find((s) => s.path === p)!; + // One screen per address — a layout does not double them. + expect(screens.screens.filter((s) => s.path === '/')).toHaveLength(1); + expect(screens.links.find((l) => l.from === at('/settings').id && l.to === at('/login').id)).toBeDefined(); + const publish = screens.links.find((l) => l.from === at('/editor').id && l.to === at('/article/:slug').id)!; + expect(publish).toBeDefined(); + expect(publish.sites[0]).toMatchObject({ href: '/article/${…}', method: 'goto' }); + expect(screens.links.find((l) => l.from === at('/article/:slug').id && l.to === at('/editor').id)).toBeDefined(); + expect(screens.dropped).toBe(0); + }); +}); diff --git a/__tests__/tanstack-router.test.ts b/__tests__/tanstack-router.test.ts new file mode 100644 index 0000000..d222082 --- /dev/null +++ b/__tests__/tanstack-router.test.ts @@ -0,0 +1,355 @@ +/** + * TanStack Router as a Screens app (`src/resolution/frameworks/tanstack-router.ts`, + * `src/resolution/tanstack-router-synthesizer.ts`): routes declared file-based + * (`createFileRoute('/posts/$postId')`) and code-based (`createRoute({ path, + * getParentRoute })`), and the navigation between them — where the destination + * is the route PATTERN rather than a filled URL, and rides under a `to` key. + * + * The fixture is the TanStack kitchen-sink and basic examples' shape. Mirrors + * `react-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 { + parseTanstackRoutes, + tanstackPath, + tanstackNavVerb, + tanstackDestination, +} from '../src/resolution/frameworks/tanstack-router'; +import type { Node } from '../src/types'; + +// ============================================================================= +// Paths +// ============================================================================= + +describe('tanstack: tanstackPath', () => { + it.each([ + ['/', '/'], + ['/login', '/login'], + ['/posts/$postId', '/posts/:postId'], + // A pathless layout is not in the URL; nor is a route group. + ['/_auth/profile', '/profile'], + ['/_pathlessLayout/route-a', '/route-a'], + ['/(this-folder-is-not-in-the-url)/route-group', '/route-group'], + // An index route's trailing slash is the address of its parent. + ['/dashboard/', '/dashboard'], + // A trailing `_` un-nests without changing the segment. + ['/posts_/$postId/edit', '/posts/:postId/edit'], + ['/files/$', '/files/:splat*'], + ])('%s → %s', (raw, normalized) => { + expect(tanstackPath(raw)).toBe(normalized); + }); + + it('a path that names no address is nothing', () => { + expect(tanstackPath('posts')).toBeNull(); + }); +}); + +// ============================================================================= +// Reading the routes +// ============================================================================= + +describe('tanstack: parseTanstackRoutes — file-based', () => { + it('takes the path from the literal and the component from the options', () => { + const src = + "import { createFileRoute } from '@tanstack/react-router'\n" + + "export const Route = createFileRoute('/dashboard/invoices/$invoiceId')({\n" + + ' params: { parse: (p) => ({ invoiceId: Number(p.invoiceId) }) },\n' + + ' component: InvoiceComponent,\n' + + '})\n'; + expect(parseTanstackRoutes(src)).toEqual([ + { path: '/dashboard/invoices/:invoiceId', component: 'InvoiceComponent', index: false, fileBased: true, line: 2 }, + ]); + }); + + it('finds a component written on a chained .update()', () => { + const src = + "export const Route = createFileRoute('/login')({\n" + + ' validateSearch: z.object({ redirect: z.string().optional() }),\n' + + '}).update({\n' + + ' component: LoginComponent,\n' + + '})\n'; + expect(parseTanstackRoutes(src)[0]).toMatchObject({ path: '/login', component: 'LoginComponent' }); + }); + + it('marks an index route, and drops a pathless layout that is no address of its own', () => { + expect(parseTanstackRoutes("createFileRoute('/dashboard/')({ component: X })")[0]).toMatchObject({ + path: '/dashboard', + index: true, + }); + expect(parseTanstackRoutes("createFileRoute('/_auth')({ component: X })")).toEqual([]); + // …but the index INSIDE a pathless layout is the page at that layout's + // address — `_layout/index.tsx` is a project's home page. + expect(parseTanstackRoutes("createFileRoute('/_layout/')({ component: Home })")[0]).toMatchObject({ + path: '/', + index: true, + }); + }); +}); + +describe('tanstack: parseTanstackRoutes — code-based', () => { + const src = + "import { createRootRoute, createRoute } from '@tanstack/react-router'\n" + + 'const rootRoute = createRootRoute({ component: RootComponent })\n' + + 'const indexRoute = createRoute({\n' + + ' getParentRoute: () => rootRoute,\n' + + " path: '/',\n" + + ' component: IndexComponent,\n' + + '})\n' + + 'const postsLayoutRoute = createRoute({\n' + + ' getParentRoute: () => rootRoute,\n' + + " path: 'posts',\n" + + ' component: PostsLayoutComponent,\n' + + '})\n' + + 'const postsIndexRoute = createRoute({\n' + + ' getParentRoute: () => postsLayoutRoute,\n' + + " path: '/',\n" + + ' component: PostsIndexComponent,\n' + + '})\n' + + 'const postRoute = createRoute({\n' + + ' getParentRoute: () => postsLayoutRoute,\n' + + " path: '$postId',\n" + + ' component: PostComponent,\n' + + '})\n' + + 'const pathlessRoute = createRoute({\n' + + ' getParentRoute: () => rootRoute,\n' + + " id: 'pathless',\n" + + ' component: PathlessComponent,\n' + + '})\n' + + 'const routeARoute = createRoute({\n' + + ' getParentRoute: () => pathlessRoute,\n' + + " path: '/route-a',\n" + + ' component: RouteAComponent,\n' + + '})\n'; + + it('composes a path through getParentRoute, and a pathless layout adds nothing to it', () => { + expect(parseTanstackRoutes(src).map((r) => [r.path, r.component])).toEqual([ + ['/', 'IndexComponent'], + ['/posts', 'PostsIndexComponent'], + ['/posts/:postId', 'PostComponent'], + ['/route-a', 'RouteAComponent'], + ]); + }); + + it('a layout with children is not itself a page at that address', () => { + // `postsLayoutRoute` sits at `/posts` and wraps the index that renders there. + const posts = parseTanstackRoutes(src).filter((r) => r.path === '/posts'); + expect(posts).toHaveLength(1); + expect(posts[0]!.component).toBe('PostsIndexComponent'); + }); +}); + +// ============================================================================= +// Destinations +// ============================================================================= + +describe('tanstack: destinations', () => { + it.each([ + ['navigate', 'navigate'], + ['redirect', 'redirect'], + ['router.navigate', 'navigate'], + ])('%s is a navigation', (name, verb) => { + expect(tanstackNavVerb(name)).toBe(verb); + }); + + it.each(['push', 'replace', 'paths.push', 'goto'])('%s is not', (name) => { + expect(tanstackNavVerb(name)).toBeNull(); + }); + + it('reads the `to` key, and normalises the pattern the way a route name is', () => { + expect(tanstackDestination("{ to: '/posts/$postId' }")?.path).toBe('/posts/:postId'); + expect(tanstackDestination("{ to: '/login', search: { redirect } }")?.path).toBe('/login'); + expect(tanstackDestination("'/posts/$postId'")?.path).toBe('/posts/:postId'); + }); + + it('a navigation with no destination changes the search on the page it is on', () => { + expect(tanstackDestination('{ search: (old) => ({ ...old, page: 2 }) }')).toBeNull(); + }); +}); + +// ============================================================================= +// The whole picture, indexed +// ============================================================================= + +describe('tanstack: a routed app 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-tanstack-')); + write('package.json', JSON.stringify({ name: 'app', dependencies: { react: '19', '@tanstack/react-router': '1' } })); + write( + 'src/routes/index.tsx', + "import { createFileRoute, Link } from '@tanstack/react-router'\n" + + "export const Route = createFileRoute('/')({ component: IndexComponent })\n" + + 'function IndexComponent() {\n' + + ' return (\n' + + '
\n' + + ' \n' + + ' A post\n' + + ' \n' + + ' Sign in\n' + + '
\n' + + ' )\n' + + '}\n' + ); + write( + 'src/routes/posts.route.tsx', + "import { createFileRoute, Outlet } from '@tanstack/react-router'\n" + + "export const Route = createFileRoute('/posts')({ component: PostsLayout })\n" + + 'function PostsLayout() {\n return \n}\n' + ); + write( + 'src/routes/posts.index.tsx', + "import { createFileRoute } from '@tanstack/react-router'\n" + + "export const Route = createFileRoute('/posts/')({ component: PostsIndexComponent })\n" + + 'function PostsIndexComponent() {\n return
Posts
\n}\n' + ); + write( + 'src/routes/posts.$postId.tsx', + "import { createFileRoute } from '@tanstack/react-router'\n" + + "export const Route = createFileRoute('/posts/$postId')({ component: PostComponent })\n" + + 'function PostComponent() {\n return
Post
\n}\n' + ); + write( + 'src/routes/login.tsx', + "import { createFileRoute, useNavigate } from '@tanstack/react-router'\n" + + "export const Route = createFileRoute('/login')({ component: LoginComponent })\n" + + 'function LoginComponent() {\n' + + ' const navigate = useNavigate()\n' + + ' async function submit(creds) {\n' + + ' const ok = await signIn(creds)\n' + + " if (ok) navigate({ to: '/dashboard' })\n" + + ' }\n' + + ' return \n' + + '}\n' + ); + write( + 'src/routes/_auth.tsx', + "import { createFileRoute, redirect } from '@tanstack/react-router'\n" + + "export const Route = createFileRoute('/_auth')({\n" + + ' beforeLoad: ({ context }) => {\n' + + " if (context.auth.status === 'loggedOut') {\n" + + " throw redirect({ to: '/login' })\n" + + ' }\n' + + ' },\n' + + '})\n' + ); + write( + 'src/routes/_auth.dashboard.tsx', + "import { createFileRoute, Link } from '@tanstack/react-router'\n" + + "export const Route = createFileRoute('/_auth/dashboard')({ component: DashboardComponent })\n" + + 'function DashboardComponent() {\n' + + ' return All posts\n' + + '}\n' + ); + // The precision floor: a pattern nothing serves, and a search-only navigation. + write( + 'src/routes/settings.tsx', + "import { createFileRoute, useNavigate } from '@tanstack/react-router'\n" + + "export const Route = createFileRoute('/settings')({ component: SettingsComponent })\n" + + 'function SettingsComponent() {\n' + + ' const navigate = useNavigate()\n' + + ' function nowhere() {\n' + + " navigate({ to: '/no-such-route' })\n" + + ' }\n' + + ' function filter() {\n' + + ' navigate({ search: (old) => ({ ...old, page: 2 }) })\n' + + ' }\n' + + ' return