From 271df123346e028eccf3ffb8202ed232ef4d7a67 Mon Sep 17 00:00:00 2001 From: Antonio Ledebuhr Date: Tue, 25 Aug 2026 18:45:28 +0200 Subject: [PATCH] integration: return HTTP 404 for unmatched URLs and close remaining browser-gate gaps Co-authored-by: Cursor --- e2e/helpers.ts | 15 ++++++++++----- e2e/layout.e2e.ts | 26 +++++++++++++++++--------- e2e/seo.e2e.ts | 8 ++++++++ eslint.config.js | 13 +++++++++++-- src/app/app.routes.server.spec.ts | 19 +++++++++++++++++++ src/app/app.routes.server.ts | 1 + tsconfig.e2e.json | 8 ++++++++ tsconfig.json | 3 +++ 8 files changed, 77 insertions(+), 16 deletions(-) create mode 100644 src/app/app.routes.server.spec.ts create mode 100644 tsconfig.e2e.json diff --git a/e2e/helpers.ts b/e2e/helpers.ts index 1a829e7..43ba4b4 100644 --- a/e2e/helpers.ts +++ b/e2e/helpers.ts @@ -13,9 +13,13 @@ export function pagePath(routeId: RouteId, locale: AppLocale): string { return routePath(routeId, locale); } -export async function readHtml(request: APIRequestContext, path: string): Promise { +export async function readHtml( + request: APIRequestContext, + path: string, + expectedStatus: number, +): Promise { const response = await request.get(path); - expect(response.status(), `GET ${path} failed`).toBeLessThan(400); + expect(response.status(), `GET ${path} status`).toBe(expectedStatus); return response.text(); } @@ -42,7 +46,8 @@ export async function expectHead( ? '/en/missing-route' : '/missing-route' : pagePath(routeId, locale); - const html = await readHtml(request, path); + const expectedStatus = routeId === 'notFound' ? 404 : 200; + const html = await readHtml(request, path, expectedStatus); const metadata = buildRouteMetadata(routeId, locale, SITE_CONTENT_DATA); expect(html, `${path} title`).toContain(`${metadata.title}`); @@ -78,11 +83,11 @@ export async function expectHead( expect(html).toContain('name="twitter:description"'); } -export async function expectNoOverflow(page: Page): Promise { +export async function expectNoOverflow(page: Page, route: string, width: number): Promise { const overflow = await page.evaluate( () => document.documentElement.scrollWidth <= window.innerWidth + 1, ); - expect(overflow, `horizontal overflow at ${page.url()}`).toBe(true); + expect(overflow, `horizontal overflow at ${width}px on ${route}`).toBe(true); } export async function jsonLdGraph(page: Page): Promise { diff --git a/e2e/layout.e2e.ts b/e2e/layout.e2e.ts index 72f5735..f0fd7ad 100644 --- a/e2e/layout.e2e.ts +++ b/e2e/layout.e2e.ts @@ -13,11 +13,16 @@ const CHECKED = [ '/missing-route', ]; +const VIEWPORTS = [320, 768, 1024, 1440] as const; + test.describe('layout and crawlability', () => { test('does not overflow horizontally on checked routes', async ({ page }) => { - for (const path of CHECKED) { - await page.goto(path); - await expectNoOverflow(page); + for (const width of VIEWPORTS) { + await page.setViewportSize({ width, height: 900 }); + for (const path of CHECKED) { + await page.goto(path); + await expectNoOverflow(page, path, width); + } } }); @@ -33,14 +38,17 @@ test.describe('layout and crawlability', () => { seen.add(path); await page.goto(path); - const urls = await page.evaluate(() => { + const { urls, baseURI } = await page.evaluate(() => { const values = [ ...Array.from(document.querySelectorAll('a[href]'), (node) => node.getAttribute('href')), ...Array.from(document.querySelectorAll('[src]'), (node) => node.getAttribute('src')), ]; - return values.filter( - (value): value is string => typeof value === 'string' && value.length > 0, - ); + return { + baseURI: document.baseURI, + urls: values.filter( + (value): value is string => typeof value === 'string' && value.length > 0, + ), + }; }); for (const raw of urls) { @@ -48,8 +56,8 @@ test.describe('layout and crawlability', () => { continue; } - const resolved = new URL(raw, page.url()); - if (resolved.origin !== new URL(page.url()).origin) { + const resolved = new URL(raw, baseURI); + if (resolved.origin !== new URL(baseURI).origin) { continue; } diff --git a/e2e/seo.e2e.ts b/e2e/seo.e2e.ts index 2ed8a99..a39bdde 100644 --- a/e2e/seo.e2e.ts +++ b/e2e/seo.e2e.ts @@ -30,6 +30,14 @@ test.describe('server-rendered metadata', () => { } }); + test('unmatched URLs return 404 and a real route stays 200', async ({ request }) => { + const home = await request.get('/'); + expect(home.status(), 'GET /').toBe(200); + + await expectHead(request, 'notFound', 'de'); + await expectHead(request, 'notFound', 'en'); + }); + test('client navigation does not duplicate head tags', async ({ page }) => { await page.goto('/'); await page.locator('a.contact-cta').first().waitFor(); diff --git a/eslint.config.js b/eslint.config.js index 544c055..2504855 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -11,8 +11,6 @@ module.exports = tseslint.config( '.angular/**', 'node_modules/**', 'coverage/**', - 'e2e/**', - 'playwright.config.ts', 'playwright-report/**', 'test-results/**', '.lighthouseci/**', @@ -20,6 +18,7 @@ module.exports = tseslint.config( }, { files: ['**/*.ts'], + ignores: ['e2e/**/*.ts', 'playwright.config.ts'], extends: [ eslint.configs.recommended, ...tseslint.configs.recommended, @@ -49,5 +48,15 @@ module.exports = tseslint.config( files: ['**/*.html'], extends: [...angular.configs.templateRecommended, ...angular.configs.templateAccessibility], }, + { + files: ['e2e/**/*.ts', 'playwright.config.ts'], + extends: [eslint.configs.recommended, ...tseslint.configs.recommended], + languageOptions: { + parserOptions: { + project: './tsconfig.e2e.json', + tsconfigRootDir: __dirname, + }, + }, + }, prettier, ); diff --git a/src/app/app.routes.server.spec.ts b/src/app/app.routes.server.spec.ts new file mode 100644 index 0000000..ad32501 --- /dev/null +++ b/src/app/app.routes.server.spec.ts @@ -0,0 +1,19 @@ +import { RenderMode } from '@angular/ssr'; +import { serverRoutes } from './app.routes.server'; + +describe('server routes', () => { + it('returns HTTP 404 from the catch-all and leaves prerendered routes without a status', () => { + const catchAll = serverRoutes.find((route) => route.path === '**'); + + expect(catchAll?.renderMode).toBe(RenderMode.Server); + expect(catchAll && 'status' in catchAll ? catchAll.status : undefined).toBe(404); + + const prerendered = serverRoutes.filter((route) => route.path !== '**'); + expect(prerendered.length).toBeGreaterThan(0); + + for (const route of prerendered) { + expect(route.renderMode, route.path).toBe(RenderMode.Prerender); + expect('status' in route, `${route.path} must not set status`).toBe(false); + } + }); +}); diff --git a/src/app/app.routes.server.ts b/src/app/app.routes.server.ts index 59dc9eb..db62f29 100644 --- a/src/app/app.routes.server.ts +++ b/src/app/app.routes.server.ts @@ -9,5 +9,6 @@ export const serverRoutes: ServerRoute[] = [ { path: '**', renderMode: RenderMode.Server, + status: 404, }, ]; diff --git a/tsconfig.e2e.json b/tsconfig.e2e.json new file mode 100644 index 0000000..12ce1e9 --- /dev/null +++ b/tsconfig.e2e.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./out-tsc/e2e", + "types": ["node"] + }, + "include": ["e2e/**/*.ts", "playwright.config.ts"] +} diff --git a/tsconfig.json b/tsconfig.json index 2ab7442..2aa209d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -28,6 +28,9 @@ }, { "path": "./tsconfig.spec.json" + }, + { + "path": "./tsconfig.e2e.json" } ] }