| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481 |
- /**
- * 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);
- });
- });
|