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.",
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user