"use strict";
var CONFIG = null;
var VARDEFS = [];
var ITEMS = []; // flattened test items across all groups
var GROUPS = []; // [{ name, label, source, keys: [item key, ...] }]
var RESULTS = {}; // item key -> { verdict, left_panel, right_panel, requests, values }
var OPEN = {}; // item key -> bool
var GROUP_OPEN = {}; // group index -> bool (default open)
var BUSY = false;
var ABORT = false;
var CONFIG_FILE = null; // currently selected config file name
var STORAGE_PREFIX = "left_panel-right_panel-diff-vars::";
var INTERVAL_PREFIX = "left_panel-right_panel-diff-interval::";
var $ = function (id) { return document.getElementById(id); };
function esc(s) {
return String(s == null ? "" : s)
.replace(/&/g, "&").replace(//g, ">")
.replace(/"/g, """);
}
function showBanner(html, warn) {
var el = $("banner");
el.className = "banner" + (warn ? " warn" : "");
el.innerHTML = html;
el.style.display = "";
}
// Fills in every static label from MESSAGES.page. Keeps index.html as bare
// markup/ids so all wording -- static or dynamic -- lives in messages.js.
function applyStaticText() {
document.title = MESSAGES.page.title;
$("pageTitle").textContent = MESSAGES.page.title;
$("subtitle").textContent = MESSAGES.page.subtitleDefault;
$("varsPanelTitle").textContent = MESSAGES.page.varsPanelTitle;
$("varsHelp").innerHTML = MESSAGES.page.varsHelp;
$("configFileLabel").textContent = MESSAGES.page.configFileLabel;
$("intervalLabel").textContent = MESSAGES.page.intervalLabel;
$("intervalUnit").textContent = MESSAGES.page.intervalUnit;
$("runAll").textContent = MESSAGES.page.runAllButton;
$("stop").textContent = MESSAGES.page.stopButton;
$("reload").textContent = MESSAGES.page.reloadButton;
$("colNo").textContent = MESSAGES.page.colNo;
$("colApi").textContent = MESSAGES.page.colApi;
$("colHost").textContent = MESSAGES.page.colHost;
$("colMethod").textContent = MESSAGES.page.colMethod;
$("colPath").textContent = MESSAGES.page.colPath;
$("colVerdict").textContent = MESSAGES.page.colVerdict;
}
/* ---------------------------------------------------------------- config */
function normalizeVarDefs(vars) {
var out = [];
if (!vars) return out;
Object.keys(vars).forEach(function (name) {
var v = vars[name];
if (v !== null && typeof v === "object") {
out.push({ name: name, label: v.label || name, def: v["default"] == null ? "" : String(v["default"]), secret: !!v.secret });
} else {
out.push({ name: name, label: name, def: v == null ? "" : String(v), secret: false });
}
});
return out;
}
// Variable values are stored per config file. Sharing one bucket would carry
// a dev token straight into a production config's input field.
function storageKey() { return STORAGE_PREFIX + (CONFIG_FILE || ""); }
function loadStoredVars() {
try { return JSON.parse(sessionStorage.getItem(storageKey()) || "{}"); } catch (e) { return {}; }
}
function storeVars(values) {
try { sessionStorage.setItem(storageKey(), JSON.stringify(values)); } catch (e) { /* private mode */ }
}
function currentVars() {
var values = {};
VARDEFS.forEach(function (d) {
var input = $("var-" + d.name);
values[d.name] = input ? input.value : d.def;
});
return values;
}
function renderVars() {
var stored = loadStoredVars();
$("vars").innerHTML = VARDEFS.map(function (d) {
var value = stored[d.name] != null ? stored[d.name] : d.def;
return '
' +
'' +
'' +
'
';
}).join("");
VARDEFS.forEach(function (d) {
var input = $("var-" + d.name);
if (input) input.addEventListener("input", function () { storeVars(currentVars()); });
});
}
/* ------------------------------------------------------- substitution */
var PLACEHOLDER = /\{\{\s*([A-Za-z0-9_]+)\s*\}\}/g;
function subst(text, values) {
return String(text).replace(PLACEHOLDER, function (whole, name) {
var v = values[name];
return (v === undefined || v === null || v === "") ? whole : v;
});
}
function hasUnresolved(text) {
PLACEHOLDER.lastIndex = 0;
return PLACEHOLDER.test(String(text));
}
function substDeep(value, values) {
if (typeof value === "string") return subst(value, values);
if (Array.isArray(value)) return value.map(function (v) { return substDeep(v, values); });
if (value && typeof value === "object") {
var out = {};
Object.keys(value).forEach(function (k) { out[k] = substDeep(value[k], values); });
return out;
}
return value;
}
function maskSecrets(text, values) {
var out = String(text);
VARDEFS.forEach(function (d) {
if (!d.secret) return;
var v = values[d.name];
if (v && v.length >= 4) out = out.split(v).join("••••" + v.slice(-4));
});
return out;
}
/* ---------------------------------------------------- request building */
// Some configs put an already-percent-encoded value in query (e.g. "%20",
// "a%28b%29") because that's what the upstream expects verbatim. A decode/
// re-encode round trip can't detect that reliably -- encodeURIComponent
// leaves ( ) ! ~ * ' unescaped, so re-encoding "%28" comes back as a literal
// "(" instead of "%28" and looks like a mismatch even though the value was
// already valid encoding. Instead, check the string is entirely made of
// unreserved characters and/or valid %XX escapes; if so it's already
// percent-encoded and is passed through untouched. Anything else (plain
// text, or a stray "%" not part of a real escape like "100%") is encoded
// normally.
var FULLY_PERCENT_ENCODED = /^(?:[A-Za-z0-9\-_.!~*'()]|%[0-9A-Fa-f]{2})*$/;
function encodeQueryPart(v) {
var s = String(v);
return FULLY_PERCENT_ENCODED.test(s) ? s : encodeURIComponent(s);
}
function buildQuery(query) {
if (!query) return "";
var parts = Object.keys(query).map(function (k) {
return encodeQueryPart(k) + "=" + encodeQueryPart(query[k]);
});
return parts.length ? "?" + parts.join("&") : "";
}
// Case-insensitive header lookup. HTTP header names are case-insensitive, and
// upstreams are inconsistent about casing, so every comparison goes through here.
function headerValueCI(headers, name) {
var lc = String(name).toLowerCase();
var found;
Object.keys(headers || {}).forEach(function (k) {
if (k.toLowerCase() === lc) found = headers[k];
});
return found; // undefined means absent, which is distinct from ""
}
// Single source of the merge order: target defaults, then per-API overrides,
// then per-API per-target overrides. buildRequest and the Host column both read
// it, so the precedence can never drift between them.
function mergedHeadersFor(api, targetKey, values) {
var target = CONFIG && CONFIG.targets && CONFIG.targets[targetKey];
if (!target) return {};
var headers = {};
[target.headers, api.headers, api.targetHeaders && api.targetHeaders[targetKey]].forEach(function (src) {
if (!src) return;
Object.keys(src).forEach(function (k) { headers[k] = src[k]; });
});
return values ? substDeep(headers, values) : headers;
}
// Unlike buildRequest this never throws, so the list can still render a Host
// before the variables have been filled in (it shows the raw {{VAR}} then).
function hostFor(api, targetKey, values) {
var v = headerValueCI(mergedHeadersFor(api, targetKey, values), "Host");
return v == null ? "" : String(v);
}
function buildRequest(api, targetKey, values) {
var target = CONFIG.targets[targetKey];
if (!target) throw new Error(MESSAGES.request.missingTarget(targetKey));
var headers = mergedHeadersFor(api, targetKey, null);
var path = (api.paths && api.paths[targetKey]) ? api.paths[targetKey] : api.path;
// query is substituted before buildQuery encodes it, for the same reason
// body is substituted before encoding below: encodeURIComponent would
// otherwise turn {{VAR}} into %7B%7B...%7D%7D and the placeholder would
// never match again.
var query = api.query ? substDeep(api.query, values) : api.query;
var spec = substDeep({
method: (api.method || "GET").toUpperCase(),
url: String(target.baseUrl || "").replace(/\/+$/, "") + (path || "") + buildQuery(query),
headers: headers,
body: api.body == null ? "" : api.body
}, values);
// Variables are substituted above while body is still an object, so a
// {{VAR}} inside a form field is resolved before encodeURIComponent ever
// sees it (otherwise the braces themselves would get percent-encoded).
var contentType = headerValueCI(spec.headers, "Content-Type") || "";
var body = spec.body;
if (body && typeof body === "object") {
body = /application\/x-www-form-urlencoded/i.test(contentType)
? Object.keys(body).map(function (k) {
return encodeURIComponent(k) + "=" + encodeURIComponent(body[k]);
}).join("&")
: JSON.stringify(body);
}
spec.body = body == null ? "" : String(body);
spec.timeoutMs = CONFIG.timeoutMs || 30000;
var unresolved = [];
if (hasUnresolved(spec.url)) unresolved.push("url");
Object.keys(spec.headers).forEach(function (k) { if (hasUnresolved(spec.headers[k])) unresolved.push("header " + k); });
if (hasUnresolved(spec.body)) unresolved.push("body");
if (unresolved.length) {
throw new Error(MESSAGES.request.unresolvedVars(unresolved.join(", ")));
}
// Empty header values are dropped so an unset optional token does not
// produce a bare "Authorization:" header.
Object.keys(spec.headers).forEach(function (k) {
if (spec.headers[k] === "" || spec.headers[k] == null) delete spec.headers[k];
});
return spec;
}
/* ------------------------------------------------------------- curl */
// Escaping targets PowerShell's double-quoted-string rules, since this tool
// only ever documents launching itself via powershell -- that is the shell a
// copied command is overwhelmingly likely to be pasted into. Backticks must be
// escaped first, or the backticks these rules insert would get re-escaped by
// the later replacements.
function psQuote(value) {
var s = String(value == null ? "" : value);
s = s.replace(/`/g, "``"); // literal backtick
s = s.replace(/\$/g, "`$"); // stop "$var" / "$(...)" interpolation
s = s.replace(/"/g, '`"'); // literal double quote
return '"' + s + '"';
}
function buildCurlCommand(spec) {
var parts = ["curl", "-sk", "-i", "-X", spec.method, psQuote(spec.url)];
Object.keys(spec.headers || {}).forEach(function (k) {
parts.push("-H", psQuote(k + ": " + spec.headers[k]));
});
if (spec.body) parts.push("--data-raw", psQuote(spec.body));
return parts.join(" ");
}
// One command per side: both gateways in dual mode, the single target in
// single mode. Uses the same buildRequest() an actual run would, so the
// copied command matches what "실행" would have sent, including real
// (unmasked) secret values -- a command with masked values would not run.
function curlCommandsFor(api, values) {
var sides = compareSides();
if (sides.mode === "single") {
return [{ label: sides.leftLabel, command: buildCurlCommand(buildRequest(api, sides.leftKey, values)) }];
}
return [
{ label: sides.leftLabel, command: buildCurlCommand(buildRequest(api, sides.leftKey, values)) },
{ label: sides.rightLabel, command: buildCurlCommand(buildRequest(api, sides.rightKey, values)) }
];
}
function copyTextToClipboard(text) {
if (navigator.clipboard && navigator.clipboard.writeText) {
return navigator.clipboard.writeText(text);
}
// Fallback for browsers without the async Clipboard API.
return new Promise(function (resolve, reject) {
var ta = document.createElement("textarea");
ta.value = text;
ta.style.position = "fixed";
ta.style.left = "-9999px";
document.body.appendChild(ta);
ta.focus();
ta.select();
var ok = false;
try { ok = document.execCommand("copy"); } catch (e) { ok = false; }
document.body.removeChild(ta);
if (ok) resolve(); else reject(new Error(MESSAGES.clipboard.copyFailed));
});
}
function flashButtonLabel(btn, text, revertTo) {
btn.textContent = text;
btn.disabled = true;
setTimeout(function () {
btn.textContent = revertTo;
btn.disabled = false;
}, 1200);
}
/* ------------------------------------------------------------ compare */
// Each pattern gets its own token. A shared token would let two different
// masked fields collapse into the same text and hide a real difference
// (e.g. ab vs ba).
function applyIgnore(text, patterns) {
var out = String(text == null ? "" : text);
(patterns || []).forEach(function (p, i) {
try { out = out.replace(new RegExp(p, "g"), ""); }
catch (e) { throw new Error(MESSAGES.ignorePattern.invalidRegex(i, p, e.message)); }
});
return out;
}
// Match counts per pattern. A pattern matching zero times on both sides is
// almost always a typo; different counts per side mean a structural gap.
// Named left/right rather than left_panel/right_panel because in single-target mode the right
// side is the expected value, not a gateway.
function patternStats(patterns, leftBody, rightBody) {
return (patterns || []).map(function (p, i) {
function count(text) {
var found = String(text == null ? "" : text).match(new RegExp(p, "g"));
return found ? found.length : 0;
}
return { index: i, pattern: p, leftCount: count(leftBody), rightCount: count(rightBody) };
});
}
/* ------------------------------------------------------- compare sides */
// The two targets. names dual mode compares. Defaulting to ["left_panel",
// "right_panel"] keeps every existing config working unchanged; a config for a
// different pair of systems sets "compareTargets": ["", ""]
// instead of being stuck with these two literal names.
function resolveDualTargetKeys(cfg) {
var pair = cfg && Array.isArray(cfg.compareTargets) ? cfg.compareTargets : null;
if (pair && pair.length === 2 && pair[0] && pair[1]) return [String(pair[0]), String(pair[1])];
return ["left_panel", "right_panel"];
}
// Which two things are being compared, and what to call them. Everything
// downstream reads this instead of assuming a fixed target-key pair.
function compareSides() {
if (CONFIG && CONFIG.compareMode === "single") {
var k = CONFIG.singleTarget;
var t = (CONFIG.targets && CONFIG.targets[k]) || null;
return {
mode: "single", leftKey: k, rightKey: null,
leftLabel: (t && t.label) || k || MESSAGES.compare.defaultTargetLabel, rightLabel: MESSAGES.compare.expectedLabel
};
}
var keys = resolveDualTargetKeys(CONFIG);
var lt = (CONFIG && CONFIG.targets && CONFIG.targets[keys[0]]) || null;
var rt = (CONFIG && CONFIG.targets && CONFIG.targets[keys[1]]) || null;
return {
mode: "dual", leftKey: keys[0], rightKey: keys[1],
leftLabel: (lt && lt.label) || keys[0], rightLabel: (rt && rt.label) || keys[1]
};
}
/* --------------------------------------------- response header verdict */
// Global list, plus per-API additions, minus per-API exclusions. Exclusion is
// applied last, so listing a header in both places excludes it.
function resolveCompareHeaders(api) {
var map = {}; // lowercased name -> original spelling, for display
function add(list) {
(list || []).forEach(function (h) { if (h) map[String(h).toLowerCase()] = String(h); });
}
add(CONFIG && CONFIG.compareHeaders);
add(api.compareHeaders);
(api.ignoreHeaders || []).forEach(function (h) { delete map[String(h).toLowerCase()]; });
return Object.keys(map).map(function (lc) { return map[lc]; });
}
// Returns null when nothing is configured, which keeps the verdict byte-identical
// to the pre-feature behaviour instead of adding an always-passing check.
function compareJudgedHeaders(api, left, right) {
var names = resolveCompareHeaders(api);
if (!names.length) return null;
return names.map(function (n) {
var lv = headerValueCI(left && left.headers, n);
var rv = headerValueCI(right && right.headers, n);
var lp = lv !== undefined, rp = rv !== undefined;
// Absent on both sides counts as equal; present on one side only does not.
// Values are compared verbatim: "text/xml" and "text/xml; charset=utf-8"
// are a mismatch, which is the point of the check.
return {
name: n, left: lv, right: rv, leftPresent: lp, rightPresent: rp,
equal: (!lp && !rp) ? true : (lp && rp && String(lv) === String(rv))
};
});
}
function headerMismatchNames(checks) {
return (checks || []).filter(function (c) { return !c.equal; }).map(function (c) { return c.name; });
}
function judge(api, left, right) {
var sides = compareSides();
var labels = { leftLabel: sides.leftLabel, rightLabel: sides.rightLabel };
if (left.error || right.error) {
return {
result: "ERROR",
reason: [left.error ? sides.leftLabel + ": " + left.error : null,
right.error ? sides.rightLabel + ": " + right.error : null].filter(Boolean).join(" / "),
leftLabel: labels.leftLabel, rightLabel: labels.rightLabel
};
}
// Header checks apply to both branches below: response headers have nothing
// to do with whether the body is text or binary.
var headerChecks = compareJudgedHeaders(api, left, right);
var headersOk = true;
var headerReason = null;
if (headerChecks) {
var bad = headerMismatchNames(headerChecks);
headersOk = bad.length === 0;
if (!headersOk) headerReason = MESSAGES.judge.headerMismatch(bad.join(", "));
}
// Binary responses (images and the like) are compared byte-for-byte through
// the server-side sha256. ignorePatterns are text-only and cannot apply.
if (left.isBinary || right.isBinary || left.bodyTooLarge || right.bodyTooLarge) {
var statusSame = left.status === right.status;
var why = [];
if (!statusSame) why.push(MESSAGES.judge.statusMismatch(left.status, right.status));
var out = {
binary: true, patterns: [], stats: [], headerChecks: headerChecks,
leftLabel: labels.leftLabel, rightLabel: labels.rightLabel
};
if (left.isBinary !== right.isBinary) {
why.push(MESSAGES.judge.formatMismatch(left.isBinary
? MESSAGES.judge.binaryVsText(sides.leftLabel, sides.rightLabel)
: MESSAGES.judge.textVsBinary(sides.leftLabel, sides.rightLabel)));
if (headerReason) why.push(headerReason);
out.result = "Fail";
out.reason = why.join(", ");
return out;
}
var sameBytes = left.bodySha256 === right.bodySha256;
if (!sameBytes) why.push(MESSAGES.judge.bodyMismatchBinary(left.bodyBytes, right.bodyBytes));
if (headerReason) why.push(headerReason);
out.result = (statusSame && sameBytes && headersOk) ? "OK" : "Fail";
if (why.length) out.reason = why.join(", ");
if ((api.ignorePatterns || []).length) out.patternsIgnored = true;
if (left.bodyTooLarge || right.bodyTooLarge) out.bodyCapped = true;
return out;
}
var patterns = api.ignorePatterns || [];
var a = applyIgnore(left.body, patterns);
var b = applyIgnore(right.body, patterns);
var stats = patternStats(patterns, left.body, right.body);
var statusEqual = left.status === right.status;
var bodyEqual = a === b;
var base = {
leftText: a, rightText: b, patterns: patterns, stats: stats,
headerChecks: headerChecks, leftLabel: labels.leftLabel, rightLabel: labels.rightLabel
};
if (statusEqual && bodyEqual && headersOk) { base.result = "OK"; return base; }
var reasons = [];
if (!statusEqual) reasons.push(MESSAGES.judge.statusMismatch(left.status, right.status));
if (!bodyEqual) reasons.push(MESSAGES.judge.bodyMismatch);
if (headerReason) reasons.push(headerReason);
base.result = "Fail";
base.reason = reasons.join(", ");
return base;
}
/* ------------------------------------------- single-target expectations */
// Adapts an api.expected block into something shaped enough like a proxy
// response that columnHtml and the diff renderer can consume it directly.
function expectedResponse(api) {
var ex = api.expected;
if (!ex) return { isExpected: true, missing: true };
if (ex.bodyFileError) return { isExpected: true, fileError: ex.bodyFileError };
var hasInline = ex.body != null;
var hasFile = ex.bodyText != null;
var body = hasInline ? String(ex.body) : (hasFile ? String(ex.bodyText) : null);
return {
isExpected: true,
status: (ex.status == null ? null : Number(ex.status)),
statusText: "",
headers: ex.headers || {},
body: body,
bodyBytes: body == null ? 0 : body.length,
bodySha256: "",
isBinary: false,
bodyTooLarge: false,
elapsedMs: 0,
error: null,
bothBodySpecified: hasInline && hasFile,
bodySource: hasInline ? "body" : (hasFile ? ex.bodyFile : null)
};
}
function judgeSingle(api, actual, expected) {
var sides = compareSides();
var labels = { leftLabel: sides.leftLabel, rightLabel: sides.rightLabel };
if (expected.missing) {
return { result: "ERROR", reason: MESSAGES.single.expectedMissing, leftLabel: labels.leftLabel, rightLabel: labels.rightLabel };
}
if (expected.fileError) {
return { result: "ERROR", reason: MESSAGES.single.expectedFileError(expected.fileError), leftLabel: labels.leftLabel, rightLabel: labels.rightLabel };
}
if (actual.error) {
return { result: "ERROR", reason: sides.leftLabel + ": " + actual.error, leftLabel: labels.leftLabel, rightLabel: labels.rightLabel };
}
var hasStatus = expected.status != null && !isNaN(expected.status);
var expHeaderNames = Object.keys(expected.headers || {});
var hasBody = expected.body != null;
if (!hasStatus && !expHeaderNames.length && !hasBody) {
return {
result: "ERROR",
reason: MESSAGES.single.expectedNothingToCompare,
leftLabel: labels.leftLabel, rightLabel: labels.rightLabel
};
}
var reasons = [];
if (hasStatus && actual.status !== expected.status) {
reasons.push(MESSAGES.single.statusMismatch(actual.status, expected.status));
}
// Only expected.headers is used here. The global compareHeaders list compares
// two live responses; with no second response there is nothing for it to mean.
var headerChecks = null;
if (expHeaderNames.length) {
headerChecks = expHeaderNames.map(function (n) {
var av = headerValueCI(actual.headers, n);
var ev = String(expected.headers[n]);
return {
name: n, left: av, right: ev,
leftPresent: av !== undefined, rightPresent: true,
equal: av !== undefined && String(av) === ev
};
});
var badHdr = headerMismatchNames(headerChecks);
badHdr.forEach(function (n) {
var av = headerValueCI(actual.headers, n);
reasons.push(MESSAGES.single.headerMismatch(n, av === undefined ? MESSAGES.single.headerAbsent : av, expected.headers[n]));
});
}
var patterns = api.ignorePatterns || [];
var base = {
patterns: patterns, stats: [], headerChecks: headerChecks,
leftLabel: labels.leftLabel, rightLabel: labels.rightLabel, single: true
};
if (hasBody) {
if (actual.isBinary || actual.bodyTooLarge) {
reasons.push(MESSAGES.single.binaryNotComparable);
base.patternsIgnored = patterns.length > 0;
} else {
var a = applyIgnore(actual.body, patterns);
var b = applyIgnore(expected.body, patterns);
base.leftText = a;
base.rightText = b;
base.stats = patternStats(patterns, actual.body, expected.body);
if (a !== b) reasons.push(MESSAGES.judge.bodyMismatch);
}
}
base.result = reasons.length ? "Fail" : "OK";
if (reasons.length) base.reason = reasons.join(", ");
return base;
}
var DIFF_LINE_LIMIT = 1200;
function diffLines(aText, bText) {
var A = String(aText).split(/\r\n|\r|\n/);
var B = String(bText).split(/\r\n|\r|\n/);
if (A.length > DIFF_LINE_LIMIT || B.length > DIFF_LINE_LIMIT) return null;
var n = A.length, m = B.length, w = m + 1;
var dp = new Int32Array((n + 1) * w);
for (var i = n - 1; i >= 0; i--) {
for (var j = m - 1; j >= 0; j--) {
dp[i * w + j] = (A[i] === B[j])
? dp[(i + 1) * w + (j + 1)] + 1
: Math.max(dp[(i + 1) * w + j], dp[i * w + (j + 1)]);
}
}
var ops = [], x = 0, y = 0;
while (x < n && y < m) {
if (A[x] === B[y]) { ops.push({ t: " ", a: x + 1, b: y + 1, text: A[x] }); x++; y++; }
else if (dp[(x + 1) * w + y] >= dp[x * w + (y + 1)]) { ops.push({ t: "-", a: x + 1, text: A[x] }); x++; }
else { ops.push({ t: "+", b: y + 1, text: B[y] }); y++; }
}
while (x < n) { ops.push({ t: "-", a: x + 1, text: A[x] }); x++; }
while (y < m) { ops.push({ t: "+", b: y + 1, text: B[y] }); y++; }
return ops;
}
function firstDifference(a, b) {
var len = Math.min(a.length, b.length);
for (var i = 0; i < len; i++) if (a[i] !== b[i]) return i;
return len;
}
/* ----------------------------------------------------------- rendering */
function renderSummary() {
var allKeys = ITEMS.map(function (it) { return it.key; });
$("summary").innerHTML =
'' + MESSAGES.summary.groupsAndApis(GROUPS.length, ITEMS.length) + "" +
chipsHtml(countKeys(allKeys));
}
// judgedMap: { lowercasedName: { equal: bool } } for headers that carry verdict
// weight. Those rows are marked so a reader can tell a header that merely
// differs from one that actually caused a Fail.
function headerTable(leftHeaders, rightHeaders, side, judgedMap) {
var keys = {};
Object.keys(leftHeaders || {}).forEach(function (k) { keys[k.toLowerCase()] = k; });
Object.keys(rightHeaders || {}).forEach(function (k) { keys[k.toLowerCase()] = keys[k.toLowerCase()] || k; });
var lower = Object.keys(keys).sort();
function pick(obj, lc) {
var found = null;
Object.keys(obj || {}).forEach(function (k) { if (k.toLowerCase() === lc) found = obj[k]; });
return found;
}
return '
' + lower.map(function (lc) {
var lv = pick(leftHeaders, lc), rv = pick(rightHeaders, lc);
var mine = side === "left" ? lv : rv;
if (mine == null) mine = "";
var differs = String(lv == null ? "" : lv) !== String(rv == null ? "" : rv);
var judged = judgedMap && judgedMap[lc];
var cls = [];
if (judged) cls.push("judged");
if (judged && !judged.equal) cls.push("mismatch");
else if (differs) cls.push("differs");
return '
' + esc(keys[lc]) + '
' + esc(mine) + "
";
}).join("") + "
";
}
function judgedMapFrom(checks) {
if (!checks) return null;
var m = {};
checks.forEach(function (c) { m[String(c.name).toLowerCase()] = { equal: c.equal }; });
return m;
}
function headerSectionTitle(checks) {
if (!checks || !checks.length) return MESSAGES.headerSection.notJudged;
var bad = headerMismatchNames(checks).length;
if (bad === 0) return MESSAGES.headerSection.judgedMatch(checks.length);
return MESSAGES.headerSection.judgedMismatch(checks.length, bad);
}
function formatBytes(n) {
n = Number(n) || 0;
if (n < 1024) return n + " B";
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + " KB";
return (n / 1024 / 1024).toFixed(2) + " MB";
}
function bodyHtml(res) {
// Read past the size cap: no body was kept, only the digest of what arrived.
if (res.bodyTooLarge && !res.isBinary) {
return '
";
}
// The expected side has no request of its own and no status unless one was
// configured, so it renders a reduced column rather than faking those fields.
if (res.isExpected) {
var meta = (res.status == null ? MESSAGES.column.expectedNoStatus : res.status + MESSAGES.column.expectedStatusSuffix);
var srcNote = res.bodySource && res.bodySource !== "body"
? '
";
}
function statsHtml(stats, leftLabel, rightLabel) {
stats = stats || [];
var unused = stats.filter(function (s) { return s.leftCount === 0 && s.rightCount === 0; }).length;
var lopsided = stats.filter(function (s) { return s.leftCount !== s.rightCount; }).length;
var rows = stats.map(function (s) {
var warn = (s.leftCount === 0 && s.rightCount === 0) ? "differs" : (s.leftCount !== s.rightCount ? "differs" : "");
return '
<IGNORED#' + s.index + '>
' +
'
' + esc(s.pattern) + "
" +
'
' + s.leftCount + MESSAGES.stats.countUnit + "
" +
'
' + s.rightCount + MESSAGES.stats.countUnit + "
";
}).join("");
var notes = [];
if (unused) notes.push(MESSAGES.stats.unusedNote(unused));
if (lopsided) notes.push(MESSAGES.stats.lopsidedNote(lopsided));
return '
";
}
function detailHtml(key) {
var entry = RESULTS[key];
if (!entry) return '
' + MESSAGES.detail.notRunYet + '
';
if (entry.buildError) return '
' + esc(entry.buildError) + "
";
var v = entry.verdict;
var left = entry.left, right = entry.right;
var leftLabel = v.leftLabel || (entry.sides && entry.sides.leftLabel) || MESSAGES.stats.defaultLeftLabel;
var rightLabel = v.rightLabel || (entry.sides && entry.sides.rightLabel) || MESSAGES.stats.defaultRightLabel;
var html = '
';
}
if (v.patterns && v.patterns.length) html += statsHtml(v.stats, leftLabel, rightLabel);
if (left && right) {
var jmap = judgedMapFrom(v.headerChecks);
var hdrTitle = headerSectionTitle(v.headerChecks);
html += '
";
tbody.appendChild(head);
if (!GROUP_OPEN[gi]) return;
group.keys.forEach(function (key, idx) {
var api = ITEMS[key].api;
var entry = RESULTS[key];
var verdict = entry && entry.verdict ? entry.verdict.result : (entry && entry.running ? "RUN" : "");
var label = verdict === "RUN" ? MESSAGES.row.running : (verdict || MESSAGES.row.notRun);
// Host is taken from the fully merged headers after substitution, so it
// shows what will actually be sent. Both sides are shown only when they
// differ, which keeps the common case to one line.
var hostText = "-", hostDiffers = false;
if (renderSides.mode === "single") {
hostText = hostFor(api, renderSides.leftKey, renderVals) || "-";
} else {
var hl = hostFor(api, renderSides.leftKey, renderVals);
var hr = hostFor(api, renderSides.rightKey, renderVals);
if (hl === hr) { hostText = hl || "-"; }
else { hostText = (hl || "-") + " → " + (hr || "-"); hostDiffers = true; }
}
var row = document.createElement("tr");
row.className = "api-row";
row.innerHTML =
// Sequence number within the group, computed here for display only --
// it is not part of the API's JSON definition, so it is styled (class
// "seq") to read as generated chrome rather than configured data.
'