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(); 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.", ); });