401 lines
11 KiB
TypeScript
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.",
|
|
);
|
|
});
|