import type { BodyRule, CheckFailure, CssRule, ExpectOperator, ExpectValue, JsonRule, XpathRule } from "../types"; import * as cheerio from "cheerio"; import * as xpath from "xpath"; import { DOMParser } from "@xmldom/xmldom"; import { mismatchFailure, errorFailure } from "./failure"; const isObject = (v: unknown): v is Record => v !== null && typeof v === "object" && !Array.isArray(v); export function evaluateJsonPath(json: unknown, path: string): unknown { if (!path.startsWith("$.")) return undefined; const segments = path.slice(2).split("."); let current: unknown = json; for (const seg of segments) { const bracketMatch = /^(.+?)\[(\d+)\]$/.exec(seg); if (bracketMatch) { current = (current as Record)?.[bracketMatch[1]!]; const idx = parseInt(bracketMatch[2]!, 10); if (!Array.isArray(current) || idx >= current.length) return undefined; current = current[idx]; } else { if (current === null || current === undefined) return undefined; current = (current as Record)[seg]; } } return current; } export function applyOperator(actual: unknown, op: ExpectOperator): boolean { for (const [key, expected] of Object.entries(op)) { if (expected === undefined) continue; switch (key) { case "equals": if (actual !== expected) return false; break; case "contains": if (!String(actual).includes(expected as string)) return false; break; case "match": if (!new RegExp(expected as string).test(String(actual))) return false; break; case "empty": { const isEmpty = actual === null || actual === undefined || actual === "" || (Array.isArray(actual) && actual.length === 0) || (typeof actual === "object" && Object.keys(actual as object).length === 0); if (expected !== isEmpty) return false; break; } case "exists": if (expected) { if (actual === undefined) return false; } else { if (actual !== undefined) return false; } break; case "gte": if (!(Number(actual) >= (expected as number))) return false; break; case "lte": if (!(Number(actual) <= (expected as number))) return false; break; case "gt": if (!(Number(actual) > (expected as number))) return false; break; case "lt": if (!(Number(actual) < (expected as number))) return false; break; } } return true; } export function checkExpectValue(actual: unknown, expected: ExpectValue): boolean { if (isObject(expected)) { return applyOperator(actual, expected as ExpectOperator); } return applyOperator(actual, { equals: expected as string | number | boolean | null }); } function checkJsonRule( body: string, rule: JsonRule, rulePath: string, ): { matched: boolean; failure: CheckFailure | null } { const { path, ...operators } = rule; const fullPath = `${rulePath}.json(${path})`; let json: unknown; try { json = JSON.parse(body); } catch { return { matched: false, failure: errorFailure("body", fullPath, "body is not valid JSON"), }; } const actual = evaluateJsonPath(json, path); const opKeys = Object.keys(operators); if (opKeys.length === 0) { if (actual === undefined) { return { matched: false, failure: mismatchFailure("body", fullPath, "defined", actual, `path ${path} is undefined`), }; } return { matched: true, failure: null }; } const matched = applyOperator(actual, operators); if (!matched) { return { matched: false, failure: mismatchFailure("body", fullPath, operators, actual, `json path ${path} mismatch`), }; } return { matched: true, failure: null }; } function checkCssRule( body: string, rule: CssRule, rulePath: string, ): { matched: boolean; failure: CheckFailure | null } { const { selector, attr, ...operators } = rule; const fullPath = `${rulePath}.css(${selector}${attr ? `@${attr}` : ""})`; let $: cheerio.CheerioAPI; try { $ = cheerio.load(body); } catch { return { matched: false, failure: errorFailure("body", fullPath, "failed to parse HTML"), }; } const el = $(selector); const opKeys = Object.keys(operators); if (opKeys.length === 0) { if (attr !== undefined) { if (el.attr(attr) === undefined) { return { matched: false, failure: mismatchFailure("body", fullPath, "attr present", undefined, `attribute ${attr} not found`), }; } return { matched: true, failure: null }; } if (el.length === 0) { return { matched: false, failure: mismatchFailure("body", fullPath, "element found", "no match", `selector ${selector} not found`), }; } return { matched: true, failure: null }; } if (operators.exists === true) { if (el.length === 0) { return { matched: false, failure: mismatchFailure("body", fullPath, true, false, `selector ${selector} not found`), }; } return { matched: true, failure: null }; } if (operators.exists === false) { if (el.length > 0) { return { matched: false, failure: mismatchFailure("body", fullPath, false, true, `selector ${selector} exists`), }; } return { matched: true, failure: null }; } if (el.length === 0) { return { matched: false, failure: mismatchFailure("body", fullPath, "element found", "no match", `selector ${selector} not found`), }; } const actual = attr ? el.attr(attr) : el.text(); const matched = applyOperator(actual ?? "", operators); if (!matched) { return { matched: false, failure: mismatchFailure("body", fullPath, operators, actual, `css selector ${selector} mismatch`), }; } return { matched: true, failure: null }; } function checkXpathRule( body: string, rule: XpathRule, rulePath: string, ): { matched: boolean; failure: CheckFailure | null } { const { path, ...operators } = rule; const fullPath = `${rulePath}.xpath(${path})`; let doc: ReturnType; try { doc = new DOMParser().parseFromString(body, "text/xml"); } catch { return { matched: false, failure: errorFailure("body", fullPath, "failed to parse XML/HTML"), }; } const nodes = xpath.select(path, doc as unknown as Node); if (!nodes || !Array.isArray(nodes) || nodes.length === 0) { return { matched: false, failure: mismatchFailure("body", fullPath, "node found", "no match", `xpath ${path} not found`), }; } const node = nodes[0]!; const actual = node.nodeValue ?? (node as unknown as Element).textContent ?? ""; const opKeys = Object.keys(operators); if (opKeys.length === 0) { return { matched: true, failure: null }; } const matched = applyOperator(actual, operators); if (!matched) { return { matched: false, failure: mismatchFailure("body", fullPath, operators, actual, `xpath ${path} mismatch`), }; } return { matched: true, failure: null }; } function checkSingleBodyRule( body: string, rule: BodyRule, index: number, ): { matched: boolean; failure: CheckFailure | null } { const rulePath = `body[${index}]`; if ("contains" in rule) { const matched = body.includes(rule.contains); if (!matched) { return { matched: false, failure: mismatchFailure("body", rulePath, rule.contains, body, `body does not contain "${rule.contains}"`), }; } return { matched: true, failure: null }; } if ("regex" in rule) { const matched = new RegExp(rule.regex).test(body); if (!matched) { return { matched: false, failure: mismatchFailure("body", rulePath, `/${rule.regex}/`, body, `body does not match /${rule.regex}/`), }; } return { matched: true, failure: null }; } if ("json" in rule) { return checkJsonRule(body, rule.json, rulePath); } if ("css" in rule) { return checkCssRule(body, rule.css, rulePath); } if ("xpath" in rule) { return checkXpathRule(body, rule.xpath, rulePath); } return { matched: true, failure: null }; } export function checkBodyExpect(body: string, rules?: BodyRule[]): { matched: boolean; failure: CheckFailure | null } { if (!rules || rules.length === 0) return { matched: true, failure: null }; for (let i = 0; i < rules.length; i++) { const result = checkSingleBodyRule(body, rules[i]!, i); if (!result.matched) return result; } return { matched: true, failure: null }; }