Files
cloudflare__vinext/tests/trailing-slash.test.ts
T
James Anderson 2354ca6e60 test(trailing-slash): pin core enforcement contract for App Router (#1332) (#1635)
* test(trailing-slash): pin core enforcement contract for App Router (#1332)

Adds focused integration tests against a dev-server fixture built from
app-basic with a minimal `next.config.ts` overriding `trailingSlash`.

The core enforcement contract documented in Next.js
test/e2e/trailing-slashes and test/e2e/app-dir/trailingslash was already
implemented across the dev server, prod-server, app-rsc-handler, and the
deploy worker entry by #1395 and #1413, but no end-to-end test asserted
the full contract end-to-end through the dev server. These regression
tests close that gap so future refactors of the trailing-slash redirect
or `<Link>` normalisation paths fail loudly.

Coverage:
- trailingSlash: true
  - GET /about → 308 Location: /about/
  - GET /about/ → 200
  - <Link href="/about"> renders href="/about/"
- trailingSlash: false (default)
  - GET /about/ → 308 Location: /about
  - GET /about → 200
  - <Link href="/about"> renders href="/about" (no trailing slash)

Refs #1332.

* test(trailing-slash): address bonk review nits

- Rename tests/trailing-slash-1332.test.ts → tests/trailing-slash.test.ts
  to match repo naming convention (the issue ref lives in the header
  comment and describe titles).
- Switch to the shared `startFixtureServer` helper from tests/helpers.ts;
  drop the local `startServer` and the now-unused `createServer` / `Server`
  imports.
- Tighten the second describe title to "trailingSlash: false" — the
  fixture explicitly sets `false`, not omits the field. The default-vs-
  explicit behaviour is identical today, so the merged-name was misleading.
- Document why we keep `fs.cpSync` rather than `createIsolatedFixture`:
  app-basic's node_modules carries fixture-local symlinks
  (fake-context-lib, fake-css-lib, …) that `createIsolatedFixture` would
  break by replacing node_modules with a workspace symlink, and the
  isolated copy also gets its own `.vite` optimizer cache (preventing
  races against the workspace cache used by other tests). Verified
  empirically: the bot-suggested `createIsolatedFixture` swap reproduces
  500s on every request, mirroring the rationale already captured in
  tests/favicon-short-circuit.test.ts.

Refs #1332.

* test(trailing-slash): bump 200-serve timeouts to 30s for dev-mode compile

CI showed both 200-serve cases hitting the 5s default while the dev server
was compiling the /about route on first hit. Match the headroom we already
use elsewhere (e.g. tests/app-router.test.ts).

* style: apply biome formatter to trailing-slash.test.ts
2026-05-28 21:07:58 +01:00

126 lines
4.9 KiB
TypeScript

/**
* Regression coverage for issue #1332 — trailing slash configuration not
* enforced. These tests cover the core enforcement contract documented in
* Next.js test/e2e/trailing-slashes/* and test/e2e/app-dir/trailingslash/*:
*
* - With `trailingSlash: true`, a request to `/foo` returns 308 → `/foo/`
* - With `trailingSlash: false`, a request to `/foo/` returns 308 → `/foo`
* - App Router pages obey the redirect
* - <Link href="/foo"> renders as href="/foo/" when trailingSlash is true
*
* Refs cloudflare/vinext#1332
*/
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterAll, beforeAll, describe, expect, it } from "vite-plus/test";
import type { ViteDevServer } from "vite-plus";
import { APP_FIXTURE_DIR, startFixtureServer } from "./helpers.js";
/**
* Copy the app-basic fixture and overwrite next.config.ts to set only
* `trailingSlash`. We use `fs.cpSync({ recursive: true })` (not
* `createIsolatedFixture`) for two reasons:
* 1. app-basic's node_modules contains symlinks to fixture-local packages
* (`fake-context-lib`, etc.). `createIsolatedFixture` replaces
* node_modules with a workspace symlink and would break those.
* 2. Each isolated copy gets its own `.vite` dep-optimizer cache, so
* concurrent test runs cannot race on a shared optimization output.
* See `tests/favicon-short-circuit.test.ts` for the same rationale.
*/
function copyAppFixtureWithTrailingSlash(prefix: string, trailingSlash: boolean): string {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
fs.cpSync(APP_FIXTURE_DIR, tmpDir, { recursive: true });
fs.rmSync(path.join(tmpDir, "node_modules", ".vite"), { recursive: true, force: true });
// Replace the default app-basic next.config.ts. It carries a long list of
// redirects/rewrites/headers used by other suites; we want a clean slate so
// behaviour under test is exactly the trailingSlash policy.
fs.writeFileSync(
path.join(tmpDir, "next.config.ts"),
`import type { NextConfig } from "vinext";
const nextConfig: NextConfig = { trailingSlash: ${trailingSlash} };
export default nextConfig;
`,
);
return tmpDir;
}
describe("App Router trailingSlash: true (#1332)", () => {
let tmpDir: string;
let server: ViteDevServer;
let baseUrl: string;
beforeAll(async () => {
tmpDir = copyAppFixtureWithTrailingSlash("vinext-ts-true-", true);
({ server, baseUrl } = await startFixtureServer(tmpDir));
}, 60000);
afterAll(async () => {
await server?.close();
if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
});
it("redirects /about → /about/ with 308", async () => {
const res = await fetch(`${baseUrl}/about`, { redirect: "manual" });
expect(res.status).toBe(308);
const location = res.headers.get("location");
expect(location).not.toBeNull();
expect(new URL(location!, baseUrl).pathname).toBe("/about/");
});
// First hit triggers a dev-mode compile of the /about route; on CI this
// routinely exceeds the 5s default. Mirrors the headroom used elsewhere
// (see tests/app-router.test.ts).
it("serves /about/ with 200 (no redirect)", async () => {
const res = await fetch(`${baseUrl}/about/`, { redirect: "manual" });
expect(res.status).toBe(200);
const html = await res.text();
expect(html).toContain("About");
}, 30000);
it("home page <Link> renders href with a trailing slash", async () => {
// app-basic's homepage has <Link href="/about">. Under trailingSlash: true
// the rendered href should be normalised to "/about/".
const res = await fetch(`${baseUrl}/`);
expect(res.status).toBe(200);
const html = await res.text();
expect(html).toMatch(/href="\/about\/"/);
});
});
describe("App Router trailingSlash: false (#1332)", () => {
let tmpDir: string;
let server: ViteDevServer;
let baseUrl: string;
beforeAll(async () => {
tmpDir = copyAppFixtureWithTrailingSlash("vinext-ts-false-", false);
({ server, baseUrl } = await startFixtureServer(tmpDir));
}, 60000);
afterAll(async () => {
await server?.close();
if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
});
it("redirects /about/ → /about with 308", async () => {
const res = await fetch(`${baseUrl}/about/`, { redirect: "manual" });
expect(res.status).toBe(308);
expect(new URL(res.headers.get("location")!, baseUrl).pathname).toBe("/about");
});
it("serves /about with 200", async () => {
const res = await fetch(`${baseUrl}/about`, { redirect: "manual" });
expect(res.status).toBe(200);
const html = await res.text();
expect(html).toContain("About");
}, 30000);
it("home page <Link> renders href without a trailing slash", async () => {
const res = await fetch(`${baseUrl}/`);
expect(res.status).toBe(200);
const html = await res.text();
expect(html).toMatch(/href="\/about(?!\/)"/);
});
});