Audit 404 + 500 error pages per TZ §4.1.21

Gaps closed:
- noindex meta: ErrorPage now emits <meta name="robots" content="noindex,nofollow"> (R6)
- 500 refresh CTA: add PAGE500.REFRESH i18n key (9 locales) and a reload button (R5)
- SSR HTTP status: $.tsx converted to $/page.tsx+error.tsx; loader throws Response(404)
  so Modern.js emits the correct status code; same pattern for error/[code]/page.tsx (R8)
- Add error.tsx error-elements so the branded page renders after the loader throws

Pre-existing (already compliant): URL preservation, root link, all 9 locales, support link.
Tests: 34 new assertions cover R4–R8 (noindex, root link, refresh, i18n keys, loader status).
This commit is contained in:
2026-04-22 01:56:53 +03:00
parent 5286049420
commit a94b01cee9
16 changed files with 366 additions and 80 deletions
+3 -2
View File
@@ -150,7 +150,8 @@
"HEADER": "Serverfehler",
"PLACEHOLDER-INPUT": "Webseite durchsuchen",
"SUPPORT": "Support",
"TO-HOME": "STARTSEITE"
"TO-HOME": "STARTSEITE",
"REFRESH": "Seite neu laden"
},
"SCHEDULE": {
"DOWNLOAD-SCHEDULE": "Flugplan herunterladen",
@@ -429,4 +430,4 @@
"ROUTE": "",
"SCHEDULE-ROUTE": ""
}
}
}
+3 -2
View File
@@ -184,7 +184,8 @@
"HEADER": "Server error",
"PLACEHOLDER-INPUT": "Search the website",
"SUPPORT": "Support",
"TO-HOME": "HOME"
"TO-HOME": "HOME",
"REFRESH": "Refresh page"
},
"SCHEDULE": {
"COL-FLIGHT": "Flight",
@@ -464,4 +465,4 @@
"CONNECTING_FLIGHTS": "Show connecting flights",
"NO_DIRECTIONS_INFO": "No routes found, change the search parameters"
}
}
}
+3 -2
View File
@@ -150,7 +150,8 @@
"HEADER": "Error del servidor",
"PLACEHOLDER-INPUT": "Buscar en el sitio web",
"SUPPORT": "Asistencia",
"TO-HOME": "PÁGINA DE INICIO"
"TO-HOME": "PÁGINA DE INICIO",
"REFRESH": "Actualizar página"
},
"SCHEDULE": {
"DOWNLOAD-SCHEDULE": "Descargar programación",
@@ -429,4 +430,4 @@
"ROUTE": "",
"SCHEDULE-ROUTE": ""
}
}
}
+3 -2
View File
@@ -150,7 +150,8 @@
"HEADER": "Erreur de serveur",
"PLACEHOLDER-INPUT": "Interroger le site Web",
"SUPPORT": "Assistance",
"TO-HOME": "PAGE D'ACCUEIL"
"TO-HOME": "PAGE D'ACCUEIL",
"REFRESH": "Actualiser la page"
},
"SCHEDULE": {
"DOWNLOAD-SCHEDULE": "Télécharger les horaires",
@@ -429,4 +430,4 @@
"ROUTE": "",
"SCHEDULE-ROUTE": ""
}
}
}
+3 -2
View File
@@ -150,7 +150,8 @@
"HEADER": "Errore del server",
"PLACEHOLDER-INPUT": "Cerca sul sito web",
"SUPPORT": "Assistenza",
"TO-HOME": "Pagina iniziale"
"TO-HOME": "Pagina iniziale",
"REFRESH": "Aggiorna la pagina"
},
"SCHEDULE": {
"DOWNLOAD-SCHEDULE": "Scarica programma",
@@ -429,4 +430,4 @@
"ROUTE": "",
"SCHEDULE-ROUTE": ""
}
}
}
+3 -2
View File
@@ -150,7 +150,8 @@
"HEADER": "サーバーエラー",
"PLACEHOLDER-INPUT": "ウェブサイトを検索",
"SUPPORT": "サポート",
"TO-HOME": "ホーム"
"TO-HOME": "ホーム",
"REFRESH": "ページを更新"
},
"SCHEDULE": {
"DOWNLOAD-SCHEDULE": "スケジュールをダウンロード",
@@ -429,4 +430,4 @@
"ROUTE": "",
"SCHEDULE-ROUTE": ""
}
}
}
+3 -2
View File
@@ -150,7 +150,8 @@
"HEADER": "서버 오류",
"PLACEHOLDER-INPUT": "웹사이트 검색",
"SUPPORT": "지원",
"TO-HOME": "홈"
"TO-HOME": "홈",
"REFRESH": "페이지 새로고침"
},
"SCHEDULE": {
"DOWNLOAD-SCHEDULE": "일정 다운로드",
@@ -429,4 +430,4 @@
"ROUTE": "",
"SCHEDULE-ROUTE": ""
}
}
}
+3 -2
View File
@@ -184,7 +184,8 @@
"HEADER": "Ошибка на сервере",
"PLACEHOLDER-INPUT": "Поиск по сайту",
"SUPPORT": "Поддержка",
"TO-HOME": "НА ГЛАВНУЮ"
"TO-HOME": "НА ГЛАВНУЮ",
"REFRESH": "Обновить страницу"
},
"SCHEDULE": {
"COL-FLIGHT": "Рейс",
@@ -464,4 +465,4 @@
"CONNECTING_FLIGHTS": "Показать только рейсы с пересадкой",
"NO_DIRECTIONS_INFO": "Маршрутов не найдено, измените параметры поиска"
}
}
}
+3 -2
View File
@@ -150,7 +150,8 @@
"HEADER": "服务器错误",
"PLACEHOLDER-INPUT": "搜索网站",
"SUPPORT": "支持",
"TO-HOME": "主页"
"TO-HOME": "主页",
"REFRESH": "刷新页面"
},
"SCHEDULE": {
"DOWNLOAD-SCHEDULE": "下载航班表",
@@ -429,4 +430,4 @@
"ROUTE": "",
"SCHEDULE-ROUTE": ""
}
}
}
-15
View File
@@ -1,15 +0,0 @@
/**
* Global catch-all (splat) route.
*
* Modern.js/react-router resolves any URL that doesn't match a more
* specific route to this file. We render the branded 404 page so
* mistyped deep links like `/onlineboard//route/...` (double slash) or
* `/does-not-exist` land on the same "Страница не найдена" screen as
* the explicit `/error/404` route.
*/
import { ErrorPage } from "@/ui/errors/ErrorPage.js";
export default function NotFoundRoute(): JSX.Element {
return <ErrorPage code="404" />;
}
+12
View File
@@ -0,0 +1,12 @@
/**
* Error element for the catch-all splat route.
*
* React Router renders this when the splat loader throws a 404 Response.
* The URL is preserved in the address bar (no redirect).
*/
import { ErrorPage } from "@/ui/errors/ErrorPage.js";
export default function NotFoundErrorElement(): JSX.Element {
return <ErrorPage code="404" />;
}
+18
View File
@@ -0,0 +1,18 @@
/**
* Global catch-all (splat) route.
*
* Modern.js/react-router resolves any URL that doesn't match a more
* specific route here. The loader throws a 404 Response so SSR emits
* the correct HTTP status code (TZ §4.1.21). The errorElement (error.tsx)
* then renders the branded "Страница не найдена" page with URL preserved.
*/
export const loader = (): never => {
throw new Response(null, { status: 404 });
};
// The page component is unreachable (loader always throws), but Modern.js
// requires a default export for file-based route modules.
export default function NotFoundRoute(): null {
return null;
}
+15
View File
@@ -0,0 +1,15 @@
/**
* Error element for the /error/[code] route.
*
* React Router renders this when the route's loader throws a Response
* to set the correct HTTP status code (TZ §4.1.21).
* Reads the error code from the URL to render the branded page.
*/
import { useParams } from "@modern-js/runtime/router";
import { ErrorPage } from "@/ui/errors/ErrorPage.js";
export default function ErrorCodeElement(): JSX.Element {
const { code } = useParams<{ code: string }>();
return <ErrorPage {...(code ? { code } : {})} />;
}
+9
View File
@@ -1,6 +1,15 @@
import { useParams } from "@modern-js/runtime/router";
import type { LoaderFunction } from "@modern-js/runtime/router";
import { ErrorPage } from "@/ui/errors/ErrorPage.js";
/** Set the correct HTTP response status to match the error code (TZ §4.1.21). */
export const loader: LoaderFunction = ({ params }) => {
const code = params["code"];
const status = code === "404" ? 404 : code === "503" ? 503 : 500;
throw new Response(null, { status });
};
// errorElement renders the branded page when the loader throws above.
export default function ErrorRoute(): JSX.Element {
const { code } = useParams<{ code: string }>();
return <ErrorPage {...(code ? { code } : {})} />;
+216
View File
@@ -0,0 +1,216 @@
// @vitest-environment jsdom
/**
* TZ §4.1.21 — 404 + 500 error page compliance tests.
*
* Rules verified:
* R1 Unknown URL → 404 (route-level, not tested here — see $.error.tsx)
* R2 Server error → 500 (route-level, not tested here — see error/[code]/page.tsx)
* R3 URL preserved on error page (render in-place, no redirect)
* R4 404: link to root + localized message
* R5 500: refresh CTA + support contact + localized message
* R6 noindex meta on both pages
* R7 Translated for all 9 locales (i18n key presence check)
* R8 HTTP status matching (loader-level, integration-tested separately)
*/
import { render, screen, fireEvent } from "@testing-library/react";
import { ErrorPage } from "./ErrorPage.js";
// Suppress React act() warnings about async state updates in useEffect
beforeAll(() => {
vi.spyOn(console, "error").mockImplementation(() => {});
});
afterAll(() => {
vi.restoreAllMocks();
});
describe("4.1.21 — ErrorPage", () => {
// ── R6: noindex meta ──────────────────────────────────────────────────────
it("4.1.21-R6: 404 page renders noindex meta", () => {
render(<ErrorPage code="404" />);
const meta = document.querySelector('meta[name="robots"]');
expect(meta).not.toBeNull();
expect(meta?.getAttribute("content")).toMatch(/noindex/);
});
it("4.1.21-R6: 500 page renders noindex meta", () => {
render(<ErrorPage code="500" />);
const meta = document.querySelector('meta[name="robots"]');
expect(meta).not.toBeNull();
expect(meta?.getAttribute("content")).toMatch(/noindex/);
});
it("4.1.21-R6: unknown code fallback page renders noindex meta", () => {
render(<ErrorPage />);
const meta = document.querySelector('meta[name="robots"]');
expect(meta).not.toBeNull();
expect(meta?.getAttribute("content")).toMatch(/noindex/);
});
// ── R4: 404 page has root link ────────────────────────────────────────────
it("4.1.21-R4: 404 page has a link to root /", () => {
render(<ErrorPage code="404" />);
const rootLink = document.querySelector('a[href="/"]');
expect(rootLink).not.toBeNull();
});
it("4.1.21-R4: 500 page has a link to root /", () => {
render(<ErrorPage code="500" />);
const rootLink = document.querySelector('a[href="/"]');
expect(rootLink).not.toBeNull();
});
// ── R4: 404 localized message (SSR Russian fallback) ─────────────────────
it("4.1.21-R4: 404 page shows SSR Russian title fallback", () => {
render(<ErrorPage code="404" />);
// Before i18n resolves, title is the key itself
const page = screen.getByTestId("error-page-404");
expect(page).toBeDefined();
// Displays the numeric code
expect(page.textContent).toContain("404");
});
// ── R5: 500 page refresh CTA ─────────────────────────────────────────────
it("4.1.21-R5: 500 page shows refresh button", () => {
render(<ErrorPage code="500" />);
const refreshBtn = screen.getByTestId("error-page-refresh");
expect(refreshBtn).toBeDefined();
});
it("4.1.21-R5: 503 page shows refresh button", () => {
render(<ErrorPage code="503" />);
const refreshBtn = screen.getByTestId("error-page-refresh");
expect(refreshBtn).toBeDefined();
});
it("4.1.21-R5: 404 page does NOT show refresh button", () => {
render(<ErrorPage code="404" />);
const refreshBtn = document.querySelector('[data-testid="error-page-refresh"]');
expect(refreshBtn).toBeNull();
});
it("4.1.21-R5: refresh button calls window.location.reload", () => {
const reloadMock = vi.fn();
Object.defineProperty(window, "location", {
value: { reload: reloadMock, pathname: "/" },
writable: true,
});
render(<ErrorPage code="500" />);
const refreshBtn = screen.getByTestId("error-page-refresh");
fireEvent.click(refreshBtn);
expect(reloadMock).toHaveBeenCalledOnce();
});
// ── R5: 500 page has support link ─────────────────────────────────────────
it("4.1.21-R5: 500 page has support link", () => {
render(<ErrorPage code="500" />);
const supportLink = document.querySelector('a[href*="help"]');
expect(supportLink).not.toBeNull();
});
// ── R7: i18n keys present in all 9 locales ───────────────────────────────
// We verify the locale JSON files directly rather than loading the full i18n stack.
const LOCALES = ["ru", "en", "de", "es", "fr", "it", "ja", "ko", "zh"] as const;
it.each(LOCALES)(
"4.1.21-R7: locale %s has PAGE404 keys (HEADER, DESCRIPTION, TO-HOME)",
async (lang) => {
const mod = await import(`../../i18n/locales/${lang}/common.json`);
const data = mod.default as Record<string, Record<string, string>>;
expect(data["PAGE404"]).toBeDefined();
expect(data["PAGE404"]["HEADER"]).toBeTruthy();
expect(data["PAGE404"]["DESCRIPTION"]).toBeTruthy();
expect(data["PAGE404"]["TO-HOME"]).toBeTruthy();
},
);
it.each(LOCALES)(
"4.1.21-R7: locale %s has PAGE500 keys (HEADER, DESCRIPTION, REFRESH, SUPPORT)",
async (lang) => {
const mod = await import(`../../i18n/locales/${lang}/common.json`);
const data = mod.default as Record<string, Record<string, string>>;
expect(data["PAGE500"]).toBeDefined();
expect(data["PAGE500"]["HEADER"]).toBeTruthy();
expect(data["PAGE500"]["DESCRIPTION"]).toBeTruthy();
expect(data["PAGE500"]["REFRESH"]).toBeTruthy();
expect(data["PAGE500"]["SUPPORT"]).toBeTruthy();
},
);
// ── SSR status code loader tests ─────────────────────────────────────────
it("4.1.21-R8: splat route loader throws a 404 Response", async () => {
const { loader } = await import("../../routes/$/page.js");
expect(loader).toBeDefined();
let caught: Response | null = null;
try {
// loader is typed as () => never; it always throws
(loader as () => never)();
} catch (e) {
caught = e as Response;
}
expect(caught).toBeInstanceOf(Response);
expect((caught as Response).status).toBe(404);
});
it("4.1.21-R8: error/[code] loader throws 404 for code=404", async () => {
const { loader } = await import("../../routes/error/[code]/page.js");
expect(loader).toBeDefined();
let caught: Response | null = null;
try {
await (loader as (args: { params: Record<string, string> }) => Promise<never>)(
{ params: { code: "404" } }
);
} catch (e) {
caught = e as Response;
}
expect(caught).toBeInstanceOf(Response);
expect((caught as Response).status).toBe(404);
});
it("4.1.21-R8: error/[code] loader throws 500 for code=500", async () => {
const { loader } = await import("../../routes/error/[code]/page.js");
let caught: Response | null = null;
try {
await (loader as (args: { params: Record<string, string> }) => Promise<never>)(
{ params: { code: "500" } }
);
} catch (e) {
caught = e as Response;
}
expect((caught as Response).status).toBe(500);
});
it("4.1.21-R8: error/[code] loader throws 503 for code=503", async () => {
const { loader } = await import("../../routes/error/[code]/page.js");
let caught: Response | null = null;
try {
await (loader as (args: { params: Record<string, string> }) => Promise<never>)(
{ params: { code: "503" } }
);
} catch (e) {
caught = e as Response;
}
expect((caught as Response).status).toBe(503);
});
it("4.1.21-R8: error/[code] loader throws 500 for unknown code", async () => {
const { loader } = await import("../../routes/error/[code]/page.js");
let caught: Response | null = null;
try {
await (loader as (args: { params: Record<string, string> }) => Promise<never>)(
{ params: { code: "418" } }
);
} catch (e) {
caught = e as Response;
}
expect((caught as Response).status).toBe(500);
});
});
+69 -47
View File
@@ -29,6 +29,8 @@ interface ErrorConfig {
buyTicketKey: string;
homeKey: string;
supportKey: string;
/** Present only for 5xx pages — triggers a refresh CTA button. */
refreshKey?: string;
}
const ERROR_CONFIG: Record<string, ErrorConfig> = {
@@ -47,6 +49,7 @@ const ERROR_CONFIG: Record<string, ErrorConfig> = {
buyTicketKey: "PAGE500.BUY-TICKET",
homeKey: "PAGE500.TO-HOME",
supportKey: "PAGE500.SUPPORT",
refreshKey: "PAGE500.REFRESH",
},
"503": {
titleKey: "PAGE500.HEADER",
@@ -55,6 +58,7 @@ const ERROR_CONFIG: Record<string, ErrorConfig> = {
buyTicketKey: "PAGE500.BUY-TICKET",
homeKey: "PAGE500.TO-HOME",
supportKey: "PAGE500.SUPPORT",
refreshKey: "PAGE500.REFRESH",
},
};
@@ -65,6 +69,7 @@ const FALLBACK_CONFIG: ErrorConfig = {
buyTicketKey: "PAGE500.BUY-TICKET",
homeKey: "PAGE500.TO-HOME",
supportKey: "PAGE500.SUPPORT",
refreshKey: "PAGE500.REFRESH",
};
export interface ErrorPageProps {
@@ -76,7 +81,7 @@ export function ErrorPage({ code }: ErrorPageProps): JSX.Element {
const config = (code ? ERROR_CONFIG[code] : undefined) ?? FALLBACK_CONFIG;
const searchRef = useRef<HTMLInputElement>(null);
const [translations, setTranslations] = useState<Record<string, string> | null>(null);
const [translations, setTranslations] = useState<Record<string, string | undefined> | null>(null);
const [searchTerm, setSearchTerm] = useState("");
useEffect(() => {
@@ -92,6 +97,7 @@ export function ErrorPage({ code }: ErrorPageProps): JSX.Element {
buyTicket: t(config.buyTicketKey),
home: t(config.homeKey),
support: t(config.supportKey),
refresh: config.refreshKey ? t(config.refreshKey) : undefined,
});
});
}, [config]);
@@ -112,56 +118,72 @@ export function ErrorPage({ code }: ErrorPageProps): JSX.Element {
const buyTicket = translations?.buyTicket ?? "Купить билет";
const home = translations?.home ?? "На главную";
const support = translations?.support ?? "Поддержка";
const refresh = translations?.refresh ?? (config.refreshKey ? "Обновить страницу" : undefined);
const displayCode = code ?? "?";
return (
<div className="error-page" data-testid={`error-page-${code ?? "unknown"}`}>
<section className="error-page__section frame">
<div
className={`error-page__illustration errorCode-${code ?? "500"}`}
style={{ backgroundImage: `url('${config.image}')` }}
/>
<div className="error-page__content">
<div className="error-page__code">{displayCode}</div>
<div className="error-page__title">{title}</div>
<div className="error-page__description">{description}</div>
<div className="error-page__search">
<div className="error-page__search-control">
<input
ref={searchRef}
type="search"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
/>
<div
className="error-page__search-icon"
onClick={handleSearch}
/>
<>
{/* noindex: error pages must not be indexed (TZ §4.1.21) */}
<meta name="robots" content="noindex,nofollow" />
<div className="error-page" data-testid={`error-page-${code ?? "unknown"}`}>
<section className="error-page__section frame">
<div
className={`error-page__illustration errorCode-${code ?? "500"}`}
style={{ backgroundImage: `url('${config.image}')` }}
/>
<div className="error-page__content">
<div className="error-page__code">{displayCode}</div>
<div className="error-page__title">{title}</div>
<div className="error-page__description">{description}</div>
<div className="error-page__search">
<div className="error-page__search-control">
<input
ref={searchRef}
type="search"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
/>
<div
className="error-page__search-icon"
onClick={handleSearch}
/>
</div>
</div>
<div className="error-page__actions">
<a
className="error-page__btn error-page__btn--primary"
href={`https://www.aeroflot.ru/booking?from=${code ?? "500"}`}
>
{buyTicket}
</a>
<a
className="error-page__btn error-page__btn--secondary"
href="/"
>
{home}
</a>
{/* 5xx pages: refresh CTA (TZ §4.1.21) */}
{refresh && (
<button
type="button"
className="error-page__btn error-page__btn--refresh"
data-testid="error-page-refresh"
onClick={() => window.location.reload()}
>
{refresh}
</button>
)}
<a
className="error-page__btn error-page__btn--link"
href={`https://www.aeroflot.ru/help?from=${code ?? "500"}`}
>
{support}
</a>
</div>
</div>
<div className="error-page__actions">
<a
className="error-page__btn error-page__btn--primary"
href={`https://www.aeroflot.ru/booking?from=${code ?? "500"}`}
>
{buyTicket}
</a>
<a
className="error-page__btn error-page__btn--secondary"
href="/"
>
{home}
</a>
<a
className="error-page__btn error-page__btn--link"
href={`https://www.aeroflot.ru/help?from=${code ?? "500"}`}
>
{support}
</a>
</div>
</div>
</section>
</div>
</section>
</div>
</>
);
}