Integrate PageSpeed Insights audits
This commit is contained in:
365
tests/pagespeed-action-source.test.ts
Normal file
365
tests/pagespeed-action-source.test.ts
Normal 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.",
|
||||
);
|
||||
});
|
||||
191
tests/pagespeed-audit-input-integration.test.ts
Normal file
191
tests/pagespeed-audit-input-integration.test.ts
Normal 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",
|
||||
);
|
||||
});
|
||||
301
tests/pagespeed-audit-input.test.ts
Normal file
301
tests/pagespeed-audit-input.test.ts
Normal 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,
|
||||
);
|
||||
});
|
||||
343
tests/pagespeed-insights.test.ts
Normal file
343
tests/pagespeed-insights.test.ts
Normal 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/);
|
||||
});
|
||||
242
tests/pagespeed-persistence-source.test.ts
Normal file
242
tests/pagespeed-persistence-source.test.ts
Normal 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.",
|
||||
);
|
||||
});
|
||||
226
tests/pagespeed-schema.test.ts
Normal file
226
tests/pagespeed-schema.test.ts
Normal 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.",
|
||||
);
|
||||
});
|
||||
@@ -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.",
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user