Integrate PageSpeed Insights audits

This commit is contained in:
2026-06-04 22:12:59 +02:00
parent 99d61ac736
commit f0a948aec9
19 changed files with 3755 additions and 12 deletions

View File

@@ -0,0 +1,343 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
buildPageSpeedRequestUrl,
classifyPageSpeedError,
fetchPageSpeedResult,
normalizePageSpeedResult,
type PageSpeedErrorType,
} from "../lib/pagespeed-insights";
const MOBILE_RAW_FIXTURE = {
analysisUTCTimestamp: "2026-06-01T08:00:00.000Z",
lighthouseResult: {
finalUrl: "https://example.com/mobil",
categories: {
performance: { score: 0.81 },
accessibility: { score: 0.96 },
"best-practices": { score: 0.77 },
seo: { score: 0.94 },
},
audits: {
"first-contentful-paint": {
title: "Erste Inhalte",
numericValue: 2850,
score: 0.76,
},
"largest-contentful-paint": {
title: "Größtes Inhaltselement",
numericValue: 4300,
score: 0.6,
},
"cumulative-layout-shift": {
title: "Layout-Verschiebung",
numericValue: 0.14,
},
"total-blocking-time": {
title: "Blockierende Skripte",
numericValue: 420,
},
"speed-index": {
title: "Speed Index",
numericValue: 4800,
},
"unused-css-rules": {
title: "Nicht verwendetes CSS",
score: 0.4,
details: {
type: "opportunity",
overallSavingsMs: 380,
},
},
"modern-image-formats": {
title: "Moderne Bildformate",
score: 0.55,
details: {
type: "opportunity",
overallSavingsBytes: 52000,
},
},
},
},
};
const DESKTOP_RAW_FIXTURE = {
analysisUTCTimestamp: "2026-06-01T09:00:00.000Z",
lighthouseResult: {
finalUrl: "https://example.com/desktop",
categories: {
performance: { score: 0.93 },
accessibility: { score: 0.99 },
"best-practices": { score: 0.85 },
seo: { score: 0.97 },
},
audits: {
"first-contentful-paint": {
title: "Erste Inhalte",
numericValue: 1800,
score: 0.91,
},
"largest-contentful-paint": {
title: "Größtes Inhaltselement",
numericValue: 3600,
score: 0.73,
},
"cumulative-layout-shift": {
title: "Layout-Verschiebung",
numericValue: 0.08,
},
"total-blocking-time": {
title: "Blockierende Skripte",
numericValue: 310,
},
"speed-index": {
title: "Speed Index",
numericValue: 3800,
},
"offscreen-images": {
title: "Außenseiten-Bilder",
score: 0.9,
details: {
type: "opportunity",
overallSavingsMs: 210,
},
},
},
},
};
test("buildPageSpeedRequestUrl includes required query params and repeated categories", () => {
const url = buildPageSpeedRequestUrl({
url: "https://example.com/landing?x=1&y=2",
strategy: "mobile",
locale: "de-DE",
apiKey: "super-secret",
});
const parsed = new URL(url);
assert.equal(
parsed.origin + parsed.pathname,
"https://pagespeedonline.googleapis.com/pagespeedonline/v5/runPagespeed",
);
assert.equal(parsed.searchParams.get("url"), "https://example.com/landing?x=1&y=2");
assert.equal(parsed.searchParams.get("strategy"), "mobile");
assert.equal(parsed.searchParams.get("locale"), "de-DE");
assert.equal(parsed.searchParams.get("key"), "super-secret");
assert.deepEqual(parsed.searchParams.getAll("category"), [
"performance",
"accessibility",
"best-practices",
"seo",
]);
assert.equal(
parsed.search.includes("url=https%3A%2F%2Fexample.com%2Flanding%3Fx%3D1%26y%3D2"),
true,
"URL input should be encoded",
);
});
test("buildPageSpeedRequestUrl omits empty API keys", () => {
const url = buildPageSpeedRequestUrl({
url: "https://example.com",
strategy: "desktop",
apiKey: "",
locale: "de-DE",
});
const parsed = new URL(url);
assert.equal(parsed.searchParams.has("key"), false);
});
test("normalizePageSpeedResult maps mobile scores, metrics, and implications", () => {
const normalized = normalizePageSpeedResult({
strategy: "mobile",
sourceUrl: "https://example.com",
raw: MOBILE_RAW_FIXTURE,
});
assert.equal(normalized.strategy, "mobile");
assert.equal(normalized.sourceUrl, "https://example.com");
assert.equal(normalized.finalUrl, "https://example.com/mobil");
assert.equal(normalized.analysisTimestamp, "2026-06-01T08:00:00.000Z");
assert.equal(normalized.scores?.performance, 0.81);
assert.equal(normalized.scores?.accessibility, 0.96);
assert.equal(normalized.scores?.bestPractices, 0.77);
assert.equal(normalized.scores?.seo, 0.94);
assert.equal(normalized.metrics.firstContentfulPaintMs, 2850);
assert.equal(normalized.metrics.largestContentfulPaintMs, 4300);
assert.equal(normalized.metrics.cumulativeLayoutShift, 0.14);
assert.equal(normalized.metrics.totalBlockingTimeMs, 420);
assert.equal(normalized.metrics.speedIndexMs, 4800);
assert.equal(normalized.opportunities.length >= 1, true);
assert.equal(normalized.implications.length >= 2, true);
assert.equal(normalized.implications.some((text) => text.includes("Besucher")), true);
for (const implication of normalized.implications) {
assert.equal(
/score\s*\d+/i.test(implication),
false,
`Implication should not contain raw score text: ${implication}`,
);
assert.equal(implication.length > 0, true);
}
});
test("normalizePageSpeedResult maps desktop scores and metrics", () => {
const normalized = normalizePageSpeedResult({
strategy: "desktop",
sourceUrl: "https://example.com/landing",
raw: DESKTOP_RAW_FIXTURE,
});
assert.equal(normalized.strategy, "desktop");
assert.equal(normalized.sourceUrl, "https://example.com/landing");
assert.equal(normalized.finalUrl, "https://example.com/desktop");
assert.equal(normalized.analysisTimestamp, "2026-06-01T09:00:00.000Z");
assert.equal(normalized.scores?.performance, 0.93);
assert.equal(normalized.scores?.bestPractices, 0.85);
assert.equal(normalized.metrics.firstContentfulPaintMs, 1800);
assert.equal(normalized.metrics.speedIndexMs, 3800);
assert.equal(normalized.metrics.totalBlockingTimeMs, 310);
assert.equal(normalized.opportunities.length >= 1, true);
assert.equal(normalized.implications.length >= 2, true);
});
test("classifyPageSpeedError maps status and body signals", () => {
const quotaByStatus = classifyPageSpeedError({ status: 429 });
assert.equal(quotaByStatus.errorType, "quota");
const quotaByBody = classifyPageSpeedError({
status: 403,
body: { error: { errors: [{ reason: "userRateLimitExceeded" }] } },
});
assert.equal(quotaByBody.errorType, "quota");
const timeoutError = classifyPageSpeedError({
error: new DOMException("timed out", "AbortError"),
});
assert.equal(timeoutError.errorType, "timeout");
const unavailableByStatus = classifyPageSpeedError({ status: 404 });
assert.equal(unavailableByStatus.errorType, "unavailable");
const unavailableByBody = classifyPageSpeedError({
status: 500,
body: { error: { message: "Failed to fetch document from given URL" } },
});
assert.equal(unavailableByBody.errorType, "unavailable");
const invalidUrl = classifyPageSpeedError({
status: 400,
body: { error: { message: "Invalid URL: unsupported format" } },
});
assert.equal(invalidUrl.errorType, "invalid_url");
const apiError = classifyPageSpeedError({
status: 500,
body: { error: { message: "backend down" } },
});
assert.equal(apiError.errorType, "api_error");
assert.match(apiError.message, /backend down/);
});
test("classifyPageSpeedError returns unknown for non-classified cases", () => {
const classified = classifyPageSpeedError({
error: new Error("something odd"),
});
const errorType: PageSpeedErrorType = classified.errorType;
assert.equal(errorType, "unknown");
assert.match(classified.message, /something odd/);
});
test("fetchPageSpeedResult uses injected fetch and uses the built request URL", async () => {
const calls: string[] = [];
const fetchImpl = async (url: string) => {
calls.push(url);
return {
ok: true,
status: 200,
async json() {
return { ok: true };
},
} as Response;
};
const actual = await fetchPageSpeedResult({
url: "https://example.com/test?tracking=true",
strategy: "desktop",
apiKey: "secret-key",
fetchImpl,
});
assert.deepEqual(actual, { ok: true });
assert.equal(calls.length, 1);
const parsed = new URL(calls[0]);
assert.equal(parsed.searchParams.get("strategy"), "desktop");
assert.equal(parsed.searchParams.get("locale"), "de-DE");
assert.deepEqual(
parsed.searchParams.getAll("category"),
["performance", "accessibility", "best-practices", "seo"],
);
assert.equal(parsed.searchParams.get("key"), "secret-key");
});
test(
"fetchPageSpeedResult throws classified api_error when response.ok response has invalid JSON",
async () => {
const fetchImpl = async () =>
({
ok: true,
status: 200,
async json() {
throw new SyntaxError("Unexpected token <");
},
}) as unknown as Response;
let caughtError: unknown;
try {
await fetchPageSpeedResult({
url: "https://example.com/broken-json",
strategy: "mobile",
fetchImpl,
});
assert.fail("Expected fetchPageSpeedResult to throw");
} catch (error) {
caughtError = error;
}
assert.match(String((caughtError as Error).message), /Unexpected token </i);
assert.equal((caughtError as Error & { errorType?: string }).errorType, "api_error");
},
);
test("fetchPageSpeedResult preserves Google API error messages", async () => {
const fetchImpl = async () =>
({
ok: false,
status: 403,
async json() {
return {
error: {
code: 403,
message: "API key not valid. Please pass a valid API key.",
status: "PERMISSION_DENIED",
},
};
},
}) as unknown as Response;
let caughtError: unknown;
try {
await fetchPageSpeedResult({
url: "https://example.com/key-error",
strategy: "desktop",
fetchImpl,
});
assert.fail("Expected fetchPageSpeedResult to throw");
} catch (error) {
caughtError = error;
}
assert.equal((caughtError as Error & { errorType?: string }).errorType, "api_error");
assert.match(String((caughtError as Error).message), /API key not valid/);
});