import {
appDir,
hostname,
page,
server,
waitForChange,
waitForHydration,
} from "playground/utils";
import { beforeAll } from "vitest";
import { describe, expect, test } from "vitest";
beforeAll(async () => {
await server(null, { cwd: appDir("examples/typed-file-router") });
});
// ── Basic route rendering ──
describe("typed-file-router — basic routes", () => {
test("renders home page at /", async () => {
await page.goto(hostname);
await page.waitForLoadState("load");
expect(await page.textContent("body")).toContain(
"Welcome to Typed File Router"
);
});
test("renders about page at /about", async () => {
await page.goto(`${hostname}/about`);
await page.waitForLoadState("load");
expect(await page.textContent("body")).toContain("About");
expect(await page.textContent("body")).toContain(
"simple page with no dynamic params"
);
});
test("renders root layout with navigation", async () => {
await page.goto(hostname);
await page.waitForLoadState("load");
// The root layout should have a nav with typed links
const nav = await page.$("nav");
expect(nav).not.toBeNull();
// Nav should have links generated by typed route descriptors
const navText = await nav.textContent();
expect(navText).toContain("Home");
expect(navText).toContain("About");
expect(navText).toContain("User 42");
expect(navText).toContain("Dashboard");
// Nav includes matcher-gated demo routes
expect(navText).toContain("Product ABC-123");
expect(navText).toContain("Product abc-123");
expect(navText).toContain("Docs nested");
expect(navText).toContain("Docs flat");
});
});
// ── Dynamic user route with Zod validation ──
describe("typed-file-router — user route ([id])", () => {
test("renders user page with valid numeric id", async () => {
await page.goto(`${hostname}/user/42`);
await page.waitForLoadState("load");
expect(await page.textContent("body")).toContain("User Profile");
expect(await page.textContent("body")).toContain("42");
});
test("renders user page with different id", async () => {
await page.goto(`${hostname}/user/99`);
await page.waitForLoadState("load");
expect(await page.textContent("body")).toContain("User Profile");
expect(await page.textContent("body")).toContain("99");
});
test("user page has typed Link to User 99", async () => {
await page.goto(`${hostname}/user/42`);
await page.waitForLoadState("load");
// [id].page.tsx has user.Link params={{ id: "99" }}
const link99 = await page.$('a[href="/user/99"]');
expect(link99).not.toBeNull();
expect(await link99.textContent()).toContain("User 99");
});
});
// ── Client components ──
describe("typed-file-router — client components", () => {
test("renders clock client component at /clock", async () => {
await page.goto(`${hostname}/clock`);
await page.waitForLoadState("load");
await waitForHydration();
expect(await page.textContent("body")).toContain("Clock (Client Page)");
expect(await page.textContent("body")).toContain("live clock");
});
test("renders interactive counter at /counter", async () => {
await page.goto(`${hostname}/counter`);
await page.waitForLoadState("load");
await waitForHydration();
expect(await page.textContent("body")).toContain("Counter (Client Page)");
expect(await page.textContent("body")).toContain("Count:");
// Counter should be interactive — click and verify increment
const button = await page.$("button");
expect(button).not.toBeNull();
expect(await button.textContent()).toContain("Count: 0");
await button.click();
expect(await button.textContent()).toContain("Count: 1");
await button.click();
expect(await button.textContent()).toContain("Count: 2");
});
});
// ── Dashboard with parallel routes/outlets ──
describe("typed-file-router — dashboard layout and outlets", () => {
test("renders dashboard page at /dashboard", async () => {
await page.goto(`${hostname}/dashboard`);
await page.waitForLoadState("load");
expect(await page.textContent("body")).toContain(
"Welcome to the dashboard"
);
expect(await page.textContent("body")).toContain("typed outlets");
});
test("renders dashboard layout structure with sidebar and content", async () => {
await page.goto(`${hostname}/dashboard`);
await page.waitForLoadState("load");
// Layout should have both sidebar and content sections
expect(await page.textContent("body")).toContain("Sidebar");
expect(await page.textContent("body")).toContain("Content");
});
});
// ── Bound outlets via @lazarv/react-server/outlets ──
describe("typed-file-router — bound outlets", () => {
// /panels mounts the @sidebar/nav and @content/feed outlets directly
// via the typed `@lazarv/react-server/outlets` module. The outlet name
// is bound at the import site (no `outlet="..."` prop needed) and
// `url` is typed against the same route table as `Link.to`.
test("renders the panels page", async () => {
await page.goto(`${hostname}/panels`);
await page.waitForLoadState("load");
expect(await page.textContent("body")).toContain("Panels");
});
test("sidebar.Outlet mounts /dashboard/nav into the sidebar slot", async () => {
await page.goto(`${hostname}/panels`);
await page.waitForLoadState("load");
await waitForHydration();
const sidebar = await page.$('[data-testid="panels-sidebar"]');
expect(sidebar).not.toBeNull();
const sidebarText = await sidebar.textContent();
// /dashboard/nav resolves to @sidebar/nav.page.tsx, which renders the
// SidebarNav with three internal links.
expect(sidebarText).toContain("Overview");
expect(sidebarText).toContain("Settings");
expect(sidebarText).toContain("Analytics");
});
test("content.Outlet mounts /dashboard/feed into the content slot", async () => {
await page.goto(`${hostname}/panels`);
await page.waitForLoadState("load");
await waitForHydration();
const content = await page.$('[data-testid="panels-content"]');
expect(content).not.toBeNull();
const contentText = await content.textContent();
// /dashboard/feed resolves to @content/feed.page.tsx → ContentFeed.
expect(contentText).toContain("Activity Feed");
expect(contentText).toContain("User signed up");
expect(contentText).toContain("New order placed");
});
// Regression: while the panels page is mounted, the sidebar+content
// outlets are registered in ClientProvider's `outlets` Map. A default
// <Link> click (no `target`/`local`/`root` prop) used to broadcast the
// navigation to every active non-root outlet and skip PAGE_ROOT entirely,
// so the user stayed on /panels with the sidebar/content slots showing
// null. Top-level Link clicks must navigate the page even with named
// outlets active.
test("typed Link click from panels to dashboard performs full navigation", async () => {
await page.goto(`${hostname}/panels`);
await page.waitForLoadState("load");
await waitForHydration();
// Wait for the bound outlets to register (their useEffect must have
// run before we click the cross-page link).
await page.waitForSelector('[data-testid="panels-sidebar"]');
const prevUrl = page.url();
const dashboardLink = await page.$('nav a[href="/dashboard"]');
expect(dashboardLink).not.toBeNull();
await dashboardLink.click();
await waitForChange(null, () => page.url(), prevUrl);
expect(page.url()).toContain("/dashboard");
const body = await page.textContent("body");
expect(body).toContain("Welcome to the dashboard");
expect(body).toContain("Sidebar");
expect(body).toContain("Content"); });
// The bound `Outlet` resolves the `url` against the file-router manifest
// on the server (RSC env) and renders the matching outlet page as
// `children` for `ReactServerComponent`. The result must already be in
// the initial SSR HTML — no client round-trip required. A raw fetch
// bypasses Playwright's JS execution and proves the content is server
// rendered, not hydrated.
test("server preloads outlet content into the initial SSR HTML", async () => {
const response = await fetch(`${hostname}/panels`);
expect(response.ok).toBe(true);
const html = await response.text();
// Sidebar slot: from @sidebar/nav.page.tsx (SidebarNav)
expect(html).toContain("Overview");
expect(html).toContain("Settings");
expect(html).toContain("Analytics");
// Content slot: from @content/feed.page.tsx (ContentFeed)
expect(html).toContain("Activity Feed");
expect(html).toContain("User signed up");
});
test("each bound outlet receives its own outlet identifier", async () => {
await page.goto(`${hostname}/panels`);
await page.waitForLoadState("load");
await waitForHydration();
// Each `<*.Outlet />` call creates an island with the matching outlet
// identifier; in dev mode the runtime emits a marker per outlet. The
// marker proves the outlet name was bound at the call site rather than
// collapsed to a single shared scope.
const sidebarMarker = await page.$(
'[data-testid="panels-sidebar"] [data-devtools-outlet="sidebar"]'
);
const contentMarker = await page.$(
'[data-testid="panels-content"] [data-devtools-outlet="content"]'
);
// Markers are dev-only; assert when present, skip otherwise so the
// assertion still passes against a production build.
if (sidebarMarker || contentMarker) {
expect(sidebarMarker).not.toBeNull();
expect(contentMarker).not.toBeNull();
}
});
});
// ── Virtual routes ──
describe("typed-file-router — virtual routes", () => {
test("renders virtual route at /virtual", async () => {
await page.goto(`${hostname}/virtual`);
await page.waitForLoadState("load");
expect(await page.textContent("body")).toContain("Virtual Route Page");
expect(await page.textContent("body")).toContain(
"virtual route in the config"
);
});
});
// ── Client-side navigation via typed Links ──
describe("typed-file-router — client-side navigation", () => {
test("navigates to about via typed Link click", async () => {
await page.goto(hostname);
await page.waitForLoadState("load");
await waitForHydration();
const prevUrl = page.url();
const aboutLink = await page.$('nav a[href="/about"]');
expect(aboutLink).not.toBeNull();
await aboutLink.click();
await waitForChange(null, () => page.url(), prevUrl);
expect(page.url()).toContain("/about");
expect(await page.textContent("body")).toContain("About");
});
test("navigates to dynamic user route via typed Link", async () => {
await page.goto(hostname);
await page.waitForLoadState("load");
await waitForHydration();
const prevUrl = page.url();
const userLink = await page.$('nav a[href="/user/42"]');
expect(userLink).not.toBeNull();
await userLink.click();
await waitForChange(null, () => page.url(), prevUrl);
expect(page.url()).toContain("/user/42");
expect(await page.textContent("body")).toContain("User Profile");
expect(await page.textContent("body")).toContain("42");
});
test("navigates from user page to User 99 via typed Link", async () => {
await page.goto(`${hostname}/user/42`);
await page.waitForLoadState("load");
await waitForHydration();
const prevUrl = page.url();
const user99Link = await page.$('a[href="/user/99"]');
expect(user99Link).not.toBeNull();
await user99Link.click();
await waitForChange(null, () => page.url(), prevUrl);
expect(page.url()).toContain("/user/99");
expect(await page.textContent("body")).toContain("99");
});
});
// ── Resource routes ──
describe("typed-file-router — resource routes", () => {
test("renders todos page with server-loaded data at /todos", async () => {
await page.goto(`${hostname}/todos`);
await page.waitForLoadState("load");
await waitForHydration();
expect(await page.textContent("body")).toContain("Todos");
// Verify todo items from the server resource loader
expect(await page.textContent("body")).toContain("Set up file router");
expect(await page.textContent("body")).toContain("Add resource files");
expect(await page.textContent("body")).toContain("Deploy to production");
});
test("todos page shows todo list with correct item count", async () => {
await page.goto(`${hostname}/todos`);
await page.waitForLoadState("load");
await waitForHydration();
const listItems = await page.$$('[data-testid="todos-list"] li');
expect(listItems.length).toBe(7);
});
test("navigates to todos via typed Link click", async () => {
await page.goto(hostname);
await page.waitForLoadState("load");
await waitForHydration();
const prevUrl = page.url();
const todosLink = await page.$('nav a[href="/todos"]');
expect(todosLink).not.toBeNull();
await todosLink.click();
await waitForChange(null, () => page.url(), prevUrl);
expect(page.url()).toContain("/todos");
expect(await page.textContent("body")).toContain("Todos");
});
test("todos page has filter links", async () => {
await page.goto(`${hostname}/todos`);
await page.waitForLoadState("load");
await waitForHydration();
expect(await page.textContent("body")).toContain("all");
expect(await page.textContent("body")).toContain("active");
expect(await page.textContent("body")).toContain("completed");
});
});
// ── Route matchers ──
describe("typed-file-router — route matchers", () => {
test("[sku=uppercase] matches uppercase SKU, sibling [sku] does not steal", async () => {
await page.goto(`${hostname}/product/ABC-123`);
await page.waitForLoadState("load");
expect(await page.textContent('[data-testid="route"]')).toBe(
"matched=[sku=uppercase]"
);
expect(await page.textContent('[data-testid="sku-upper"]')).toBe("ABC-123");
});
test("[sku=uppercase] rejects lowercase, [sku] catches the fallback", async () => {
await page.goto(`${hostname}/product/abc-123`);
await page.waitForLoadState("load");
expect(await page.textContent('[data-testid="route"]')).toBe(
"matched=[sku]"
);
expect(await page.textContent('[data-testid="sku-any"]')).toBe("abc-123");
});
test("[...slug=nested] matcher receives array, matches when length ≥ 2", async () => {
await page.goto(`${hostname}/docs/getting-started/install`);
await page.waitForLoadState("load");
expect(await page.textContent('[data-testid="route"]')).toBe(
"matched=[...slug=nested]"
);
expect(await page.textContent('[data-testid="slug"]')).toBe(
"getting-started/install"
);
});
test("[...slug=nested] rejects single segment, [...slug] catches fallback", async () => {
await page.goto(`${hostname}/docs/intro`);
await page.waitForLoadState("load");
expect(await page.textContent('[data-testid="route"]')).toBe(
"matched=[...slug]"
);
expect(await page.textContent('[data-testid="slug"]')).toBe("intro");
});
// Typed Link href construction — these asserts lock in the buildHref fix
// for matcher-aliased brackets. Before the fix, an alias-gated bracket was
// left verbatim in the emitted href (e.g. `/product/[sku=uppercase]`).
test("typed Link for [sku=uppercase] emits concrete URL with substituted params", async () => { await page.goto(hostname);
await page.waitForLoadState("load");
const link = await page.$('nav a[href="/product/ABC-123"]');
expect(link).not.toBeNull();
expect(await link.textContent()).toContain("Product ABC-123");
});
test("typed Link for [sku] fallback emits concrete URL with substituted params", async () => {
await page.goto(hostname);
await page.waitForLoadState("load");
const link = await page.$('nav a[href="/product/abc-123"]');
expect(link).not.toBeNull();
expect(await link.textContent()).toContain("Product abc-123");
});
test("typed Link for [...slug=nested] joins array params into a concrete URL", async () => {
await page.goto(hostname);
await page.waitForLoadState("load");
const link = await page.$('nav a[href="/docs/getting-started/install"]');
expect(link).not.toBeNull();
expect(await link.textContent()).toContain("Docs nested");
});
test("typed Link for [...slug] catch-all joins a single segment into a URL", async () => {
await page.goto(hostname);
await page.waitForLoadState("load");
const link = await page.$('nav a[href="/docs/intro"]');
expect(link).not.toBeNull();
expect(await link.textContent()).toContain("Docs flat");
});
// Client-side navigation — verifies matcher dispatch still runs correctly
// after hydration, not just on initial SSR. The cross-link on each demo
// page points at its sibling; clicking must re-match against the matcher.
test("client-side navigation from matcher page to fallback sibling re-runs matcher dispatch", async () => {
await page.goto(`${hostname}/product/ABC-123`);
await page.waitForLoadState("load");
await waitForHydration();
const prevUrl = page.url();
const fallbackLink = await page.$('a[href="/product/abc-123"]');
expect(fallbackLink).not.toBeNull();
await fallbackLink.click();
await waitForChange(null, () => page.url(), prevUrl);
expect(page.url()).toContain("/product/abc-123");
expect(await page.textContent('[data-testid="route"]')).toBe(
"matched=[sku]"
);
});
test("client-side navigation from fallback back to matcher page", async () => {
await page.goto(`${hostname}/product/abc-123`);
await page.waitForLoadState("load");
await waitForHydration();
const prevUrl = page.url();
const matcherLink = await page.$('a[href="/product/ABC-123"]');
expect(matcherLink).not.toBeNull();
await matcherLink.click();
await waitForChange(null, () => page.url(), prevUrl);
expect(page.url()).toContain("/product/ABC-123");
expect(await page.textContent('[data-testid="route"]')).toBe(
"matched=[sku=uppercase]"
);
});
test("client-side navigation from flat docs fallback to nested docs matcher", async () => {
await page.goto(`${hostname}/docs/intro`);
await page.waitForLoadState("load");
await waitForHydration();
const prevUrl = page.url();
const nestedLink = await page.$('a[href="/docs/getting-started/install"]');
expect(nestedLink).not.toBeNull();
await nestedLink.click();
await waitForChange(null, () => page.url(), prevUrl);
expect(page.url()).toContain("/docs/getting-started/install");
expect(await page.textContent('[data-testid="route"]')).toBe(
"matched=[...slug=nested]"
);
});
});
// ── Browser history ──
describe("typed-file-router — browser history", () => {
test("back and forward navigation works", async () => {
await page.goto(hostname);
await page.waitForLoadState("load");
await waitForHydration();
// Navigate to about
await page.goto(`${hostname}/about`);
await page.waitForLoadState("load");
// Navigate to dashboard
await page.goto(`${hostname}/dashboard`);
await page.waitForLoadState("load");
// Go back
await page.goBack();
await page.waitForLoadState("load");
expect(page.url()).toContain("/about");
// Go back again
await page.goBack();
await page.waitForLoadState("load");
expect(page.url()).toBe(`${hostname}/`);
// Go forward
await page.goForward();
await page.waitForLoadState("load");
expect(page.url()).toContain("/about");
});
});