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:
@@ -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> {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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,
|
||||||
);
|
);
|
||||||
|
|||||||
19
src/app/app.routes.server.spec.ts
Normal file
19
src/app/app.routes.server.spec.ts
Normal 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -9,5 +9,6 @@ export const serverRoutes: ServerRoute[] = [
|
|||||||
{
|
{
|
||||||
path: '**',
|
path: '**',
|
||||||
renderMode: RenderMode.Server,
|
renderMode: RenderMode.Server,
|
||||||
|
status: 404,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
8
tsconfig.e2e.json
Normal file
8
tsconfig.e2e.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./out-tsc/e2e",
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"include": ["e2e/**/*.ts", "playwright.config.ts"]
|
||||||
|
}
|
||||||
@@ -28,6 +28,9 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "./tsconfig.spec.json"
|
"path": "./tsconfig.spec.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.e2e.json"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user