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,365 @@
import assert from "node:assert/strict";
import { existsSync, readFileSync } from "node:fs";
import path from "node:path";
import test from "node:test";
import ts from "typescript";
const actionPath = path.join(process.cwd(), "convex", "pageSpeedAction.ts");
const actionSource = existsSync(actionPath) ? readFileSync(actionPath, "utf8") : "";
const actionSourceFile = ts.createSourceFile(
"pageSpeedAction.ts",
actionSource,
ts.ScriptTarget.ES2022,
true,
ts.ScriptKind.TS,
);
function getExportedConstNames(file: ts.SourceFile) {
const names = new Set<string>();
const visit = (node: ts.Node) => {
if (ts.isVariableStatement(node)) {
const isExported = node.modifiers?.some(
(mod) => mod.kind === ts.SyntaxKind.ExportKeyword,
);
if (!isExported) {
ts.forEachChild(node, visit);
return;
}
const isConst = node.declarationList.flags & ts.NodeFlags.Const;
if (!isConst) {
ts.forEachChild(node, visit);
return;
}
for (const declaration of node.declarationList.declarations) {
if (ts.isIdentifier(declaration.name)) {
names.add(declaration.name.text);
}
}
}
ts.forEachChild(node, visit);
};
ts.forEachChild(file, visit);
return names;
}
function hasPattern(source: string, pattern: RegExp) {
return pattern.test(source);
}
test("pageSpeedAction module exists and runs in Node runtime", () => {
assert.equal(existsSync(actionPath), true, "pageSpeedAction.ts should exist");
assert.equal(
hasPattern(actionSource, /^"use node";/m),
true,
"pageSpeedAction.ts should use Node runtime",
);
});
test("pageSpeedAction exports processPageSpeedAudit as internalAction with runId validator", () => {
const exports = getExportedConstNames(actionSourceFile);
assert.equal(
exports.has("processPageSpeedAudit"),
true,
"processPageSpeedAudit should be exported",
);
assert.equal(
hasPattern(
actionSource,
/processPageSpeedAudit\s*=\s*internalAction\(\s*{\s*args:\s*{[\s\S]*runId:\s*v\.id\(\s*["']agentRuns["']\s*\)/,
),
true,
"processPageSpeedAudit should be an internalAction with runId validator",
);
});
test("pageSpeedAction starts and finishes run mutations", () => {
assert.equal(
hasPattern(
actionSource,
/internal\.pageSpeed\.startPageSpeedAuditRun/,
),
true,
"Action should call internal.pageSpeed.startPageSpeedAuditRun",
);
assert.equal(
hasPattern(
actionSource,
/internal\.pageSpeed\.finishPageSpeedAuditRun/,
),
true,
"Action should call internal.pageSpeed.finishPageSpeedAuditRun",
);
});
test("pageSpeedAction has action-level guard to fail whole run on unexpected errors", () => {
assert.equal(
hasPattern(
actionSource,
/try\s*{[\s\S]*?await ctx\.runMutation\(internal\.pageSpeed\.startPageSpeedAuditRun,\s*{[\s\S]*?}\);\s*[\s\S]*?for\s*\(\s*(?:const|let)\s+strategy\s+of\s+STRATEGIES[\s\S]*?\}\s*catch \(error\)\s*{[\s\S]*classifyPageSpeedFailure\(error,\s*apiKeyRaw\)[\s\S]*?internal\.pageSpeed\.finishPageSpeedAuditRun[\s\S]*status:\s*["']failed["']/,
),
true,
"Action should wrap run lifecycle in an outer try/catch that finalizes the run as failed.",
);
});
test("pageSpeedAction enforces raw payload size guard before storage", () => {
assert.equal(
hasPattern(actionSource, /MAX_RAW_PAGESPEED_BYTES/),
true,
"Action should declare MAX_RAW_PAGESPEED_BYTES constant.",
);
assert.equal(
hasPattern(
actionSource,
/new TextEncoder\(\)\.encode\(rawJson\)\.byteLength/,
),
true,
"Action should calculate raw JSON byte length before attempting to store.",
);
assert.equal(
hasPattern(
actionSource,
/rawJsonBytes\s*>\s*MAX_RAW_PAGESPEED_BYTES[\s\S]*?errorType:\s*["']api_error["'][\s\S]*?errorSummary:\s*RAW_PAGESPEED_BYTES_SUMMARY/,
),
true,
"Oversized raw payloads should be rejected as api_error with the required German summary.",
);
assert.equal(
hasPattern(
actionSource,
/new Blob\(\[rawJson\][\s\S]*type:\s*["']application\/json["']/,
),
true,
"Normal raw payloads should still be stored as application/json blobs.",
);
assert.equal(
hasPattern(
actionSource,
/if\s*\(\s*rawJsonBytes\s*>\s*MAX_RAW_PAGESPEED_BYTES[\s\S]*?}\s*[\s\S]*?continue;[\s\S]*?await ctx\.storage\.store\(/,
),
true,
"Raw payload storage must be skipped for oversized payloads.",
);
});
test("pageSpeedAction runs both strategies and catches per-strategy errors", () => {
assert.equal(
hasPattern(
actionSource,
/["']mobile["'][\s\S]*["']desktop["']/,
),
true,
"Action should include both page speed strategies: mobile and desktop",
);
assert.equal(
hasPattern(
actionSource,
/for\s*\(\s*(?:const|let)\s+strategy\s+of[\s\S]*?\)\s*{[\s\S]*?try[\s\S]*?catch\s*\([^)]*\)[\s\S]*?}/,
),
true,
"Action should catch errors inside per-strategy loop",
);
});
test("pageSpeedAction stores and persists results and writes events", () => {
assert.equal(
hasPattern(
actionSource,
/ctx\.storage\.store\([\s\S]*new Blob\(\[\s*rawJson\s*[\s\S]*type:\s*["']application\/json["']/,
),
true,
"Raw PageSpeed payload should be stored via ctx.storage.store with application/json blob",
);
assert.equal(
hasPattern(
actionSource,
/internal\.pageSpeed\.persistPageSpeedResult[\s\S]*status:\s*["']succeeded["']/,
),
true,
"Action should persist succeeded PageSpeed results",
);
assert.equal(
hasPattern(
actionSource,
/internal\.pageSpeed\.persistPageSpeedResult[\s\S]*status:\s*["']failed["']/,
),
true,
"Action should persist failed PageSpeed results",
);
assert.equal(
/api\.runs\.appendEvent,\s*{\s*[\s\S]*runId:\s*args\.runId,\s*[\s\S]*level:\s*["']info["']/.test(
actionSource,
),
true,
"Action should append info events for successful strategy results",
);
assert.equal(
/level:\s*["']warning["']/.test(actionSource) ||
/level:\s*["']error["']/.test(actionSource),
true,
"Action should append warning/error events for failed strategy results",
);
});
test("pageSpeedAction strips non-persisted normalized fields before Convex mutation", () => {
assert.equal(
hasPattern(actionSource, /toPersistedPageSpeedNormalizedResult/),
true,
"Action should map normalized PageSpeed output into the Convex validator shape.",
);
assert.equal(
hasPattern(
actionSource,
/normalized:\s*toPersistedPageSpeedNormalizedResult\(normalized\)/,
),
true,
"Action should persist only the normalized subset accepted by convex/pageSpeed.ts.",
);
assert.equal(
hasPattern(
actionSource,
/normalized,\s*[\r\n]/,
),
false,
"Action should not pass the full normalized object with strategy/sourceUrl/finalUrl/analysisTimestamp.",
);
});
test("pageSpeedAction does not expose API key in event messages/details", () => {
assert.equal(
hasPattern(
actionSource,
/api\.runs\.appendEvent[\s\S]{0,500}PAGESPEED_API_KEY/,
),
false,
"Action events should not include raw PAGESPEED_API_KEY",
);
});
test("pageSpeedAction imports PageSpeed helpers from lib/pagespeed-insights", () => {
const hasLibImport =
actionSource.includes("fetchPageSpeedResult") &&
actionSource.includes("normalizePageSpeedResult") &&
actionSource.includes("classifyPageSpeedError") &&
actionSource.includes('from "../lib/pagespeed-insights"');
assert.equal(hasLibImport, true, "Action should import required PageSpeed helper functions");
});
test("pageSpeedAction exposes configurable PageSpeed timeout via env var", () => {
assert.equal(
hasPattern(
actionSource,
/PAGESPEED_TIMEOUT_MS/
),
true,
"PageSpeed timeout should be configurable with PAGESPEED_TIMEOUT_MS.",
);
assert.equal(
hasPattern(actionSource, /DEFAULT_PAGESPEED_TIMEOUT_MS\s*=\s*60_000/),
true,
"PageSpeed timeout default should be 60_000ms.",
);
assert.equal(
hasPattern(actionSource, /MIN_PAGESPEED_TIMEOUT_MS\s*=\s*10_000/),
true,
"PageSpeed timeout min clamp should be 10_000ms.",
);
assert.equal(
hasPattern(actionSource, /MAX_PAGESPEED_TIMEOUT_MS\s*=\s*120_000/),
true,
"PageSpeed timeout max clamp should be 120_000ms.",
);
});
test("pageSpeedAction parses and clamps timeout values before use", () => {
assert.equal(
hasPattern(
actionSource,
/function parsePageSpeedTimeoutMs\(\s*raw:\s*string \| undefined\)/,
),
true,
"Action should parse PAGESPEED_TIMEOUT_MS via a dedicated helper.",
);
assert.equal(
hasPattern(actionSource, /Number\.parseInt\(raw,\s*10\)/),
true,
"Action should parse env timeout values as decimal integers.",
);
assert.equal(
hasPattern(actionSource, /Number\.isFinite\(/),
true,
"Invalid timeout values should be handled via Number.isFinite validation.",
);
assert.equal(
hasPattern(
actionSource,
/Math\.max\(\s*parsed,\s*MIN_PAGESPEED_TIMEOUT_MS\s*\)/,
),
true,
"Timeout below min should be clamped.",
);
assert.equal(
hasPattern(
actionSource,
/Math\.min\(\s*[\s\S]*MAX_PAGESPEED_TIMEOUT_MS\s*,\s*\)/,
),
true,
"Timeout above max should be clamped.",
);
});
test("pageSpeedAction passes resolved timeout to PageSpeed fetch calls", () => {
assert.equal(
hasPattern(
actionSource,
/const timeoutMs = resolvePageSpeedTimeoutMs\(\)/,
),
true,
"Action should resolve timeout once from helper and pass it to fetch calls.",
);
assert.equal(
hasPattern(
actionSource,
/fetchPageSpeedResult\([\s\S]{0,250}timeoutMs,/
),
true,
"Action should pass resolved timeout to fetchPageSpeedResult.",
);
assert.equal(
hasPattern(
actionSource,
/const timeoutMs\s*=\s*10_000/,
),
false,
"Timeout should not be hardcoded to 10_000ms in processPageSpeedAudit.",
);
});

View File

@@ -0,0 +1,191 @@
import assert from "node:assert/strict";
import { existsSync, readFileSync } from "node:fs";
import path from "node:path";
import test from "node:test";
import ts from "typescript";
const auditInputsPath = path.join(process.cwd(), "convex", "auditInputs.ts");
const auditInputsSource = existsSync(auditInputsPath)
? readFileSync(auditInputsPath, "utf8")
: "";
const sourceFile = ts.createSourceFile(
"auditInputs.ts",
auditInputsSource,
ts.ScriptTarget.ES2022,
true,
ts.ScriptKind.TS,
);
function getExportedConstNames(file: ts.SourceFile) {
const names = new Set<string>();
const visit = (node: ts.Node) => {
if (ts.isVariableStatement(node)) {
const isExported = node.modifiers?.some(
(mod) => mod.kind === ts.SyntaxKind.ExportKeyword,
);
if (!isExported) {
ts.forEachChild(node, visit);
return;
}
const isConst = node.declarationList.flags & ts.NodeFlags.Const;
if (!isConst) {
ts.forEachChild(node, visit);
return;
}
for (const declaration of node.declarationList.declarations) {
if (ts.isIdentifier(declaration.name)) {
names.add(declaration.name.text);
}
}
}
ts.forEachChild(node, visit);
};
ts.forEachChild(file, visit);
return names;
}
function hasPattern(source: string, pattern: RegExp) {
return pattern.test(source);
}
function extractExportSource(name: string) {
const marker = `export const ${name} = `;
const declarationIndex = auditInputsSource.indexOf(marker);
assert.notEqual(declarationIndex, -1, `Expected declaration for ${name}`);
const openBraceIndex = auditInputsSource.indexOf("{", declarationIndex);
let depth = 0;
let end = -1;
for (let index = openBraceIndex; index < auditInputsSource.length; index += 1) {
const char = auditInputsSource[index];
if (char === "{") {
depth += 1;
} else if (char === "}") {
depth -= 1;
if (depth === 0) {
end = index;
break;
}
}
}
assert.notEqual(end, -1, `Expected balanced braces for ${name}`);
return auditInputsSource.slice(openBraceIndex, end + 1);
}
test("auditInputs module exists and exports pageSpeed input translator query", () => {
assert.equal(
existsSync(auditInputsPath),
true,
"convex/auditInputs.ts should be present",
);
const exports = getExportedConstNames(sourceFile);
assert.equal(
exports.has("getPageSpeedAuditInputs"),
true,
"auditInputs module should export getPageSpeedAuditInputs",
);
});
test("auditInputs module calls buildPageSpeedAuditInputs for lead/audit PageSpeed results", () => {
const querySource = extractExportSource("getPageSpeedAuditInputs");
assert.equal(
hasPattern(
auditInputsSource,
/buildPageSpeedAuditInputs[\s\S]*?from\s*["']\.\.\/lib\/pagespeed-audit-input["']/,
),
true,
"auditInputs should import buildPageSpeedAuditInputs",
);
assert.equal(
hasPattern(querySource, /buildPageSpeedAuditInputs\(results\.map\(/),
true,
"auditInputs should call buildPageSpeedAuditInputs",
);
});
test("auditInputs query fetches stored pageSpeedResults by lead or audit", () => {
const querySource = extractExportSource("getPageSpeedAuditInputs");
assert.equal(
hasPattern(
auditInputsSource,
/getPageSpeedAuditInputs\s*=\s*internalQuery\s*\(/,
),
true,
"getPageSpeedAuditInputs should be registered as an internal query",
);
assert.equal(
hasPattern(
querySource,
/handler\s*:\s*async\s*\(/,
),
true,
"getPageSpeedAuditInputs source block should include an async handler",
);
assert.equal(
hasPattern(
auditInputsSource,
/ctx\.db[\s\S]*?\.query\([\s\S]*?["']pageSpeedResults["']\s*\)/,
),
true,
"auditInputs should read from pageSpeedResults table",
);
assert.equal(
hasPattern(
auditInputsSource,
/withIndex\(["']by_auditId["'][\s\S]*?eq\([\s\S]*?auditId[\s\S]*?\)/,
),
true,
"auditInputs should support audit-scoped PageSpeed results",
);
assert.equal(
hasPattern(
auditInputsSource,
/withIndex\(["']by_leadId["'][\s\S]*?eq\([\s\S]*?leadId[\s\S]*?\)/,
),
true,
"auditInputs should support lead-scoped PageSpeed results",
);
});
test("auditInputs returns only plain-language prompt fields", () => {
const querySource = extractExportSource("getPageSpeedAuditInputs");
assert.equal(
hasPattern(
querySource,
/technicalSignals\s*:\s*string\[\][\s\S]*customerImplications\s*:\s*string\[\][\s\S]*internalNotes\s*:\s*string\[\]/,
),
true,
"Return type should expose only technicalSignals, customerImplications, and internalNotes",
);
const returnConstruction = querySource.match(
/buildPageSpeedAuditInputs\([\s\S]*?\);/,
);
assert.notEqual(
returnConstruction,
null,
"auditInputs should return buildPageSpeedAuditInputs output",
);
assert.equal(
/rawStorageId/.test(returnConstruction?.[0] ?? ""),
false,
"Returned fields must not include rawStorageId",
);
assert.equal(
/\bscores\b/.test(returnConstruction?.[0] ?? ""),
false,
"Returned fields must not include scores",
);
});

View File

@@ -0,0 +1,301 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
assertNoPublicPageSpeedScores,
buildPageSpeedAuditInputs,
type PageSpeedMinimalAuditResult,
} from "../lib/pagespeed-audit-input";
const MOBILE_AND_DESKTOP_FIXTURES: PageSpeedMinimalAuditResult[] = [
{
strategy: "mobile",
status: "succeeded",
sourceUrl: "https://example.com",
normalized: {
metrics: {
firstContentfulPaintMs: 3200,
largestContentfulPaintMs: 5000,
cumulativeLayoutShift: 0.2,
},
implications: [
"Score 0.42: Der erste sichtbare Inhalt erscheint zu langsam.",
"Die Seite zeigt das Hauptbild zu langsam.",
"Die Inhalte verschieben sich beim Laden.",
],
opportunities: [
"Nicht verwendetes CSS kann entfernt werden.",
"Bilder ohne passende Komprimierung koennen verzichtet werden.",
],
},
},
{
strategy: "desktop",
status: "succeeded",
sourceUrl: "https://example.com",
normalized: {
metrics: {
firstContentfulPaintMs: 1200,
largestContentfulPaintMs: 2200,
cumulativeLayoutShift: 0.04,
},
implications: [
"Die Seite zeigt das Hauptbild zu langsam.",
"Inhalte werden beim Laden sauber angezeigt.",
],
opportunities: ["Serverantworten sind stabil.", "Inhalte werden gestaffelt geladen."],
},
},
];
test("buildPageSpeedAuditInputs converts normalized implications into German customer impact statements", () => {
const actual = buildPageSpeedAuditInputs(MOBILE_AND_DESKTOP_FIXTURES);
assert.equal(actual.customerImplications.length > 0, true);
assert.equal(
actual.customerImplications.includes(
"Die Seite zeigt das Hauptbild zu langsam.",
),
true,
);
assert.equal(
actual.customerImplications.includes("Die Inhalte verschieben sich beim Laden."),
true,
);
assert.equal(
actual.customerImplications.some((line) => /mobile/i.test(line)),
true,
"Customer implications should include a mobile-centric statement.",
);
assert.equal(
assertNoPublicPageSpeedScores(actual.customerImplications),
true,
"Customer implications must not contain score-like values.",
);
assert.equal(
assertNoPublicPageSpeedScores(actual.technicalSignals),
true,
"Technical signals must not contain score-like values.",
);
});
test("buildPageSpeedAuditInputs detects meaningful mobile performance gaps versus desktop", () => {
const actual = buildPageSpeedAuditInputs(MOBILE_AND_DESKTOP_FIXTURES);
assert.equal(
actual.customerImplications.some((line) =>
/mobile/i.test(line) &&
/deutlich|spurbar|signifikant|langsamer/.test(line) &&
/desktop/i.test(line),
),
true,
);
});
test("buildPageSpeedAuditInputs keeps quota/api/unavailable failures in internal notes only", () => {
const actual = buildPageSpeedAuditInputs([
{
strategy: "mobile",
status: "failed",
sourceUrl: "https://bad.example",
errorType: "quota",
errorSummary: "API quota has been exceeded for this host.",
},
{
strategy: "desktop",
status: "failed",
sourceUrl: "https://bad2.example",
errorType: "unavailable",
errorSummary: "Page not reachable at the moment.",
},
{
strategy: "mobile",
status: "failed",
sourceUrl: "https://bad3.example",
errorType: "api_error",
errorSummary: "Lighthouse processing failed due to API timeout.",
},
{
strategy: "desktop",
status: "succeeded",
sourceUrl: "https://example.com",
normalized: {
implications: ["Die wichtigste Information wird zu langsam sichtbar."],
},
},
]);
assert.equal(
actual.customerImplications.some((line) => /quota|unavailable|timeout|api/i.test(line)),
false,
);
assert.equal(
actual.technicalSignals.some((line) => /quota|unavailable|timeout|api/i.test(line)),
false,
);
assert.equal(actual.internalNotes.length >= 3, true);
assert.equal(actual.internalNotes.some((line) => /quota/i.test(line)), true);
assert.equal(actual.internalNotes.some((line) => /not reachable|unreachable|erreich|timeout/i.test(line)), true);
assert.equal(actual.internalNotes.some((line) => /api/i.test(line)), true);
});
test("buildPageSpeedAuditInputs strips score-like and raw strings from public outputs", () => {
const actual = buildPageSpeedAuditInputs([
{
strategy: "mobile",
status: "succeeded",
sourceUrl: "https://example.com",
normalized: {
implications: [
"Score 0.42: FCP is high.",
"rawStorageId: file_123",
"Lighthouse category performance is present.",
"Die Seite laedt in 3.2 Sekunden.",
],
opportunities: [
"Ein { \"score\": 0.91 } kann optimiert werden.",
"redundante CSS Dateien.",
],
},
},
]);
assert.equal(assertNoPublicPageSpeedScores(actual.customerImplications), true);
assert.equal(assertNoPublicPageSpeedScores(actual.technicalSignals), true);
assert.equal(
actual.customerImplications.every((line) => !/\d/.test(line)),
true,
);
});
test("buildPageSpeedAuditInputs strips URLs, markup, JSON-like payloads, and machine-like words from public outputs", () => {
const actual = buildPageSpeedAuditInputs([
{
strategy: "mobile",
status: "succeeded",
sourceUrl: "https://example.com",
normalized: {
implications: [
"Weitere Infos findest du in https://example.com/details",
"Das <strong>Element</strong> lädt stabil.",
"{ \"pagespeed\": 0.84, \"lighthouseResult\": {} }",
"[\"rawStorageId\":\"id-0123456789abcdef0123456789\"]",
"rawStorageId: run_2026_0001",
"lighthouseResult suggests a bad candidate.",
"Die Seite laedt insgesamt spuertbar langsam.",
],
opportunities: [
"Moeglichkeit: <img src=\"x\" />",
"Pagespeed Score should not appear.",
"[{\"audit\":\"speed\"}]",
"Reduziere ungenutzte JavaScript-Dateien.",
"A longMachineToken_0123456789abcdef0123456789 to test filtering.",
],
},
},
]);
assert.equal(assertNoPublicPageSpeedScores(actual.customerImplications), true);
assert.equal(assertNoPublicPageSpeedScores(actual.technicalSignals), true);
assert.equal(actual.customerImplications.includes("Die Seite laedt insgesamt spuertbar langsam."), true);
assert.equal(
actual.technicalSignals.some((line) => /unused|reduziere|javascript/i.test(line)),
true,
);
assert.equal(
actual.customerImplications.every((line) => !/\bhttps?:\/\/|rawstorageid|lighthouseresult|pagespeed|score|<|>|\\{|\\}|\\[|\\]/i.test(line)),
true,
);
assert.equal(
actual.technicalSignals.every((line) => !/\bhttps?:\/\/|rawstorageid|lighthouseresult|pagespeed|score|<|>|\\{|\\}|\\[|\\]/i.test(line)),
true,
);
});
test("buildPageSpeedAuditInputs keeps failure categories in internal notes while removing URLs and JSON fragments", () => {
const actual = buildPageSpeedAuditInputs([
{
strategy: "mobile",
status: "failed",
sourceUrl: "https://example.com/audit?x=1",
errorType: "api_error",
errorSummary:
"PageSpeed API failed: { \"lighthouseResult\": {\"code\":\"timeout\"}, \"rawStorageId\": \"abc123\" }",
},
{
strategy: "desktop",
status: "succeeded",
sourceUrl: "https://example.com",
normalized: {
implications: [
"Die Seite laedt spuerbar schneller auf Desktop.",
],
},
},
]);
assert.equal(actual.internalNotes.length >= 1, true);
assert.equal(
actual.internalNotes.every(
(line) =>
!/https?:\/\//i.test(line) &&
!/\{|\}|\[|\]/i.test(line) &&
!/rawstorageid|lighthouseresult/i.test(line),
),
true,
);
assert.equal(
actual.internalNotes.some((line) => /api|technisch/i.test(line)),
true,
);
});
test("buildPageSpeedAuditInputs deduplicates and caps output lists", () => {
const manyImplications = Array.from({ length: 12 }, (_, index) => [
"Die Seite ist zu langsam.",
"Die Seite ist zu langsam.",
`Implication ${index}`,
"Wichtige Inhalte sind nicht sofort sichtbar.",
"Wichtige Inhalte sind nicht sofort sichtbar.",
]).flat();
const manyOpportunities = Array.from({ length: 12 }, (_, index) => [
"Komprimieren Sie Bilder.",
`Opportunity ${index}`,
"Komprimieren Sie Bilder.",
"Inhalte werden nachgeladen.",
]).flat();
const actual = buildPageSpeedAuditInputs([
{
strategy: "mobile",
status: "succeeded",
sourceUrl: "https://example.com",
normalized: {
implications: manyImplications,
opportunities: manyOpportunities,
},
},
...Array.from({ length: 10 }, (_, index) => ({
strategy: "desktop" as const,
status: "failed" as const,
sourceUrl: `https://example.com/${index}`,
errorType: "api_error" as const,
errorSummary: `Run ${String.fromCharCode(97 + (index % 26))} had internal problem.`,
})),
]);
assert.equal(actual.customerImplications.length <= 8, true);
assert.equal(actual.technicalSignals.length <= 8, true);
assert.equal(actual.customerImplications.length > 0, true);
assert.equal(actual.technicalSignals.length > 0, true);
assert.equal(actual.internalNotes.length, 6);
assert.equal(
new Set(actual.customerImplications).size,
actual.customerImplications.length,
);
assert.equal(
new Set(actual.technicalSignals).size,
actual.technicalSignals.length,
);
});

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

View File

@@ -0,0 +1,242 @@
import assert from "node:assert/strict";
import { existsSync, readFileSync } from "node:fs";
import path from "node:path";
import test from "node:test";
import ts from "typescript";
const pageSpeedPath = path.join(process.cwd(), "convex", "pageSpeed.ts");
const pageSpeedSource = existsSync(pageSpeedPath)
? readFileSync(pageSpeedPath, "utf8")
: "";
const sourceFile = ts.createSourceFile(
"pageSpeed.ts",
pageSpeedSource,
ts.ScriptTarget.ES2022,
true,
ts.ScriptKind.TS,
);
function getExportedConstNames(file: ts.SourceFile) {
const names = new Set<string>();
const visit = (node: ts.Node) => {
if (ts.isVariableStatement(node)) {
const isExported = node.modifiers?.some(
(mod) => mod.kind === ts.SyntaxKind.ExportKeyword,
);
if (!isExported) {
ts.forEachChild(node, visit);
return;
}
const isConst = node.declarationList.flags & ts.NodeFlags.Const;
if (!isConst) {
ts.forEachChild(node, visit);
return;
}
for (const declaration of node.declarationList.declarations) {
if (ts.isIdentifier(declaration.name)) {
names.add(declaration.name.text);
}
}
}
ts.forEachChild(node, visit);
};
ts.forEachChild(file, visit);
return names;
}
function hasPattern(source: string, pattern: RegExp) {
return pattern.test(source);
}
function extractExportSource(name: string) {
const marker = `export const ${name} = `;
const declarationIndex = pageSpeedSource.indexOf(marker);
assert.notEqual(declarationIndex, -1, `Expected declaration for ${name}`);
const openBraceIndex = pageSpeedSource.indexOf("{", declarationIndex);
let depth = 0;
let end = -1;
for (let index = openBraceIndex; index < pageSpeedSource.length; index += 1) {
const char = pageSpeedSource[index];
if (char === "{") {
depth += 1;
} else if (char === "}") {
depth -= 1;
if (depth === 0) {
end = index;
break;
}
}
}
assert.notEqual(end, -1, `Expected balanced braces for ${name}`);
return pageSpeedSource.slice(openBraceIndex, end + 1);
}
test("pageSpeed module exports mutation contracts", () => {
assert.equal(existsSync(pageSpeedPath), true, "pageSpeed.ts should be present");
const exports = getExportedConstNames(sourceFile);
const required = [
"queueLeadPageSpeedAudit",
"startPageSpeedAuditRun",
"persistPageSpeedResult",
"finishPageSpeedAuditRun",
];
for (const exportName of required) {
assert.equal(exports.has(exportName), true, `Expected export: ${exportName}`);
}
});
test("pageSpeed module uses internalMutation for queue/start/persist/finish", () => {
for (const name of [
"queueLeadPageSpeedAudit",
"startPageSpeedAuditRun",
"persistPageSpeedResult",
"finishPageSpeedAuditRun",
]) {
assert.equal(
hasPattern(pageSpeedSource, new RegExp(`export const ${name} = internalMutation\\s*\\(`)),
true,
`${name} should be registered as internalMutation.`,
);
}
});
test("queueLeadPageSpeedAudit dedupes per lead and schedules pagespeed action", () => {
const queueSource = extractExportSource("queueLeadPageSpeedAudit");
assert.equal(
hasPattern(
queueSource,
/withIndex\(\s*"by_type_and_status_and_leadId"[\s\S]*?eq\("type",\s*"audit"\)[\s\S]*?eq\("status",\s*"pending"\)[\s\S]*?eq\("leadId",\s*args\.leadId\)/,
),
true,
"Queue should dedupe pending audit runs by type+status+leadId.",
);
assert.equal(
hasPattern(
queueSource,
/withIndex\(\s*"by_type_and_status_and_leadId"[\s\S]*?eq\("type",\s*"audit"\)[\s\S]*?eq\("status",\s*"running"\)[\s\S]*?eq\("leadId",\s*args\.leadId\)/,
),
true,
"Queue should dedupe running audit runs by type+status+leadId.",
);
assert.equal(
hasPattern(
queueSource,
/currentStep:\s*["']pagespeed_insights["']/,
),
true,
"Queued page speed runs should use currentStep pagespeed_insights.",
);
assert.equal(
hasPattern(
queueSource,
/ctx\.scheduler\.runAfter\(\s*0,\s*internal\.pageSpeedAction\.processPageSpeedAudit,\s*\{[\s\S]*?runId/,
),
true,
"queueLeadPageSpeedAudit must schedule internal.pageSpeedAction.processPageSpeedAudit with runAfter(0, ...).",
);
assert.equal(
hasPattern(
queueSource,
/PageSpeed-Analyse wurde in die Warteschlange gesetzt\./,
),
true,
"queueLeadPageSpeedAudit should emit queue-start event message.",
);
});
test("startPageSpeedAuditRun marks run as running and handles clear failures", () => {
const startSource = extractExportSource("startPageSpeedAuditRun");
assert.equal(
hasPattern(
startSource,
/run\.type\s*!==?\s*["']audit["']/,
),
true,
"start function should require audit run type.",
);
assert.equal(
hasPattern(startSource, /run\.status\s*!==?\s*["']pending["']/),
true,
"start function should require pending status.",
);
assert.equal(
hasPattern(
startSource,
/ctx\.db\.patch\(\s*args\.runId,\s*\{[\s\S]*status:\s*["']running["']/,
),
true,
"start function should set status running.",
);
assert.equal(
hasPattern(startSource, /currentStep:\s*["']pagespeed_insights["']/),
true,
"start function should set currentStep pagespeed_insights.",
);
assert.equal(
hasPattern(
startSource,
/!run\.leadId[\s\S]*status:\s*["']failed["']/,
),
true,
"start should fail and record missing leadId.",
);
assert.equal(
hasPattern(
startSource,
/!lead\.websiteUrl[\s\S]*status:\s*["']failed["']/,
),
true,
"start should fail and record missing website URL.",
);
assert.equal(
hasPattern(
startSource,
/message:\s*["'][^"']*konnte nicht gestartet werden[^"']*["']/i,
),
true,
"start should add clear failure events.",
);
});
test("persistPageSpeedResult writes pageSpeedResults table", () => {
const persistSource = pageSpeedSource
? extractExportSource("persistPageSpeedResult")
: "export const persistPageSpeedResult = {}";
assert.equal(
hasPattern(persistSource, /ctx\.db\.insert\(\s*["']pageSpeedResults["']/),
true,
"persistPageSpeedResult should insert into pageSpeedResults.",
);
});
test("finishPageSpeedAuditRun writes completion status and finishedAt", () => {
const finishSource = extractExportSource("finishPageSpeedAuditRun");
assert.equal(
hasPattern(finishSource, /ctx\.db\.patch\(\s*args\.runId,[\s\S]*?finishedAt:\s*now/),
true,
"finish function should set finishedAt.",
);
assert.equal(
hasPattern(
finishSource,
/counters:\s*\{\s*[\s\S]*?errors:\s*args\.errors\s*\?\?/,
),
true,
"finish function should update counters.",
);
assert.equal(
hasPattern(finishSource, /currentStep:\s*["']pagespeed_insights["']/),
true,
"finish function should set currentStep pagespeed_insights.",
);
});

View File

@@ -0,0 +1,226 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import test from "node:test";
const schemaSource = readFileSync(
join(process.cwd(), "convex", "schema.ts"),
"utf8",
);
type ExactSetEquality<A, B> = [
Exclude<A, B>,
] extends [never]
? [Exclude<B, A>] extends [never]
? true
: false
: false;
type AssertPageSpeedStrategy = "mobile" | "desktop";
type AssertPageSpeedResultStatus = "succeeded" | "failed";
type AssertPageSpeedErrorType =
| "quota"
| "timeout"
| "unavailable"
| "invalid_url"
| "api_error"
| "unknown";
type PageSpeedStrategyParity = ExactSetEquality<
AssertPageSpeedStrategy,
("mobile" | "desktop")
>;
type PageSpeedResultStatusParity = ExactSetEquality<
AssertPageSpeedResultStatus,
"succeeded" | "failed"
>;
type PageSpeedErrorTypeParity = ExactSetEquality<
AssertPageSpeedErrorType,
"quota" | "timeout" | "unavailable" | "invalid_url" | "api_error" | "unknown"
>;
const _assertPageSpeedStrategyParity: PageSpeedStrategyParity = true;
const _assertPageSpeedResultStatusParity: PageSpeedResultStatusParity = true;
const _assertPageSpeedErrorTypeParity: PageSpeedErrorTypeParity = true;
function extractTableSection(tableName: string) {
const marker = `${tableName}: defineTable({`;
const markerIndex = schemaSource.indexOf(marker);
assert.notEqual(
markerIndex,
-1,
`Expected schema table definition for ${tableName}.`,
);
const objectStart = schemaSource.indexOf("{", markerIndex);
let depth = 0;
let objectEnd = -1;
for (let i = objectStart; i < schemaSource.length; i += 1) {
if (schemaSource[i] === "{") {
depth += 1;
} else if (schemaSource[i] === "}") {
depth -= 1;
if (depth === 0) {
objectEnd = i;
break;
}
}
}
assert.notEqual(objectEnd, -1, `Could not parse schema object for ${tableName}.`);
const remainder = schemaSource.slice(objectEnd + 1);
const nextTableMatch = remainder.match(/^\s*[a-zA-Z_][\w]*:\s*defineTable\(/m);
const sectionEnd =
nextTableMatch === null ? schemaSource.length : objectEnd + 1 + nextTableMatch.index!;
const section = schemaSource.slice(markerIndex, sectionEnd);
const objectBlock = schemaSource.slice(markerIndex, objectEnd + 1);
return { section, objectBlock };
}
function assertHas(pattern: RegExp, source: string, message: string) {
assert.equal(pattern.test(source), true, message);
}
test("PageSpeed validator unions are declared", () => {
assert.equal(_assertPageSpeedStrategyParity, true);
assert.equal(_assertPageSpeedResultStatusParity, true);
assert.equal(_assertPageSpeedErrorTypeParity, true);
assertHas(
/const\s+pageSpeedStrategy\s*=\s*v\.union\(\s*[\s\S]*v\.literal\(\s*["']mobile["']\s*\)\s*,\s*[\s\S]*v\.literal\(\s*["']desktop["']\s*\)[\s\S]*\)/,
schemaSource,
"Schema should define pageSpeedStrategy union with mobile and desktop.",
);
assertHas(
/const\s+pageSpeedResultStatus\s*=\s*v\.union\(\s*[\s\S]*v\.literal\(\s*["']succeeded["']\s*\)\s*,\s*[\s\S]*v\.literal\(\s*["']failed["']\s*\)[\s\S]*\)/,
schemaSource,
"Schema should define pageSpeedResultStatus union with succeeded and failed.",
);
assertHas(
/const\s+pageSpeedErrorType\s*=\s*v\.union\(\s*[\s\S]*v\.literal\(\s*["']quota["']\s*\)\s*,\s*[\s\S]*v\.literal\(\s*["']timeout["']\s*\)\s*,\s*[\s\S]*v\.literal\(\s*["']unavailable["']\s*\)\s*,\s*[\s\S]*v\.literal\(\s*["']invalid_url["']\s*\)\s*,\s*[\s\S]*v\.literal\(\s*["']api_error["']\s*\)\s*,\s*[\s\S]*v\.literal\(\s*["']unknown["']\s*\)[\s\S]*\)/,
schemaSource,
"Schema should define pageSpeedErrorType union with all declared values.",
);
});
test("pageSpeedResults table has contract fields and indexes", () => {
const { section, objectBlock } = extractTableSection("pageSpeedResults");
assertHas(
/leadId:\s*v\.id\(["']leads["']\)/,
objectBlock,
"pageSpeedResults.leadId should be required lead id.",
);
assertHas(
/auditId:\s*v\.optional\(\s*v\.id\(["']audits["']\)\s*\)/,
objectBlock,
"pageSpeedResults.auditId should be optional audit id.",
);
assertHas(
/runId:\s*v\.optional\(\s*v\.id\(["']agentRuns["']\)\s*\)/,
objectBlock,
"pageSpeedResults.runId should be optional run id.",
);
assertHas(
/strategy:\s*pageSpeedStrategy/,
objectBlock,
"pageSpeedResults.strategy should use pageSpeedStrategy validator.",
);
assertHas(
/status:\s*pageSpeedResultStatus/,
objectBlock,
"pageSpeedResults.status should use pageSpeedResultStatus validator.",
);
assertHas(
/sourceUrl:\s*v\.string\(\)/,
objectBlock,
"pageSpeedResults.sourceUrl should be required.",
);
assertHas(
/finalUrl:\s*v\.optional\(\s*v\.string\(\)\s*\)/,
objectBlock,
"pageSpeedResults.finalUrl should be optional string.",
);
assertHas(
/rawStorageId:\s*v\.optional\(\s*v\.id\(["']_storage["']\)\s*\)/,
objectBlock,
"pageSpeedResults.rawStorageId should be optional storage id.",
);
assertHas(
/errorType:\s*v\.optional\(\s*pageSpeedErrorType\s*\)/,
objectBlock,
"pageSpeedResults.errorType should be optional error type.",
);
assertHas(
/errorSummary:\s*v\.optional\(\s*v\.string\(\)\s*\)/,
objectBlock,
"pageSpeedResults.errorSummary should be optional.",
);
assertHas(
/fetchedAt:\s*v\.number\(\)/,
objectBlock,
"pageSpeedResults.fetchedAt should be required.",
);
assertHas(
/createdAt:\s*v\.number\(\)/,
objectBlock,
"pageSpeedResults.createdAt should be required.",
);
assertHas(
/scores:\s*v\.optional\(\s*v\.object\([\s\S]*?performance:\s*v\.optional\(v\.number\(\)\)[\s\S]*?accessibility:\s*v\.optional\(v\.number\(\)\)[\s\S]*?bestPractices:\s*v\.optional\(v\.number\(\)\)[\s\S]*?seo:\s*v\.optional\(v\.number\(\)\)[\s\S]*?\)\s*\)/,
objectBlock,
"pageSpeedResults.normalized.scores should include expected keys.",
);
assertHas(
/metrics:\s*v\.optional\(\s*v\.object\([\s\S]*?firstContentfulPaintMs:\s*v\.optional\(v\.number\(\)\)[\s\S]*?largestContentfulPaintMs:\s*v\.optional\(v\.number\(\)\)[\s\S]*?cumulativeLayoutShift:\s*v\.optional\(v\.number\(\)\)[\s\S]*?totalBlockingTimeMs:\s*v\.optional\(v\.number\(\)\)[\s\S]*?speedIndexMs:\s*v\.optional\(v\.number\(\)\)[\s\S]*?\)\s*\)/,
objectBlock,
"pageSpeedResults.normalized.metrics should include expected keys.",
);
assertHas(
/opportunities:\s*v\.optional\(\s*v\.array\(v\.string\(\)\)\s*\)/,
objectBlock,
"pageSpeedResults.normalized.opportunities should be optional string array.",
);
assertHas(
/implications:\s*v\.optional\(\s*v\.array\(v\.string\(\)\)\s*\)/,
objectBlock,
"pageSpeedResults.normalized.implications should be optional string array.",
);
assertHas(
/index\("by_leadId",\s*\["leadId"\]\)/,
section,
"pageSpeedResults should have by_leadId index.",
);
assertHas(
/index\("by_runId",\s*\["runId"\]\)/,
section,
"pageSpeedResults should have by_runId index.",
);
assertHas(
/index\("by_auditId",\s*\["auditId"\]\)/,
section,
"pageSpeedResults should have by_auditId index.",
);
assertHas(
/index\("by_leadId_and_strategy",\s*\["leadId",\s*"strategy"\]\)/,
section,
"pageSpeedResults should have by_leadId_and_strategy index.",
);
});
test("audits should not include public raw PageSpeed/Lighthouse JSON fields", () => {
const { objectBlock } = extractTableSection("audits");
const hasPublicRawJson = /raw.*pagespeed|pagespeed.*raw|raw.*lighthouse|lighthouse.*raw/i.test(
objectBlock,
);
assert.equal(
hasPublicRawJson,
false,
"audits should not expose raw PageSpeed/Lighthouse JSON fields.",
);
});

View File

@@ -70,6 +70,32 @@ function hasPattern(source: string, pattern: RegExp) {
return pattern.test(source);
}
function extractExportSource(source: string, name: string) {
const marker = `export const ${name} = `;
const declarationIndex = source.indexOf(marker);
assert.notEqual(declarationIndex, -1, `Expected declaration for ${name}`);
const openBraceIndex = source.indexOf("{", declarationIndex);
let depth = 0;
let end = -1;
for (let index = openBraceIndex; index < source.length; index += 1) {
const char = source[index];
if (char === "{") {
depth += 1;
} else if (char === "}") {
depth -= 1;
if (depth === 0) {
end = index;
break;
}
}
}
assert.notEqual(end, -1, `Expected balanced braces for ${name}`);
return source.slice(openBraceIndex, end + 1);
}
test("website enrichment mutation module exists and has runtime assertions", () => {
assert.equal(
existsSync(websiteEnrichmentPath),
@@ -531,3 +557,123 @@ test("website enrichment enforces TASK-8 crawler limits and runtime timeboxes",
"Default max crawl page count should be 5",
);
});
test("processLeadEnrichment schedules PageSpeed audit jobs after successful enrichment", () => {
const processBody = extractExportSource(actionSource, "processLeadEnrichment");
const persistIndex = processBody.indexOf(
"internal.websiteEnrichment.persistLeadEnrichmentResult",
);
const queueIndex = processBody.indexOf(
"internal.pageSpeed.queueLeadPageSpeedAudit",
persistIndex,
);
const finishIndex = processBody.indexOf(
"internal.websiteEnrichment.finishLeadEnrichmentRun",
persistIndex,
);
assert.notEqual(queueIndex, -1, "processLeadEnrichment should queue PageSpeed audits");
assert.notEqual(persistIndex, -1, "processLeadEnrichment should persist website enrichment result");
assert.notEqual(finishIndex, -1, "processLeadEnrichment should finish enrichment run");
assert.equal(
hasPattern(
processBody,
/runMutation\(\s*internal\.pageSpeed\.queueLeadPageSpeedAudit[\s\S]*leadId:\s*started\.lead\._id[\s\S]*parentRunId:\s*runId[\s\S]*\)/,
),
true,
"Queue call should pass lead ID and parent run ID",
);
assert.equal(queueIndex > persistIndex, true, "PageSpeed queueing should happen after persistence");
assert.equal(queueIndex < finishIndex, true, "PageSpeed queueing should happen before success finish");
});
test("processLeadEnrichment records warning on PageSpeed queue failure and continues", () => {
const processBody = extractExportSource(actionSource, "processLeadEnrichment");
assert.equal(
hasPattern(
processBody,
/try\s*\{[\s\S]*internal\.pageSpeed\.queueLeadPageSpeedAudit[\s\S]*\}\s*catch\s*\([^)]*\)\s*\{[\s\S]*api\.runs\.appendEvent[\s\S]*level:\s*"warning"/,
),
true,
"Queueing PageSpeed should be wrapped in warning-safe try/catch",
);
assert.equal(
hasPattern(
processBody,
/PageSpeed-Analyse konnte nicht in die Warteschlange gesetzt werden\./,
),
true,
"Warning event should describe queue failure",
);
});
test("processLeadEnrichment regression: queue PageSpeed on invalid URL failure when started lead exists", () => {
const processBody = extractExportSource(actionSource, "processLeadEnrichment");
const invalidUrlStart = processBody.indexOf("if (!rootUrl)");
assert.notEqual(invalidUrlStart, -1, "Invalid URL guard should exist");
const invalidUrlReturnNull = processBody.indexOf("return null;", invalidUrlStart);
assert.notEqual(
invalidUrlReturnNull,
-1,
"Invalid URL branch should return null",
);
const queueCallInInvalidUrl = processBody.indexOf(
"internal.pageSpeed.queueLeadPageSpeedAudit",
invalidUrlStart,
);
assert.equal(
queueCallInInvalidUrl > invalidUrlStart && queueCallInInvalidUrl < invalidUrlReturnNull,
true,
"Invalid URL failure path should queue PageSpeed before returning.",
);
const invalidUrlBranch = processBody.slice(invalidUrlStart, invalidUrlReturnNull);
assert.equal(
hasPattern(
invalidUrlBranch,
/leadId:\s*started\.lead\._id[\s\S]*?parentRunId:\s*runId/,
),
true,
"Invalid URL queue payload should use started.lead._id and parentRunId runId.",
);
});
test("processLeadEnrichment regression: queue PageSpeed in fatal catch path with started lead", () => {
const processBody = extractExportSource(actionSource, "processLeadEnrichment");
const outerCatchStart = processBody.lastIndexOf("catch (error)");
assert.notEqual(outerCatchStart, -1, "Outer catch block should exist");
const startedGuard = processBody.indexOf("if (started)", outerCatchStart);
assert.notEqual(startedGuard, -1, "Outer catch should guard lead patch by started check.");
const catchReturnNull = processBody.indexOf("return null;", outerCatchStart);
assert.notEqual(
catchReturnNull,
-1,
"Outer catch should return null on unrecoverable errors.",
);
const queueCallInCatch = processBody.indexOf(
"internal.pageSpeed.queueLeadPageSpeedAudit",
outerCatchStart,
);
assert.equal(
queueCallInCatch > outerCatchStart &&
queueCallInCatch > startedGuard &&
queueCallInCatch < catchReturnNull,
true,
"Fatal catch path should queue PageSpeed before returning, while started lead exists.",
);
const catchBlock = processBody.slice(outerCatchStart, catchReturnNull);
assert.equal(
hasPattern(
catchBlock,
/leadId:\s*started\.lead\._id[\s\S]*?parentRunId:\s*runId/,
),
true,
"Catch-path PageSpeed queue payload should use started.lead._id and parentRunId runId.",
);
});