integration: return HTTP 404 for unmatched URLs and close remaining browser-gate gaps

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-25 18:45:28 +02:00
parent 34367181d2
commit 271df12334
8 changed files with 77 additions and 16 deletions

View File

@@ -13,9 +13,13 @@ export function pagePath(routeId: RouteId, locale: AppLocale): string {
return routePath(routeId, locale); return routePath(routeId, locale);
} }
export async function readHtml(request: APIRequestContext, path: string): Promise<string> { export async function readHtml(
request: APIRequestContext,
path: string,
expectedStatus: number,
): Promise<string> {
const response = await request.get(path); 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(); return response.text();
} }
@@ -42,7 +46,8 @@ export async function expectHead(
? '/en/missing-route' ? '/en/missing-route'
: '/missing-route' : '/missing-route'
: pagePath(routeId, locale); : 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); const metadata = buildRouteMetadata(routeId, locale, SITE_CONTENT_DATA);
expect(html, `${path} title`).toContain(`<title>${metadata.title}</title>`); expect(html, `${path} title`).toContain(`<title>${metadata.title}</title>`);
@@ -78,11 +83,11 @@ export async function expectHead(
expect(html).toContain('name="twitter:description"'); expect(html).toContain('name="twitter:description"');
} }
export async function expectNoOverflow(page: Page): Promise<void> { export async function expectNoOverflow(page: Page, route: string, width: number): Promise<void> {
const overflow = await page.evaluate( const overflow = await page.evaluate(
() => document.documentElement.scrollWidth <= window.innerWidth + 1, () => 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<unknown> { export async function jsonLdGraph(page: Page): Promise<unknown> {

View File

@@ -13,11 +13,16 @@ const CHECKED = [
'/missing-route', '/missing-route',
]; ];
const VIEWPORTS = [320, 768, 1024, 1440] as const;
test.describe('layout and crawlability', () => { test.describe('layout and crawlability', () => {
test('does not overflow horizontally on checked routes', async ({ page }) => { test('does not overflow horizontally on checked routes', async ({ page }) => {
for (const path of CHECKED) { for (const width of VIEWPORTS) {
await page.goto(path); await page.setViewportSize({ width, height: 900 });
await expectNoOverflow(page); 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); seen.add(path);
await page.goto(path); await page.goto(path);
const urls = await page.evaluate(() => { const { urls, baseURI } = await page.evaluate(() => {
const values = [ const values = [
...Array.from(document.querySelectorAll('a[href]'), (node) => node.getAttribute('href')), ...Array.from(document.querySelectorAll('a[href]'), (node) => node.getAttribute('href')),
...Array.from(document.querySelectorAll('[src]'), (node) => node.getAttribute('src')), ...Array.from(document.querySelectorAll('[src]'), (node) => node.getAttribute('src')),
]; ];
return values.filter( return {
(value): value is string => typeof value === 'string' && value.length > 0, baseURI: document.baseURI,
); urls: values.filter(
(value): value is string => typeof value === 'string' && value.length > 0,
),
};
}); });
for (const raw of urls) { for (const raw of urls) {
@@ -48,8 +56,8 @@ test.describe('layout and crawlability', () => {
continue; continue;
} }
const resolved = new URL(raw, page.url()); const resolved = new URL(raw, baseURI);
if (resolved.origin !== new URL(page.url()).origin) { if (resolved.origin !== new URL(baseURI).origin) {
continue; continue;
} }

View File

@@ -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 }) => { test('client navigation does not duplicate head tags', async ({ page }) => {
await page.goto('/'); await page.goto('/');
await page.locator('a.contact-cta').first().waitFor(); await page.locator('a.contact-cta').first().waitFor();

View File

@@ -11,8 +11,6 @@ module.exports = tseslint.config(
'.angular/**', '.angular/**',
'node_modules/**', 'node_modules/**',
'coverage/**', 'coverage/**',
'e2e/**',
'playwright.config.ts',
'playwright-report/**', 'playwright-report/**',
'test-results/**', 'test-results/**',
'.lighthouseci/**', '.lighthouseci/**',
@@ -20,6 +18,7 @@ module.exports = tseslint.config(
}, },
{ {
files: ['**/*.ts'], files: ['**/*.ts'],
ignores: ['e2e/**/*.ts', 'playwright.config.ts'],
extends: [ extends: [
eslint.configs.recommended, eslint.configs.recommended,
...tseslint.configs.recommended, ...tseslint.configs.recommended,
@@ -49,5 +48,15 @@ module.exports = tseslint.config(
files: ['**/*.html'], files: ['**/*.html'],
extends: [...angular.configs.templateRecommended, ...angular.configs.templateAccessibility], 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, prettier,
); );

View File

@@ -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);
}
});
});

View File

@@ -9,5 +9,6 @@ export const serverRoutes: ServerRoute[] = [
{ {
path: '**', path: '**',
renderMode: RenderMode.Server, renderMode: RenderMode.Server,
status: 404,
}, },
]; ];

8
tsconfig.e2e.json Normal file
View File

@@ -0,0 +1,8 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/e2e",
"types": ["node"]
},
"include": ["e2e/**/*.ts", "playwright.config.ts"]
}

View File

@@ -28,6 +28,9 @@
}, },
{ {
"path": "./tsconfig.spec.json" "path": "./tsconfig.spec.json"
},
{
"path": "./tsconfig.e2e.json"
} }
] ]
} }