From 84c18eb82c18ade9187735cbf9848a244bcebc34 Mon Sep 17 00:00:00 2001 From: SnapOtter Date: Sun, 19 Jul 2026 16:36:33 +0800 Subject: [PATCH] test(landing): guard _redirects against catalog drift (#575) Exports a pure buildRedirects() from generate-redirects.mjs and adds a unit test asserting the committed apps/landing/public/_redirects matches it, so tool additions can't silently leave the generated redirects stale (see #573). --- apps/landing/scripts/generate-redirects.mjs | 39 ++++++++++++++++----- tests/unit/landing/redirects-drift.test.ts | 24 +++++++++++++ 2 files changed, 54 insertions(+), 9 deletions(-) create mode 100644 tests/unit/landing/redirects-drift.test.ts diff --git a/apps/landing/scripts/generate-redirects.mjs b/apps/landing/scripts/generate-redirects.mjs index 5b5220c1..22334d81 100644 --- a/apps/landing/scripts/generate-redirects.mjs +++ b/apps/landing/scripts/generate-redirects.mjs @@ -1,16 +1,37 @@ // Generates public/_redirects (Cloudflare Pages) mapping old flat tool URLs // (/tools/) to the new section-nested URLs (/tools/
//). // Both slash variants are emitted because Cloudflare matches paths exactly. +// +// buildRedirects() is exported (side-effect-free) so a drift test can assert the +// committed public/_redirects still matches the catalog; the file is only +// written when this script runs directly (the landing prebuild). import { writeFileSync } from "node:fs"; -import { fileURLToPath } from "node:url"; +import { fileURLToPath, pathToFileURL } from "node:url"; import { TOOLS, toolSection } from "@snapotter/shared"; -const lines = ["# Generated by scripts/generate-redirects.mjs -- do not edit by hand"]; -for (const tool of TOOLS) { - const to = `/tools/${toolSection(tool)}/${tool.id}/`; - lines.push(`/tools/${tool.id}/ ${to} 301`); - lines.push(`/tools/${tool.id} ${to} 301`); +/** Build the full _redirects file contents from the shared TOOLS catalog. */ +export function buildRedirects() { + const lines = ["# Generated by scripts/generate-redirects.mjs -- do not edit by hand"]; + for (const tool of TOOLS) { + const to = `/tools/${toolSection(tool)}/${tool.id}/`; + lines.push(`/tools/${tool.id}/ ${to} 301`); + lines.push(`/tools/${tool.id} ${to} 301`); + } + return `${lines.join("\n")}\n`; +} + +/** Absolute path to the generated file. */ +export const REDIRECTS_PATH = fileURLToPath(new URL("../public/_redirects", import.meta.url)); + +function writeRedirects() { + const content = buildRedirects(); + writeFileSync(REDIRECTS_PATH, content); + const redirectLines = content.trimEnd().split("\n").length - 1; + console.log(`wrote ${REDIRECTS_PATH} (${TOOLS.length} tools, ${redirectLines} redirect lines)`); +} + +// Write only when executed directly (prebuild); importing for the drift test +// stays side-effect-free. +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + writeRedirects(); } -const out = fileURLToPath(new URL("../public/_redirects", import.meta.url)); -writeFileSync(out, `${lines.join("\n")}\n`); -console.log(`wrote ${out} (${TOOLS.length} tools, ${lines.length - 1} redirect lines)`); diff --git a/tests/unit/landing/redirects-drift.test.ts b/tests/unit/landing/redirects-drift.test.ts new file mode 100644 index 00000000..d1cc4712 --- /dev/null +++ b/tests/unit/landing/redirects-drift.test.ts @@ -0,0 +1,24 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; +import { + buildRedirects, + REDIRECTS_PATH, +} from "../../../apps/landing/scripts/generate-redirects.mjs"; + +/** + * Drift guard: apps/landing/public/_redirects is generated from the shared + * TOOLS catalog by scripts/generate-redirects.mjs (runs in the landing + * prebuild) but is committed, so it silently goes stale whenever a tool is + * added without regenerating it (that is how remove-gif-background's redirects + * went missing, PR #573). Adding a tool touches @snapotter/shared and runs this + * suite, so the drift fails here at PR time instead of shipping. + */ +describe("landing _redirects drift", () => { + it("committed public/_redirects matches the generator output", () => { + const committed = readFileSync(REDIRECTS_PATH, "utf8"); + expect( + committed, + "apps/landing/public/_redirects is stale. Regenerate it with `pnpm --filter @snapotter/landing prebuild` and commit the file.", + ).toBe(buildRedirects()); + }); +});