feat(steps): draw all arms of conditional navigations as separate edges
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.
This commit is contained in:
@@ -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', () => {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -0,0 +1,481 @@
|
||||
/**
|
||||
* React Router as a Screens app (`src/resolution/frameworks/react-router.ts`,
|
||||
* `src/resolution/react-router-synthesizer.ts`): `<Route path>` routes bound
|
||||
* to their screens by `frameworks/react.ts`, and the navigation half — the
|
||||
* `history.push` / `navigate` / `redirect` calls and the `<Link to>` 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' +
|
||||
' <Router>\n' +
|
||||
" <Route path='/login' component={LoginScreen} />\n" +
|
||||
" <Route path='/shipping' component={ShippingScreen} />\n" +
|
||||
" <Route path='/payment' component={PaymentScreen} />\n" +
|
||||
" <Route path='/placeorder' component={PlaceOrderScreen} />\n" +
|
||||
" <Route path='/product/:id' component={ProductScreen} />\n" +
|
||||
" <Route path='/cart/:id?' component={CartScreen} />\n" +
|
||||
' </Router>\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 <form onSubmit={submitHandler}><CheckoutSteps step1 step2 step3 /></form>\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 <Link to='/shipping'>Continue</Link>\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 <form onSubmit={submitHandler} />\n' +
|
||||
'}\n' +
|
||||
'export default ShippingScreen\n'
|
||||
);
|
||||
write(
|
||||
'frontend/src/screens/PlaceOrderScreen.js',
|
||||
"import React from 'react'\nconst PlaceOrderScreen = () => <div>Order</div>\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 <button onClick={addToCart}>Add</button>\n' +
|
||||
'}\n' +
|
||||
'export default ProductScreen\n'
|
||||
);
|
||||
write(
|
||||
'frontend/src/screens/CartScreen.js',
|
||||
"import React from 'react'\nconst CartScreen = () => <div>Cart</div>\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' +
|
||||
' <nav>\n' +
|
||||
" <LinkContainer to='/cart'><span>Cart</span></LinkContainer>\n" +
|
||||
" {step1 ? <LinkContainer to='/login'><span>Sign In</span></LinkContainer> : null}\n" +
|
||||
" {step2 ? <NavLink to='/placeorder'>Place Order</NavLink> : null}\n" +
|
||||
" <a href='https://example.com'>Elsewhere</a>\n" +
|
||||
' </nav>\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<string, unknown>).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<string, unknown>).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 <Link to> / <NavLink to> / <LinkContainer to> navigates from the component that renders it; an external <a> 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<string, unknown>).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' +
|
||||
' <Router>\n' +
|
||||
" <Route path='/search/:keyword' component={HomeScreen} exact />\n" +
|
||||
" <Route path='/page/:pageNumber' component={HomeScreen} exact />\n" +
|
||||
" <Route path='/' component={HomeScreen} exact />\n" +
|
||||
" <Route path='/login' component={LoginScreen} />\n" +
|
||||
" <Route path='/register' component={RegisterScreen} />\n" +
|
||||
" <Route path='/product/:id' component={ProductScreen} />\n" +
|
||||
' </Router>\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 <Link to={`/product/${keyword}`}>A product</Link>\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' +
|
||||
' <Link to={redirect ? `/register?redirect=${redirect}` : \'/register\'}>Register</Link>\n' +
|
||||
' )\n' +
|
||||
'}\n' +
|
||||
'export default LoginScreen\n'
|
||||
);
|
||||
write(
|
||||
'src/screens/RegisterScreen.js',
|
||||
"import React from 'react'\nconst RegisterScreen = () => <div>Register</div>\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' +
|
||||
' <Link\n' +
|
||||
' to={\n' +
|
||||
' !isAdmin\n' +
|
||||
' ? keyword\n' +
|
||||
' ? `/search/${keyword}`\n' +
|
||||
' : `/page/${x}`\n' +
|
||||
" : '/register'\n" +
|
||||
' }\n' +
|
||||
' >\n' +
|
||||
' {x}\n' +
|
||||
' </Link>\n' +
|
||||
')\n' +
|
||||
'export default Paginate\n'
|
||||
);
|
||||
write(
|
||||
'src/screens/ProductScreen.js',
|
||||
"import React from 'react'\nconst ProductScreen = () => <div>Product</div>\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<string, unknown>).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)!;
|
||||
// `<Link to={redirect ? … : '/register'}>` 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);
|
||||
});
|
||||
});
|
||||
@@ -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 `<a href>` 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',
|
||||
'<script>\n export let data\n</script>\n' +
|
||||
'<nav>\n' +
|
||||
' <a href="/">Home</a>\n' +
|
||||
' <a href="/login">Sign in</a>\n' +
|
||||
' <a href="/settings">Settings</a>\n' +
|
||||
' <a href="https://example.com">Elsewhere</a>\n' +
|
||||
'</nav>\n' +
|
||||
'<slot />\n'
|
||||
);
|
||||
write(
|
||||
'src/routes/+page.svelte',
|
||||
'<script>\n export let data\n</script>\n<h1>Conduit</h1>\n<a href="/register">Sign up</a>\n'
|
||||
);
|
||||
write('src/routes/login/+page.svelte', '<script>\n export let form\n</script>\n<a href="/register">Need an account?</a>\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', '<script>\n export let form\n</script>\n<a href="/login">Have an account?</a>\n');
|
||||
write('src/routes/settings/+page.svelte', '<script>\n export let data\n</script>\n<h1>Settings</h1>\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',
|
||||
'<script>\n' +
|
||||
" import { goto } from '$app/navigation'\n" +
|
||||
' async function publish() {\n' +
|
||||
' const article = await save()\n' +
|
||||
' goto(`/article/${article.slug}`)\n' +
|
||||
' }\n' +
|
||||
'</script>\n' +
|
||||
'<button on:click={publish}>Publish</button>\n'
|
||||
);
|
||||
write(
|
||||
'src/routes/article/[slug]/+page.svelte',
|
||||
'<script>\n export let data\n</script>\n<a href="/editor">Edit</a>\n<a href="/profile/@{data.author}">Author</a>\n'
|
||||
);
|
||||
write('src/routes/profile/@[user]/+page.svelte', '<script>\n export let data\n</script>\n<h1>Profile</h1>\n');
|
||||
write('src/routes/profile/@[user]/+layout.svelte', '<script>\n export let data\n</script>\n<slot />\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<string, unknown>).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 <a href> 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<string, unknown>).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);
|
||||
});
|
||||
});
|
||||
@@ -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' +
|
||||
' <div>\n' +
|
||||
' <Link\n' +
|
||||
' to="/posts/$postId"\n' +
|
||||
' params={{ postId: 3 }}\n' +
|
||||
' >\n' +
|
||||
' A post\n' +
|
||||
' </Link>\n' +
|
||||
' <Link to="/login">Sign in</Link>\n' +
|
||||
' </div>\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 <Outlet />\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 <div>Posts</div>\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 <div>Post</div>\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 <form onSubmit={submit} />\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 <Link to="/posts">All posts</Link>\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 <button onClick={nowhere} />\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<string, unknown>).href as string)
|
||||
.sort();
|
||||
|
||||
it('names one route per address: the pathless layout is stripped, the index wins over the layout', () => {
|
||||
expect(cg.getNodesByKind('route').map((r) => r.name).sort()).toEqual([
|
||||
'/',
|
||||
'/dashboard',
|
||||
'/login',
|
||||
'/posts',
|
||||
'/posts/:postId',
|
||||
'/settings',
|
||||
]);
|
||||
// `/posts` is the index page, not the `posts.route.tsx` layout beside it.
|
||||
const bound = cg.getOutgoingEdges(route('/posts').id).find((e) => e.kind === 'calls');
|
||||
expect(cg.getNode(bound!.target)?.name).toBe('PostsIndexComponent');
|
||||
// `_auth.dashboard.tsx` is the page at `/dashboard`.
|
||||
expect(route('/dashboard').filePath).toBe('src/routes/_auth.dashboard.tsx');
|
||||
});
|
||||
|
||||
it('navigate({ to }) reaches the route the pattern names', () => {
|
||||
const submit = navs(sym('submit'));
|
||||
expect(submit).toHaveLength(1);
|
||||
expect(submit[0]!.target).toBe(route('/dashboard').id);
|
||||
expect(submit[0]!.metadata).toMatchObject({ href: '/dashboard', navMethod: 'navigate' });
|
||||
});
|
||||
|
||||
it('a <Link to> names the route PATTERN, with its params beside it', () => {
|
||||
// `to="/posts/$postId"` is the route, not a filled URL.
|
||||
expect(hrefs(sym('IndexComponent'))).toEqual(['/login', '/posts/:postId']);
|
||||
const link = navs(sym('IndexComponent')).find((e) => e.target === route('/posts/:postId').id)!;
|
||||
expect(link.provenance).toBe('heuristic');
|
||||
expect(link.metadata).toMatchObject({ synthesizedBy: 'tanstack-link', href: '/posts/:postId', navMethod: 'link' });
|
||||
expect(hrefs(sym('DashboardComponent'))).toEqual(['/posts']);
|
||||
});
|
||||
|
||||
it('a pattern nothing serves, and a navigation that only changes the search, are left unresolved', () => {
|
||||
expect(navs(sym('nowhere'))).toEqual([]);
|
||||
expect(navs(sym('filter'))).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)!;
|
||||
expect(at('/posts').component?.name).toBe('PostsIndexComponent');
|
||||
const toPost = screens.links.find((l) => l.from === at('/').id && l.to === at('/posts/:postId').id)!;
|
||||
expect(toPost).toBeDefined();
|
||||
expect(toPost.sites[0]).toMatchObject({ href: '/posts/:postId' });
|
||||
const signIn = screens.links.find((l) => l.from === at('/login').id && l.to === at('/dashboard').id)!;
|
||||
expect(signIn).toBeDefined();
|
||||
expect(signIn.via.map((v) => v.name)).toEqual(['submit']);
|
||||
expect(signIn.when).toBe('ok');
|
||||
expect(screens.dropped).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,301 @@
|
||||
/**
|
||||
* Vue Router as a Screens app (`src/resolution/frameworks/vue-router.ts`,
|
||||
* `src/resolution/vue-router-synthesizer.ts`): routes read out of
|
||||
* `createRouter({ routes: [...] })` and bound to the `.vue` view each names,
|
||||
* and the navigation between them — which in Vue is usually written by route
|
||||
* NAME rather than by path.
|
||||
*
|
||||
* The fixture is vue-realworld's shape: a `src/router/index.js` table of lazy
|
||||
* views, `router.push({ name })` from the script, `<router-link :to>` from the
|
||||
* template. 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 { parseVueRoutes, vueNavVerb, routeNameInExpression } from '../src/resolution/frameworks/vue-router';
|
||||
import type { Node } from '../src/types';
|
||||
|
||||
// =============================================================================
|
||||
// Reading the routes array
|
||||
// =============================================================================
|
||||
|
||||
const ROUTER_SOURCE =
|
||||
'import { createRouter, createWebHistory } from "vue-router"\n' +
|
||||
'const router = createRouter({\n' +
|
||||
' history: createWebHistory(),\n' +
|
||||
' routes: [\n' +
|
||||
' {\n' +
|
||||
' name: "home",\n' +
|
||||
' path: "/",\n' +
|
||||
' component: () => import("@/views/Home")\n' +
|
||||
' },\n' +
|
||||
' {\n' +
|
||||
' name: "login",\n' +
|
||||
' path: "/login",\n' +
|
||||
' component: () => import("@/views/Login")\n' +
|
||||
' },\n' +
|
||||
' {\n' +
|
||||
' name: "settings",\n' +
|
||||
' path: "/settings",\n' +
|
||||
' component: () => import("@/views/Settings"),\n' +
|
||||
' meta: { requiresAuth: true }\n' +
|
||||
' },\n' +
|
||||
' {\n' +
|
||||
' name: "profile",\n' +
|
||||
' path: "/profile/:username",\n' +
|
||||
' component: Profile,\n' +
|
||||
' children: [\n' +
|
||||
' { path: "favorites", component: Favorites }\n' +
|
||||
' ]\n' +
|
||||
' }\n' +
|
||||
' ]\n' +
|
||||
'})\n' +
|
||||
'export default router\n';
|
||||
|
||||
describe('vue-router: parseVueRoutes', () => {
|
||||
const entries = parseVueRoutes(ROUTER_SOURCE);
|
||||
|
||||
it('gives every entry its OWN name — the name is written above the path it belongs to', () => {
|
||||
expect(entries.map((e) => [e.name, e.path])).toEqual([
|
||||
['home', '/'],
|
||||
['login', '/login'],
|
||||
['settings', '/settings'],
|
||||
['profile', '/profile/:username'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('reads the component from a lazy import and from an identifier', () => {
|
||||
expect(entries.map((e) => e.component)).toEqual(['Home', 'Login', 'Settings', 'Profile']);
|
||||
});
|
||||
|
||||
it('skips a child route, whose path is relative to a parent this does not compose', () => {
|
||||
expect(entries.some((e) => e.path === 'favorites')).toBe(false);
|
||||
});
|
||||
|
||||
it('is nothing on a file that declares no routes', () => {
|
||||
expect(parseVueRoutes('export const paths = [{ path: "/x" }]\n')).toEqual([]);
|
||||
expect(parseVueRoutes('const x = 1\n')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('vue-router: navigation call names', () => {
|
||||
it.each([
|
||||
['router.push', 'push'],
|
||||
['router.replace', 'replace'],
|
||||
['$router.push', 'push'],
|
||||
['navigateTo', 'navigateTo'],
|
||||
])('%s → %s', (name, verb) => {
|
||||
expect(vueNavVerb(name)).toBe(verb);
|
||||
});
|
||||
|
||||
it.each(['push', 'replace', 'paths.push', 'list.replace', 'go', 'back'])(
|
||||
'%s is not a navigation — an unqualified push is an array’s',
|
||||
(name) => {
|
||||
expect(vueNavVerb(name)).toBeNull();
|
||||
}
|
||||
);
|
||||
|
||||
it('reads the route name out of an object destination, and nothing out of a path one', () => {
|
||||
expect(routeNameInExpression('{ name: "login" }')).toBe('login');
|
||||
expect(routeNameInExpression("{ name: 'profile', params: { username } }")).toBe('profile');
|
||||
expect(routeNameInExpression('{ path: "/", query }')).toBeNull();
|
||||
expect(routeNameInExpression("'/login'")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// The whole picture, indexed
|
||||
// =============================================================================
|
||||
|
||||
describe('vue-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-vue-router-'));
|
||||
write('package.json', JSON.stringify({ name: 'conduit', dependencies: { vue: '3', 'vue-router': '4' } }));
|
||||
write(
|
||||
'src/router/index.js',
|
||||
'import { createRouter, createWebHistory } from "vue-router"\n' +
|
||||
'const router = createRouter({\n' +
|
||||
' history: createWebHistory(),\n' +
|
||||
' routes: [\n' +
|
||||
' { name: "home", path: "/", component: () => import("@/views/Home") },\n' +
|
||||
' { name: "login", path: "/login", component: () => import("@/views/Login") },\n' +
|
||||
' { name: "register", path: "/register", component: () => import("@/views/Register") },\n' +
|
||||
' { name: "settings", path: "/settings", component: () => import("@/views/Settings") },\n' +
|
||||
' { name: "profile", path: "/profile/:username", component: () => import("@/views/Profile") }\n' +
|
||||
' ]\n' +
|
||||
'})\n' +
|
||||
'export default router\n'
|
||||
);
|
||||
write(
|
||||
'src/views/Home.vue',
|
||||
'<template>\n' +
|
||||
' <div><TheHeader /></div>\n' +
|
||||
'</template>\n' +
|
||||
'<script setup>\n' +
|
||||
'import { useRouter } from "vue-router"\n' +
|
||||
'import TheHeader from "@/components/TheHeader.vue"\n' +
|
||||
'const router = useRouter()\n' +
|
||||
'function goTo(tag) {\n' +
|
||||
' router.push({ path: "/", query: { tag } })\n' +
|
||||
'}\n' +
|
||||
'</script>\n'
|
||||
);
|
||||
write(
|
||||
'src/views/Login.vue',
|
||||
'<template>\n' +
|
||||
' <form @submit="submit"><router-link :to="{ name: \'register\' }">Need an account?</router-link></form>\n' +
|
||||
'</template>\n' +
|
||||
'<script setup>\n' +
|
||||
'import { useRouter } from "vue-router"\n' +
|
||||
'const router = useRouter()\n' +
|
||||
'function submit() {\n' +
|
||||
' login().then(() => router.push({ name: "home" }))\n' +
|
||||
'}\n' +
|
||||
'</script>\n'
|
||||
);
|
||||
write(
|
||||
'src/views/Register.vue',
|
||||
'<template>\n' +
|
||||
' <router-link to="/login">Have an account?</router-link>\n' +
|
||||
'</template>\n' +
|
||||
'<script setup>\n' +
|
||||
'const nothing = 1\n' +
|
||||
'</script>\n'
|
||||
);
|
||||
write(
|
||||
'src/views/Settings.vue',
|
||||
'<template>\n' +
|
||||
' <button @click="save">Save</button>\n' +
|
||||
'</template>\n' +
|
||||
'<script setup>\n' +
|
||||
'import { useRouter } from "vue-router"\n' +
|
||||
'const router = useRouter()\n' +
|
||||
'const target = "/nowhere"\n' +
|
||||
'function save(user) {\n' +
|
||||
' router.push({ name: "profile", params: { username: user.username } })\n' +
|
||||
'}\n' +
|
||||
'function bail() {\n' +
|
||||
' router.push(target)\n' +
|
||||
'}\n' +
|
||||
'</script>\n'
|
||||
);
|
||||
write(
|
||||
'src/views/Profile.vue',
|
||||
'<template>\n <div>Profile</div>\n</template>\n<script setup>\nconst x = 1\n</script>\n'
|
||||
);
|
||||
write(
|
||||
'src/components/TheHeader.vue',
|
||||
'<template>\n' +
|
||||
' <nav>\n' +
|
||||
' <router-link :to="{ name: \'home\' }">Home</router-link>\n' +
|
||||
' <router-link to="/settings">Settings</router-link>\n' +
|
||||
' <a href="https://example.com">Elsewhere</a>\n' +
|
||||
' </nav>\n' +
|
||||
'</template>\n' +
|
||||
'<script setup>\nconst y = 1\n</script>\n'
|
||||
);
|
||||
// The precision floor: an array's `push` with a string that IS a route.
|
||||
write('src/utils/trail.js', 'export function trail() {\n const paths = []\n paths.push("/login")\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<string, unknown>).href as string)
|
||||
.sort();
|
||||
|
||||
it('names every route in the table and binds it to the .vue view it names', () => {
|
||||
expect(cg.getNodesByKind('route').map((r) => r.name).sort()).toEqual([
|
||||
'/',
|
||||
'/login',
|
||||
'/profile/:username',
|
||||
'/register',
|
||||
'/settings',
|
||||
]);
|
||||
// The binding is a `calls` edge to the component, never the same-named
|
||||
// symbol a `references` edge would have found in the JS half of the app.
|
||||
const bound = cg.getOutgoingEdges(route('/login').id).find((e) => e.kind === 'calls');
|
||||
expect(cg.getNode(bound!.target)).toMatchObject({ name: 'Login', kind: 'component', filePath: 'src/views/Login.vue' });
|
||||
});
|
||||
|
||||
it('router.push({ name }) reaches the route with that name', () => {
|
||||
const login = navs(sym('submit'));
|
||||
expect(login).toHaveLength(1);
|
||||
expect(login[0]!.target).toBe(route('/').id);
|
||||
expect(login[0]!.metadata).toMatchObject({ href: 'home', navMethod: 'push', by: 'name' });
|
||||
const save = navs(sym('save'));
|
||||
expect(save[0]!.target).toBe(route('/profile/:username').id);
|
||||
expect(save[0]!.metadata).toMatchObject({ href: 'profile', by: 'name' });
|
||||
});
|
||||
|
||||
it('router.push({ path }) reaches the route with that path', () => {
|
||||
const goTo = navs(sym('goTo'));
|
||||
expect(goTo).toHaveLength(1);
|
||||
expect(goTo[0]!.target).toBe(route('/').id);
|
||||
expect(goTo[0]!.metadata).toMatchObject({ href: '/', navMethod: 'push' });
|
||||
expect((goTo[0]!.metadata as Record<string, unknown>).by).toBeUndefined();
|
||||
});
|
||||
|
||||
it('a <router-link> navigates from the component that renders it, by name or by path', () => {
|
||||
expect(hrefs(sym('TheHeader'))).toEqual(['/settings', 'home']);
|
||||
const byHref = new Map(navs(sym('TheHeader')).map((e) => [(e.metadata as Record<string, unknown>).href, e]));
|
||||
expect(byHref.get('home')!.target).toBe(route('/').id);
|
||||
expect(byHref.get('home')!.provenance).toBe('heuristic');
|
||||
expect(byHref.get('home')!.metadata).toMatchObject({ synthesizedBy: 'vue-router-link', navMethod: 'link', by: 'name' });
|
||||
expect(byHref.get('/settings')!.target).toBe(route('/settings').id);
|
||||
expect(hrefs(sym('Register'))).toEqual(['/login']);
|
||||
});
|
||||
|
||||
it('a destination nothing declares is left unresolved, and an array’s push is never claimed', () => {
|
||||
// `router.push(target)` where target is "/nowhere" — a real string, no route.
|
||||
expect(navs(sym('bail'))).toEqual([]);
|
||||
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);
|
||||
expect(screens.screens.map((s) => s.path).sort()).toEqual(['/', '/login', '/profile/:username', '/register', '/settings']);
|
||||
const at = (p: string) => screens.screens.find((s) => s.path === p)!;
|
||||
expect(at('/login').component?.name).toBe('Login');
|
||||
const toProfile = screens.links.find((l) => l.from === at('/settings').id && l.to === at('/profile/:username').id)!;
|
||||
expect(toProfile).toBeDefined();
|
||||
expect(toProfile.via.map((v) => v.name)).toEqual(['save']);
|
||||
expect(toProfile.sites[0]).toMatchObject({ href: 'profile', method: 'push' });
|
||||
expect(screens.links.find((l) => l.from === at('/login').id && l.to === at('/register').id)).toBeDefined();
|
||||
expect(screens.dropped).toBe(0);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user