import { test, expect } from "@playwright/test"; import { resetData, writeSite } from "./helpers"; // Navigation contract for the editor after the corpus walk was removed from // every render path. // // ── Why there are no loading.tsx skeletons in this app ───────────────────── // // There were, briefly. They were removed on measurement, for two reasons: // // 1. They never showed. A `loading.tsx` does NOT produce a fallback for a // client-side navigation between two children of the root layout — tested // in dev and against a production build, under three different ways of // stalling the payload. When the destination is prefetched the click // commits from the router cache with no pending state at all (this is the // win, and it is what makes the sidebar feel instant); when it is not // prefetched the router waits on the server and the browser stays on the // OLD page. Next's own loading.js reference says as much: "loading.js // provides fallback UI but does not guarantee instant client-side // navigations. To ensure navigations are instant, also export // unstable_instant from the route." That requires `cacheComponents`, which // this app cannot adopt (see plans/cache-components-spike.md). // // 2. They broke 404s. A loading boundary makes the route STREAM, so the HTTP // status is committed before the page body runs — and every page that // decides "this doesn't exist" with notFound() after an async read then // returned **200** with 404 content inside it. Measured directly: // /channels/ returned 200 with the boundaries present and 404 // without them. The last test in this file is the regression guard. // // So what makes sidebar navigation fast here is prefetch + staleTimes, not // skeletons. What IS worth pinning is the thing users feel: these routes // arrive quickly, the chrome never locks up, and a missing thing still 404s. // Sidebar links that used to cost seconds each. /channels and the retired // /actionable were the worst (4.5 s and 5.4 s) because each rendered a full // corpus walk. const HEAVY_ROUTES = [ { link: "Channels", heading: "Channels", path: "/channels" }, // The board: one row per operation, built from the registry catalog. It reads // every channel's snapshot to draw the rail, so it belongs with the heavy // routes rather than beside the cheap ones. { link: "Operations", heading: "Operations", path: "/operations" }, { link: "Jobs", heading: "Jobs", path: "/jobs" }, ] as const; test.describe("navigation", () => { test.beforeEach(async () => { await resetData("channel-with-counts"); }); test("the heavy routes load and keep the sidebar interactive", async ({ page, }) => { await page.goto("/"); const sidebar = page.locator("aside"); await expect(sidebar).toBeVisible(); for (const route of HEAVY_ROUTES) { await sidebar .getByRole("link", { name: route.link, exact: true }) .first() .click(); await expect(page).toHaveURL(new RegExp(`${route.path}(\\?|$)`)); await expect( page.getByRole("heading", { name: route.heading, level: 1 }), ).toBeVisible(); // The chrome is rendered by the root layout, which is NOT re-rendered for // a navigation between two of its own children — so it must survive every // hop with its links still clickable. await expect( sidebar.getByRole("link", { name: "Jobs", exact: true }).first(), ).toBeEnabled(); } }); test("a direct document load renders each heavy route", async ({ page }) => { for (const route of HEAVY_ROUTES) { await page.goto(route.path); await expect( page.getByRole("heading", { name: route.heading, level: 1 }), ).toBeVisible(); } }); test("the channels table discloses how fresh its counts are", async ({ page, }) => { // Counts come from each channel's last snapshot rather than a corpus walk, // so the page has to say so rather than imply they're live. This is the UI // half of that trade; the projection itself is covered by // common/controller/channelProjection.test.ts. await page.goto("/channels"); await expect(page.getByTestId("channels-freshness")).toBeVisible(); }); // REGRESSION GUARD — read the header comment before "fixing" this by adding // a loading.tsx back. // // A page that calls notFound() must return HTTP 404, not 200 with 404-looking // content in the body. Any streaming boundary above these routes commits the // status before the page body decides, silently turning every "not found" // into a 200. That is invisible in a browser and wrong for anything that // reads the status. // A RETIRED ROUTE REDIRECTS, NEVER 404s — the nav rule this repo shares with // umtool (see editor/app/lib/nav.ts). /actionable was four answers to "what // needs doing" on one page; the board is where a bookmark lands now. // /scheduler was the sync operation's page before sync was catalogued. // /jobs/active and /jobs/queue were two thirds of /jobs. // The Sites group's five satellites fold into /sites and the site's tabs. test("a retired route redirects, never 404s", async ({ page }) => { await page.goto("/actionable"); await expect(page).toHaveURL(/\/operations$/); await expect( page.getByRole("heading", { name: "Operations", exact: true }), ).toBeVisible(); await page.goto("/scheduler"); await expect(page).toHaveURL(/\/operations\/sync$/); await expect( page.getByRole("heading", { name: "Sync", level: 1 }), ).toBeVisible(); for (const retired of ["/jobs/active", "/jobs/queue"]) { await page.goto(retired); await expect(page).toHaveURL(/\/jobs$/); await expect( page.getByRole("heading", { name: "Jobs", level: 1 }), ).toBeVisible(); } await writeSite("testsite", {}); for (const retired of [ "/charts", "/aliases", "/deploy", "/build", "/homepage", ]) { await page.goto(retired); await expect(page).toHaveURL(/\/sites$/); await expect( page.getByRole("heading", { name: "Sites", level: 1 }), ).toBeVisible(); } // A ?site= bookmark is carried to the site's tab; the query survives the // redirect. await page.goto("/charts?site=testsite"); await expect(page).toHaveURL(/\/sites\/testsite\/charts(\?|$)/); await expect( page.getByRole("link", { name: "Charts tab" }), ).toHaveAttribute("aria-current", "page"); }); test("a missing resource returns 404, not 200", async ({ page }) => { for (const url of [ "/channels/definitely-not-a-channel", "/sites/definitely-not-a-site", ]) { const res = await page.goto(url); expect(res?.status(), `${url} must 404`).toBe(404); } }); });