react-router.test.ts 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481
  1. /**
  2. * React Router as a Screens app (`src/resolution/frameworks/react-router.ts`,
  3. * `src/resolution/react-router-synthesizer.ts`): `<Route path>` routes bound
  4. * to their screens by `frameworks/react.ts`, and the navigation half — the
  5. * `history.push` / `navigate` / `redirect` calls and the `<Link to>` markup
  6. * that carry a user from one screen to the next.
  7. *
  8. * The fixture is proshop's shape on purpose: a `frontend/` workspace whose
  9. * routes live in `src/App.js` and whose screens live in `src/screens/`, which
  10. * is what the app-root gate has to get right. Mirrors `nextjs.test.ts`.
  11. */
  12. import { describe, it, expect, beforeAll, afterAll } from 'vitest';
  13. import * as fs from 'fs';
  14. import * as os from 'os';
  15. import * as path from 'path';
  16. import { CodeGraph } from '../src';
  17. import { initGrammars, loadAllGrammars } from '../src/extraction/grammars';
  18. import { buildScreens } from '../src/ui-server/api/screens';
  19. import { buildSteps } from '../src/ui-server/api/steps';
  20. import { reactRouterRoot, reactRouterNavVerb } from '../src/resolution/frameworks/react-router';
  21. import type { Node } from '../src/types';
  22. // =============================================================================
  23. // The app root a route file owns
  24. // =============================================================================
  25. describe('react-router: reactRouterRoot', () => {
  26. it.each([
  27. ['frontend/src/App.js', 'frontend/'],
  28. ['src/App.tsx', ''],
  29. ['apps/web/src/routes/index.tsx', 'apps/web/'],
  30. ['client/App.jsx', 'client/'],
  31. ['App.jsx', ''],
  32. ])('%s → %s', (file, root) => {
  33. expect(reactRouterRoot(file)).toBe(root);
  34. });
  35. });
  36. describe('react-router: reactRouterNavVerb', () => {
  37. it.each([
  38. ['history.push', 'push'],
  39. ['history.replace', 'replace'],
  40. ['navigate', 'navigate'],
  41. ['router.navigate', 'navigate'],
  42. ['redirect', 'redirect'],
  43. ])('%s → %s', (name, verb) => {
  44. expect(reactRouterNavVerb(name)).toBe(verb);
  45. });
  46. it.each(['push', 'replace', 'paths.push', 'list.replace', 'items.navigate', 'go', 'goBack'])(
  47. '%s is not a navigation — an unqualified push is an array’s',
  48. (name) => {
  49. expect(reactRouterNavVerb(name)).toBeNull();
  50. }
  51. );
  52. });
  53. // =============================================================================
  54. // The whole picture, indexed
  55. // =============================================================================
  56. describe('react-router: a routed app end to end', () => {
  57. let tmpDir: string;
  58. let cg: CodeGraph;
  59. function write(rel: string, content: string): void {
  60. const full = path.join(tmpDir, rel);
  61. fs.mkdirSync(path.dirname(full), { recursive: true });
  62. fs.writeFileSync(full, content);
  63. }
  64. beforeAll(async () => {
  65. await initGrammars();
  66. await loadAllGrammars();
  67. tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-react-router-'));
  68. write('package.json', JSON.stringify({ name: 'shop', private: true }));
  69. write(
  70. 'frontend/package.json',
  71. JSON.stringify({
  72. name: 'frontend',
  73. dependencies: { react: '18', 'react-router-dom': '5', 'react-router-bootstrap': '0.26' },
  74. })
  75. );
  76. write(
  77. 'frontend/src/App.js',
  78. "import { BrowserRouter as Router, Route } from 'react-router-dom'\n" +
  79. "import LoginScreen from './screens/LoginScreen'\n" +
  80. "import ShippingScreen from './screens/ShippingScreen'\n" +
  81. "import PaymentScreen from './screens/PaymentScreen'\n" +
  82. "import PlaceOrderScreen from './screens/PlaceOrderScreen'\n" +
  83. "import ProductScreen from './screens/ProductScreen'\n" +
  84. "import CartScreen from './screens/CartScreen'\n" +
  85. 'const App = () => (\n' +
  86. ' <Router>\n' +
  87. " <Route path='/login' component={LoginScreen} />\n" +
  88. " <Route path='/shipping' component={ShippingScreen} />\n" +
  89. " <Route path='/payment' component={PaymentScreen} />\n" +
  90. " <Route path='/placeorder' component={PlaceOrderScreen} />\n" +
  91. " <Route path='/product/:id' component={ProductScreen} />\n" +
  92. " <Route path='/cart/:id?' component={CartScreen} />\n" +
  93. ' </Router>\n' +
  94. ')\n' +
  95. 'export default App\n'
  96. );
  97. // The screen the picture was wrong on: a guarded bounce out, and a push on
  98. // submit after the store action. Both are `history.push` with a literal.
  99. write(
  100. 'frontend/src/screens/PaymentScreen.js',
  101. "import React, { useState } from 'react'\n" +
  102. "import { useDispatch, useSelector } from 'react-redux'\n" +
  103. "import CheckoutSteps from '../components/CheckoutSteps'\n" +
  104. "import { savePaymentMethod } from '../actions/cartActions'\n" +
  105. 'const PaymentScreen = ({ history }) => {\n' +
  106. ' const cart = useSelector((state) => state.cart)\n' +
  107. ' const { shippingAddress } = cart\n' +
  108. ' if (!shippingAddress.address) {\n' +
  109. " history.push('/shipping')\n" +
  110. ' }\n' +
  111. " const [paymentMethod, setPaymentMethod] = useState('PayPal')\n" +
  112. ' const dispatch = useDispatch()\n' +
  113. ' const submitHandler = (e) => {\n' +
  114. ' e.preventDefault()\n' +
  115. ' dispatch(savePaymentMethod(paymentMethod))\n' +
  116. " history.push('/placeorder')\n" +
  117. ' }\n' +
  118. ' return <form onSubmit={submitHandler}><CheckoutSteps step1 step2 step3 /></form>\n' +
  119. '}\n' +
  120. 'export default PaymentScreen\n'
  121. );
  122. // A computed destination is not a destination: `redirect` is read off the
  123. // query string, so nothing static names a route.
  124. write(
  125. 'frontend/src/screens/LoginScreen.js',
  126. "import React, { useEffect } from 'react'\n" +
  127. "import { Link } from 'react-router-dom'\n" +
  128. 'const LoginScreen = ({ location, history, userInfo }) => {\n' +
  129. " const redirect = location.search ? location.search.split('=')[1] : '/'\n" +
  130. ' useEffect(() => {\n' +
  131. ' if (userInfo) {\n' +
  132. ' history.push(redirect)\n' +
  133. ' }\n' +
  134. ' }, [history, userInfo, redirect])\n' +
  135. " return <Link to='/shipping'>Continue</Link>\n" +
  136. '}\n' +
  137. 'export default LoginScreen\n'
  138. );
  139. write(
  140. 'frontend/src/screens/ShippingScreen.js',
  141. "import React from 'react'\n" +
  142. 'const ShippingScreen = ({ history }) => {\n' +
  143. ' const submitHandler = () => {\n' +
  144. " history.replace('/payment')\n" +
  145. ' }\n' +
  146. ' return <form onSubmit={submitHandler} />\n' +
  147. '}\n' +
  148. 'export default ShippingScreen\n'
  149. );
  150. write(
  151. 'frontend/src/screens/PlaceOrderScreen.js',
  152. "import React from 'react'\nconst PlaceOrderScreen = () => <div>Order</div>\nexport default PlaceOrderScreen\n"
  153. );
  154. // v6's hook, and a template hole that has to land on the `:id` route.
  155. write(
  156. 'frontend/src/screens/ProductScreen.js',
  157. "import React from 'react'\n" +
  158. "import { useNavigate } from 'react-router-dom'\n" +
  159. 'const ProductScreen = ({ match }) => {\n' +
  160. ' const navigate = useNavigate()\n' +
  161. ' const addToCart = () => {\n' +
  162. ' navigate(`/cart/${match.params.id}`)\n' +
  163. ' }\n' +
  164. ' return <button onClick={addToCart}>Add</button>\n' +
  165. '}\n' +
  166. 'export default ProductScreen\n'
  167. );
  168. write(
  169. 'frontend/src/screens/CartScreen.js',
  170. "import React from 'react'\nconst CartScreen = () => <div>Cart</div>\nexport default CartScreen\n"
  171. );
  172. // Navigation written as markup, including react-router-bootstrap's wrapper.
  173. write(
  174. 'frontend/src/components/CheckoutSteps.js',
  175. "import React from 'react'\n" +
  176. "import { NavLink } from 'react-router-dom'\n" +
  177. "import { LinkContainer } from 'react-router-bootstrap'\n" +
  178. 'const CheckoutSteps = ({ step1, step2 }) => (\n' +
  179. ' <nav>\n' +
  180. " <LinkContainer to='/cart'><span>Cart</span></LinkContainer>\n" +
  181. " {step1 ? <LinkContainer to='/login'><span>Sign In</span></LinkContainer> : null}\n" +
  182. " {step2 ? <NavLink to='/placeorder'>Place Order</NavLink> : null}\n" +
  183. " <a href='https://example.com'>Elsewhere</a>\n" +
  184. ' </nav>\n' +
  185. ')\n' +
  186. 'export default CheckoutSteps\n'
  187. );
  188. write(
  189. 'frontend/src/actions/cartActions.js',
  190. 'export const savePaymentMethod = (data) => (dispatch) => {\n' +
  191. " dispatch({ type: 'CART_SAVE_PAYMENT_METHOD', payload: data })\n" +
  192. " localStorage.setItem('paymentMethod', JSON.stringify(data))\n" +
  193. '}\n'
  194. );
  195. // The precision floor: an array's `push` with a string that IS a route.
  196. write(
  197. 'frontend/src/utils/breadcrumbs.js',
  198. 'export const trail = () => {\n' +
  199. ' const paths = []\n' +
  200. " paths.push('/placeorder')\n" +
  201. ' return paths\n' +
  202. '}\n'
  203. );
  204. cg = CodeGraph.initSync(tmpDir);
  205. await cg.indexAll();
  206. });
  207. afterAll(() => {
  208. cg?.close();
  209. if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
  210. });
  211. const route = (name: string): Node => {
  212. const r = cg.getNodesByKind('route').find((r) => r.name === name);
  213. if (!r) throw new Error(`no route ${name}: ${cg.getNodesByKind('route').map((r) => r.name).join(', ')}`);
  214. return r;
  215. };
  216. const sym = (name: string): Node => {
  217. const n = cg.getNodesByName(name).find((n) => n.kind !== 'route' && n.kind !== 'file' && n.kind !== 'import');
  218. if (!n) throw new Error(`no symbol ${name}`);
  219. return n;
  220. };
  221. const navs = (from: Node) => cg.getOutgoingEdges(from.id).filter((e) => e.kind === 'navigates');
  222. const hrefs = (from: Node) =>
  223. navs(from)
  224. .map((e) => (e.metadata as Record<string, unknown>).href as string)
  225. .sort();
  226. it('names every route and binds it to its screen', () => {
  227. expect(cg.getNodesByKind('route').map((r) => r.name).sort()).toEqual([
  228. '/cart/:id?',
  229. '/login',
  230. '/payment',
  231. '/placeorder',
  232. '/product/:id',
  233. '/shipping',
  234. ]);
  235. const bound = cg.getOutgoingEdges(route('/payment').id).find((e) => e.kind === 'references');
  236. expect(cg.getNode(bound!.target)?.name).toBe('PaymentScreen');
  237. });
  238. it('the payment screen pushes to both pages it leads to — the bounce out and the one on submit', () => {
  239. const payment = sym('PaymentScreen');
  240. expect(hrefs(payment)).toEqual(['/placeorder', '/shipping']);
  241. const byHref = new Map(navs(payment).map((e) => [(e.metadata as Record<string, unknown>).href, e]));
  242. expect(byHref.get('/shipping')!.target).toBe(route('/shipping').id);
  243. expect(byHref.get('/placeorder')!.target).toBe(route('/placeorder').id);
  244. expect(byHref.get('/placeorder')!.metadata).toMatchObject({ navMethod: 'push' });
  245. });
  246. it('history.replace navigates, and v6’s navigate() with a template hole reaches the :id route', () => {
  247. expect(navs(sym('ShippingScreen'))[0]!.target).toBe(route('/payment').id);
  248. expect(navs(sym('ShippingScreen'))[0]!.metadata).toMatchObject({ href: '/payment', navMethod: 'replace' });
  249. const product = navs(sym('ProductScreen'));
  250. expect(product).toHaveLength(1);
  251. expect(product[0]!.target).toBe(route('/cart/:id?').id);
  252. expect(product[0]!.metadata).toMatchObject({ href: '/cart/${…}', navMethod: 'navigate' });
  253. });
  254. it('a <Link to> / <NavLink to> / <LinkContainer to> navigates from the component that renders it; an external <a> does not', () => {
  255. expect(hrefs(sym('LoginScreen'))).toEqual(['/shipping']);
  256. const link = navs(sym('LoginScreen'))[0]!;
  257. expect(link.provenance).toBe('heuristic');
  258. expect(link.metadata).toMatchObject({ synthesizedBy: 'react-router-link', href: '/shipping', navMethod: 'link' });
  259. // `/cart` reaches `/cart/:id?` — an optional parameter serves the bare path too.
  260. expect(hrefs(sym('CheckoutSteps'))).toEqual(['/cart', '/login', '/placeorder']);
  261. });
  262. it('a computed destination is left unresolved, and an array’s push is never claimed', () => {
  263. // `history.push(redirect)` — the path comes off the query string.
  264. expect(navs(sym('LoginScreen')).every((e) => (e.metadata as Record<string, unknown>).synthesizedBy === 'react-router-link')).toBe(true);
  265. expect(navs(sym('trail'))).toEqual([]);
  266. });
  267. it('lands on the Screens tab as transitions between screens', async () => {
  268. const screens = await buildScreens(cg, tmpDir);
  269. expect(screens.routed).toBe(true);
  270. const at = (p: string) => screens.screens.find((s) => s.path === p)!;
  271. const link = screens.links.find((l) => l.from === at('/payment').id && l.to === at('/placeorder').id)!;
  272. expect(link).toBeDefined();
  273. expect(link.sites[0]).toMatchObject({ href: '/placeorder', method: 'push' });
  274. expect(link.via).toEqual([]);
  275. expect(screens.links.find((l) => l.from === at('/shipping').id && l.to === at('/payment').id)).toBeDefined();
  276. expect(screens.links.find((l) => l.from === at('/product/:id').id && l.to === at('/cart/:id?').id)).toBeDefined();
  277. });
  278. it('the payment screen’s Steps picture draws the pages it leads to, not just its store write', async () => {
  279. const p = await buildSteps(cg, tmpDir, new URLSearchParams({ anchor: route('/payment').id }));
  280. const anchor = p.steps.find((s) => s.anchor)!;
  281. expect(anchor.sub).toBe('PaymentScreen');
  282. const store = p.steps.find((s) => s.kind === 'effect' && s.effect?.category === 'storage')!;
  283. expect(store.label).toContain("localStorage.setItem('paymentMethod'");
  284. // Its own two pushes, plus the link back to sign-in its checkout nav renders.
  285. const to = p.steps.filter((s) => s.kind === 'screen' && !s.anchor).map((s) => s.screen?.path).sort();
  286. expect(to).toEqual(['/cart/:id?', '/login', '/placeorder', '/shipping']);
  287. const placeorder = p.steps.find((s) => s.screen?.path === '/placeorder')!;
  288. expect(placeorder.cut).toBe('screen');
  289. const push = p.links.find((l) => l.to === placeorder.id)!;
  290. expect(push.kind).toBe('navigates');
  291. expect(push.sites.map((site) => site.text)).toContain('push /placeorder');
  292. // The bounce out is drawn with the condition that sends the user there.
  293. const shipping = p.steps.find((s) => s.screen?.path === '/shipping')!;
  294. const bounce = p.links.find((l) => l.to === shipping.id)!;
  295. expect(bounce.sites[0]).toMatchObject({ text: 'push /shipping', when: '!shippingAddress.address' });
  296. });
  297. });
  298. // =============================================================================
  299. // One component at several addresses, and the destinations a login writes
  300. // =============================================================================
  301. describe('react-router: the shapes proshop is written in', () => {
  302. let tmpDir: string;
  303. let cg: CodeGraph;
  304. function write(rel: string, content: string): void {
  305. const full = path.join(tmpDir, rel);
  306. fs.mkdirSync(path.dirname(full), { recursive: true });
  307. fs.writeFileSync(full, content);
  308. }
  309. beforeAll(async () => {
  310. await initGrammars();
  311. await loadAllGrammars();
  312. tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-rr-shapes-'));
  313. write('package.json', JSON.stringify({ name: 'shop', dependencies: { react: '18', 'react-router-dom': '5' } }));
  314. // One component, four addresses — proshop renders HomeScreen at all four.
  315. write(
  316. 'src/App.js',
  317. "import { BrowserRouter as Router, Route } from 'react-router-dom'\n" +
  318. "import HomeScreen from './screens/HomeScreen'\n" +
  319. "import LoginScreen from './screens/LoginScreen'\n" +
  320. "import RegisterScreen from './screens/RegisterScreen'\n" +
  321. "import ProductScreen from './screens/ProductScreen'\n" +
  322. 'const App = () => (\n' +
  323. ' <Router>\n' +
  324. " <Route path='/search/:keyword' component={HomeScreen} exact />\n" +
  325. " <Route path='/page/:pageNumber' component={HomeScreen} exact />\n" +
  326. " <Route path='/' component={HomeScreen} exact />\n" +
  327. " <Route path='/login' component={LoginScreen} />\n" +
  328. " <Route path='/register' component={RegisterScreen} />\n" +
  329. " <Route path='/product/:id' component={ProductScreen} />\n" +
  330. ' </Router>\n' +
  331. ')\n' +
  332. 'export default App\n'
  333. );
  334. write(
  335. 'src/screens/HomeScreen.js',
  336. "import React from 'react'\n" +
  337. "import { Link } from 'react-router-dom'\n" +
  338. 'const HomeScreen = ({ match }) => {\n' +
  339. ' const keyword = match.params.keyword\n' +
  340. ' return <Link to={`/product/${keyword}`}>A product</Link>\n' +
  341. '}\n' +
  342. 'export default HomeScreen\n'
  343. );
  344. // The destination every react-router app writes for "where to after login".
  345. write(
  346. 'src/screens/LoginScreen.js',
  347. "import React, { useEffect } from 'react'\n" +
  348. "import { Link } from 'react-router-dom'\n" +
  349. 'const LoginScreen = ({ location, history, userInfo }) => {\n' +
  350. " const redirect = location.search ? location.search.split('=')[1] : '/'\n" +
  351. ' useEffect(() => {\n' +
  352. ' if (userInfo) {\n' +
  353. ' history.push(redirect)\n' +
  354. ' }\n' +
  355. ' }, [history, userInfo, redirect])\n' +
  356. ' return (\n' +
  357. ' <Link to={redirect ? `/register?redirect=${redirect}` : \'/register\'}>Register</Link>\n' +
  358. ' )\n' +
  359. '}\n' +
  360. 'export default LoginScreen\n'
  361. );
  362. write(
  363. 'src/screens/RegisterScreen.js',
  364. "import React from 'react'\nconst RegisterScreen = () => <div>Register</div>\nexport default RegisterScreen\n"
  365. );
  366. // proshop's paginator: one link, three destinations, chosen at runtime.
  367. write(
  368. 'src/components/Paginate.js',
  369. "import React from 'react'\n" +
  370. "import { Link } from 'react-router-dom'\n" +
  371. 'const Paginate = ({ isAdmin, keyword, x }) => (\n' +
  372. ' <Link\n' +
  373. ' to={\n' +
  374. ' !isAdmin\n' +
  375. ' ? keyword\n' +
  376. ' ? `/search/${keyword}`\n' +
  377. ' : `/page/${x}`\n' +
  378. " : '/register'\n" +
  379. ' }\n' +
  380. ' >\n' +
  381. ' {x}\n' +
  382. ' </Link>\n' +
  383. ')\n' +
  384. 'export default Paginate\n'
  385. );
  386. write(
  387. 'src/screens/ProductScreen.js',
  388. "import React from 'react'\nconst ProductScreen = () => <div>Product</div>\nexport default ProductScreen\n"
  389. );
  390. cg = CodeGraph.initSync(tmpDir);
  391. await cg.indexAll();
  392. });
  393. afterAll(() => {
  394. cg?.close();
  395. if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
  396. });
  397. const route = (name: string): Node => {
  398. const r = cg.getNodesByKind('route').find((r) => r.name === name);
  399. if (!r) throw new Error(`no route ${name}`);
  400. return r;
  401. };
  402. const sym = (name: string): Node => {
  403. const n = cg.getNodesByName(name).find((n) => n.kind !== 'route' && n.kind !== 'file' && n.kind !== 'import');
  404. if (!n) throw new Error(`no symbol ${name}`);
  405. return n;
  406. };
  407. const navs = (from: Node) => cg.getOutgoingEdges(from.id).filter((e) => e.kind === 'navigates');
  408. it('a `to={cond ? … : …}` is read, because markup uses the same reader a push does', () => {
  409. const toRegister = navs(sym('LoginScreen')).find((e) => e.target === route('/register').id);
  410. expect(toRegister).toBeDefined();
  411. // Both arms name `/register`; the href shows the one as written.
  412. expect(toRegister!.metadata).toMatchObject({ synthesizedBy: 'react-router-link', href: '/register?redirect=${…}' });
  413. });
  414. it('a destination whose other arm is computed still names where it goes', () => {
  415. // `const redirect = location.search ? location.search.split('=')[1] : '/'`
  416. // then `history.push(redirect)` — `/` is where this lands by default.
  417. const home = navs(sym('LoginScreen')).find((e) => e.target === route('/').id);
  418. expect(home).toBeDefined();
  419. expect(home!.metadata).toMatchObject({ href: '/', navMethod: 'push' });
  420. });
  421. it('a destination written as a three-way choice draws all three, each with the arm it took', () => {
  422. const from = navs(sym('Paginate'));
  423. const byTarget = new Map(from.map((e) => [e.target, (e.metadata as Record<string, unknown>).href]));
  424. expect(byTarget.get(route('/search/:keyword').id)).toBe('/search/${…}');
  425. expect(byTarget.get(route('/page/:pageNumber').id)).toBe('/page/${…}');
  426. expect(byTarget.get(route('/register').id)).toBe('/register');
  427. // Each edge names the path it took, not the first arm's.
  428. expect(from).toHaveLength(3);
  429. });
  430. it('a link written under a condition carries that condition, and reads as a link', async () => {
  431. const screens = await buildScreens(cg, tmpDir);
  432. const at = (p: string) => screens.screens.find((s) => s.path === p)!;
  433. // `<Link to={redirect ? … : '/register'}>` is markup: the destination is
  434. // written right there, so it is a `link`, not a helper's `return` value.
  435. const toRegister = screens.links.find((l) => l.from === at('/login').id && l.to === at('/register').id)!;
  436. expect(toRegister.sites[0]!.method).toBe('link');
  437. });
  438. it('a component rendered at several addresses gives its navigation to EVERY one', async () => {
  439. const screens = await buildScreens(cg, tmpDir);
  440. const at = (p: string) => screens.screens.find((s) => s.path === p)!;
  441. // HomeScreen serves three routes; all three lead to the product page.
  442. for (const from of ['/', '/search/:keyword', '/page/:pageNumber']) {
  443. expect(screens.links.find((l) => l.from === at(from).id && l.to === at('/product/:id').id)).toBeDefined();
  444. }
  445. // …and none of them is left as a screen you can reach but never leave.
  446. for (const s of screens.screens) {
  447. if (s.path === '/product/:id' || s.path === '/register') continue;
  448. expect(screens.links.some((l) => l.from === s.id)).toBe(true);
  449. }
  450. expect(screens.dropped).toBe(0);
  451. });
  452. });