344 lines
10 KiB
TypeScript
344 lines
10 KiB
TypeScript
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/);
|
|
});
|