Files
webdev-pipeline/tests/pagespeed-action-source.test.ts
2026-06-05 14:14:07 +02:00

401 lines
11 KiB
TypeScript

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 queues audit generation after PageSpeed completion", () => {
assert.equal(
hasPattern(actionSource, /internal\.auditGeneration\.queueLeadAuditGeneration/),
true,
"Action should call internal.auditGeneration.queueLeadAuditGeneration after PageSpeed work.",
);
assert.equal(
hasPattern(
actionSource,
/queueAuditGenerationAfterPageSpeed\(\s*ctx,\s*args\.runId,\s*started\s*\)/,
),
true,
"Action should route PageSpeed completion through a shared audit-generation handoff helper.",
);
assert.equal(
hasPattern(
actionSource,
/queueAuditGenerationAfterPageSpeed[\s\S]*leadId:\s*started\.lead\._id[\s\S]*parentRunId:\s*runId/,
),
true,
"The handoff should queue audit generation for the same lead and parent PageSpeed run.",
);
assert.equal(
hasPattern(
actionSource,
/catch \(auditQueueError\)[\s\S]*Audit-Generierung konnte nicht in die Warteschlange gesetzt werden/,
),
true,
"Audit-generation queue failures should be logged without failing PageSpeed completion.",
);
});
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.",
);
});