"use strict"; var CONFIG = null; var VARDEFS = []; var VARGROUPS = []; // [{ name, label, vardefs: [...] }], from config.variableGroups var SELECTED_GROUP = null; // name of the currently selected variable group, or null 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 VARGROUP_PREFIX = "left_panel-right_panel-diff-vargroup::"; 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; } function normalizeVariableGroups(groups) { var out = []; if (!Array.isArray(groups)) return out; groups.forEach(function (g) { if (!g || typeof g !== "object" || !g.name) return; out.push({ name: String(g.name), label: g.label || g.name, vardefs: normalizeVarDefs(g.variables) }); }); return out; } // Common (top-level) variables, with the selected group's variables layered on // top: a group can override a common variable's definition (same name) or add // new ones. With no group selected this is just the common list, unchanged. function effectiveVarDefs() { var group = SELECTED_GROUP && VARGROUPS.filter(function (g) { return g.name === SELECTED_GROUP; })[0]; if (!group) return VARDEFS; var overrides = {}; group.vardefs.forEach(function (d) { overrides[d.name] = d; }); var seen = {}; var merged = VARDEFS.map(function (d) { seen[d.name] = true; return overrides[d.name] || d; }); group.vardefs.forEach(function (d) { if (!seen[d.name]) merged.push(d); }); return merged; } // Variable values are stored per config file *and* selected group. Sharing one // bucket would carry a dev token straight into a production config's (or // group's) input field. function storageKey() { return STORAGE_PREFIX + (CONFIG_FILE || "") + "::" + (SELECTED_GROUP || ""); } 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 varGroupStorageKey() { return VARGROUP_PREFIX + (CONFIG_FILE || ""); } function loadStoredVarGroup() { try { return sessionStorage.getItem(varGroupStorageKey()) || ""; } catch (e) { return ""; } } function storeVarGroup(name) { try { sessionStorage.setItem(varGroupStorageKey(), name || ""); } catch (e) { /* private mode */ } } function currentVars() { var values = {}; effectiveVarDefs().forEach(function (d) { var input = $("var-" + d.name); values[d.name] = input ? input.value : d.def; }); return values; } function renderVarGroupSelect() { var wrap = $("varGroupSelectWrap"); if (!wrap) return; if (!VARGROUPS.length) { wrap.style.display = "none"; return; } wrap.style.display = ""; $("varGroupLabel").textContent = MESSAGES.page.varGroupLabel; $("varGroup").innerHTML = VARGROUPS.map(function (g) { return '"; }).join(""); } function renderVars() { var defs = effectiveVarDefs(); var stored = loadStoredVars(); $("vars").innerHTML = defs.map(function (d) { var value = stored[d.name] != null ? stored[d.name] : d.def; return '
| ' + esc(keys[lc]) + ' | ' + esc(mine) + " |
in a hoverable box with a copy-to-clipboard button (top-right,
// shown on :hover via CSS). The button reads the box's own .textContent at click
// time instead of duplicating the (possibly multi-MB) text into a data attribute.
function copyableBodyBox(innerHtml) {
return '' +
'" + innerHtml + "";
}
function bodyHtml(res) {
// Read past the size cap: no body was kept, only the digest of what arrived.
if (res.bodyTooLarge && !res.isBinary) {
return '' + formatBytes(res.bodyBytes) +
" · sha256 " + esc(String(res.bodySha256 || "").slice(0, 16)) + "…" +
"" + MESSAGES.body.truncatedTextNote(formatBytes(res.bodyBytes)) + "
";
}
if (!res.isBinary) return copyableBodyBox("" + esc(res.body == null ? "" : res.body) + "
");
var ct = String(res.contentType || "");
var mime = ct.split(";")[0].trim();
var meta = '' + esc(mime || MESSAGES.body.noContentType) +
" · " + formatBytes(res.bodyBytes) +
" · sha256 " + esc(String(res.bodySha256 || "").slice(0, 16)) + "…";
if (res.bodyTooLarge) {
return meta + "" + MESSAGES.body.truncatedBinaryNote(formatBytes(res.bodyBytes)) + "
";
}
if (res.protobufJson) {
var pretty = res.protobufJson;
try { pretty = JSON.stringify(JSON.parse(res.protobufJson), null, 2); } catch (e) {}
return meta + '' + MESSAGES.body.protobufDecodedNote + '' +
copyableBodyBox("" + esc(pretty) + "
");
}
if (res.protobufError) {
return meta + '' + esc(MESSAGES.body.protobufErrorNote(res.protobufError)) + '' +
"" + MESSAGES.body.binaryNoPreview + "
";
}
if (res.bodyBase64 && /^image\//i.test(mime)) {
return meta + '
';
}
return meta + "" + MESSAGES.body.binaryNoPreview + "
";
}
function columnHtml(label, res, req, values, leftHeaders, rightHeaders, side, judgedMap, hdrTitle) {
if (res.error) {
return '' + esc(label) + ' ' +
'' + MESSAGES.column.errorTitle + '' + esc(res.error) + "
" +
(req ? '' + MESSAGES.column.requestTitle + '' + esc(maskSecrets(req.method + " " + req.url, values)) + "" : "") +
"";
}
// 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"
? '' + MESSAGES.column.expectedSourceNote(esc(res.bodySource)) + "" : "";
var dupNote = res.bothBodySpecified
? '' + MESSAGES.column.expectedBothSpecifiedNote + '' : "";
var expBody = res.body == null
? '' + MESSAGES.column.expectedBodyMissing + '
'
: copyableBodyBox("" + esc(res.body) + "
");
return '' +
"" + esc(label) + ' " +
'' + MESSAGES.column.expectedHeadersTitle + '' +
(Object.keys(res.headers || {}).length
? headerTable(res.headers, res.headers, "left", judgedMap)
: '' + MESSAGES.column.expectedHeadersNone + '') +
'' + MESSAGES.column.expectedBodyTitle + '' + srcNote + dupNote + expBody +
"";
}
return '' +
"" + esc(label) +
' " +
(req ? '' + MESSAGES.column.requestTitle + '' +
'' + esc(maskSecrets(req.method + " " + req.url, values)) + "
" +
Object.keys(req.headers).map(function (k) { return esc(k + ": " + maskSecrets(req.headers[k], values)); }).join("
") + "" : "") +
'' + esc(hdrTitle || MESSAGES.headerSection.notJudged) + "" +
headerTable(leftHeaders, rightHeaders, side, judgedMap) +
'' + MESSAGES.column.responseBodyTitle + '' + bodyHtml(res) +
"";
}
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 '";
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.reason) html += '";
if (v.patternsIgnored) {
html += '';
}
if (v.bodyCapped) {
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 += '' +
columnHtml(leftLabel, left, entry.requests && entry.requests.left, entry.values,
left.headers, right.headers, "left", jmap, hdrTitle) +
columnHtml(rightLabel, right, entry.requests && entry.requests.right, entry.values,
left.headers, right.headers, "right", jmap, hdrTitle) +
"";
}
if (v.result === "Fail" && v.binary && left && right) {
html += '' + MESSAGES.detail.binaryDiffTitle + ' ' +
esc(leftLabel) + " : " + esc(String(left.contentType || "-")) + " · " + formatBytes(left.bodyBytes) + " · " + esc(String(left.bodySha256 || "-")) + "\n" +
esc(rightLabel) + " : " + esc(String(right.contentType || "-")) + " · " + formatBytes(right.bodyBytes) + " · " + esc(String(right.bodySha256 || "-")) +
"";
} else if (v.result === "Fail" && v.leftText != null && v.rightText != null && v.leftText !== v.rightText) {
var ops = diffLines(v.leftText, v.rightText);
html += '' + MESSAGES.detail.textDiffTitle(esc(leftLabel), esc(rightLabel)) + ' ';
if (!ops) {
var at = firstDifference(v.leftText, v.rightText);
html += "" + MESSAGES.detail.diffTooLarge(
DIFF_LINE_LIMIT, at,
esc(leftLabel), esc(v.leftText.substr(Math.max(0, at - 60), 160)),
esc(rightLabel), esc(v.rightText.substr(Math.max(0, at - 60), 160))
) + "";
} else {
html += "" + renderHunks(ops) + "
";
}
html += "";
}
return html + "";
}
function renderHunks(ops) {
var CONTEXT = 3;
var keep = new Array(ops.length);
ops.forEach(function (op, i) {
if (op.t === " ") return;
for (var j = Math.max(0, i - CONTEXT); j <= Math.min(ops.length - 1, i + CONTEXT); j++) keep[j] = true;
});
var out = [], skipping = false;
ops.forEach(function (op, i) {
if (!keep[i]) {
if (!skipping) { out.push(' …'); skipping = true; }
return;
}
skipping = false;
var cls = op.t === "-" ? "del" : (op.t === "+" ? "add" : "");
var no = op.t === "+" ? ("+" + op.b) : (op.t === "-" ? ("-" + op.a) : (" " + op.a));
out.push('' + esc(pad(no, 6)) + " " + esc(op.text) + "");
});
return out.join("");
}
function pad(s, n) { s = String(s); while (s.length < n) s = " " + s; return s; }
function countKeys(keys) {
var counts = { OK: 0, Fail: 0, ERROR: 0, done: 0 };
keys.forEach(function (key) {
var r = RESULTS[key];
if (r && r.verdict && counts[r.verdict.result] !== undefined) { counts[r.verdict.result]++; counts.done++; }
});
return counts;
}
function chipsHtml(counts) {
return 'OK ' + counts.OK + "" +
'Fail ' + counts.Fail + "" +
'ERROR ' + counts.ERROR + "";
}
function renderRows() {
var tbody = $("rows");
tbody.innerHTML = "";
// Computed once per render, not once per row: currentVars() reads the DOM and
// hostCellText substitutes into merged headers, so doing it per row would make
// the whole run quadratic in the number of APIs.
var renderVals = CONFIG ? currentVars() : {};
var renderSides = compareSides();
GROUPS.forEach(function (group, gi) {
var counts = countKeys(group.keys);
var head = document.createElement("tr");
head.className = "group-row";
head.innerHTML = '' +
'" +
'' + esc(group.label) + "" +
'" +
'' + chipsHtml(counts) +
'' +
" ";
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.
'' + (idx + 1) + " " +
' " +
'' + esc(api.name || MESSAGES.row.noName) +
(api.ignorePatterns && api.ignorePatterns.length ? '' + MESSAGES.row.ignorePatternsApplied(api.ignorePatterns.length) + '' : "") +
" " +
'' + esc(hostText) + " " +
'' + esc((api.method || "GET").toUpperCase()) + " " +
'' + esc(api.path || "") + " " +
'' + esc(label) + " " +
' ' +
' ';
tbody.appendChild(row);
if (OPEN[key]) {
var detail = document.createElement("tr");
detail.className = "detail";
detail.innerHTML = '' + detailHtml(key) + " ";
tbody.appendChild(detail);
}
});
});
renderSummary();
}
/* ---------------------------------------------------------- execution */
var INFLIGHT = []; // AbortControllers for requests currently on the wire
function abortInflight() {
INFLIGHT.forEach(function (c) { try { c.abort(); } catch (e) {} });
INFLIGHT = [];
}
// The last line of defence. The server has its own deadline, but if it ever
// fails to answer at all this is what stops an item sitting on "실행 중"
// forever. Failures are turned into a synthetic error response instead of a
// rejection so Promise.all never rejects and the run keeps moving.
function callProxy(spec) {
var budget = (Number(spec.timeoutMs) || 30000) + 5000;
var ac = (typeof AbortController !== "undefined") ? new AbortController() : null;
var timer = null;
var opts = {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(spec)
};
if (ac) {
opts.signal = ac.signal;
INFLIGHT.push(ac);
timer = setTimeout(function () { try { ac.abort(); } catch (e) {} }, budget);
}
function cleanup() {
if (timer) clearTimeout(timer);
if (ac) {
var i = INFLIGHT.indexOf(ac);
if (i >= 0) INFLIGHT.splice(i, 1);
}
}
return fetch("/proxy", opts).then(function (res) {
if (!res.ok) return res.text().then(function (t) { throw new Error(MESSAGES.proxy.errorPrefix(res.status, t)); });
return res.json();
}).then(function (r) {
cleanup();
return r;
}, function (e) {
cleanup();
var aborted = ac && ac.signal.aborted;
return {
status: 0, statusText: "", headers: {}, contentType: "",
isBinary: false, body: "", bodyBase64: null, bodyTooLarge: false,
bodyBytes: 0, bodySha256: "", elapsedMs: budget,
error: aborted ? MESSAGES.proxy.clientTimeout(budget) : e.message
};
});
}
function runItem(key) {
var api = ITEMS[key].api;
var values = currentVars();
var sides = compareSides();
var requests;
try {
if (sides.mode === "single") {
// No buildRequest for a second target exists on this path, so the other
// gateway cannot be contacted even by accident.
requests = { left: buildRequest(api, sides.leftKey, values), right: null };
} else {
requests = { left: buildRequest(api, sides.leftKey, values), right: buildRequest(api, sides.rightKey, values) };
}
} catch (e) {
RESULTS[key] = { buildError: e.message, verdict: { result: "ERROR", reason: e.message } };
OPEN[key] = true;
renderRows();
return Promise.resolve();
}
RESULTS[key] = { running: true };
renderRows();
var work;
if (sides.mode === "single") {
work = callProxy(requests.left).then(function (actual) {
var expected = expectedResponse(api);
var verdict;
try { verdict = judgeSingle(api, actual, expected); }
catch (e) { verdict = { result: "ERROR", reason: e.message, leftLabel: sides.leftLabel, rightLabel: sides.rightLabel }; }
RESULTS[key] = { left: actual, right: expected, verdict: verdict, requests: requests, values: values, sides: sides };
if (verdict.result !== "OK") OPEN[key] = true;
});
} else {
work = Promise.all([callProxy(requests.left), callProxy(requests.right)]).then(function (pair) {
var verdict;
try { verdict = judge(api, pair[0], pair[1]); }
catch (e) { verdict = { result: "ERROR", reason: e.message, leftLabel: sides.leftLabel, rightLabel: sides.rightLabel }; }
RESULTS[key] = { left: pair[0], right: pair[1], verdict: verdict, requests: requests, values: values, sides: sides };
if (verdict.result !== "OK") OPEN[key] = true;
});
}
return work.catch(function (e) {
RESULTS[key] = { buildError: e.message, verdict: { result: "ERROR", reason: e.message } };
OPEN[key] = true;
}).then(function () { renderRows(); });
}
function setBusy(state) {
BUSY = state;
$("runAll").disabled = state;
$("reload").disabled = state;
$("configFile").disabled = state;
$("interval").disabled = state;
if ($("varGroup")) $("varGroup").disabled = state;
$("stop").disabled = !state;
Array.prototype.forEach.call(document.querySelectorAll("[data-run],[data-grun]"), function (b) { b.disabled = state; });
}
// Shared driver for 전체 / 그룹 / 개별 execution. The interval is applied
// between items only, so a single-item run never waits.
function runSequence(keys, what) {
if (!keys.length) return Promise.resolve();
ABORT = false;
setBusy(true);
var interval = getInterval();
var chain = Promise.resolve();
keys.forEach(function (key, i) {
chain = chain.then(function () {
if (ABORT) return;
if (i === 0 || interval === 0) return;
$("progress").textContent = MESSAGES.progress.waiting(what, interval, i + 1, keys.length);
return delay(interval);
}).then(function () {
if (ABORT) return;
$("progress").textContent = MESSAGES.progress.running(what, i + 1, keys.length, ITEMS[key].api.name || "");
setBusy(true);
return runItem(key);
// Per-item guard: one unexpected throw used to break the whole chain and
// leave progress stuck on "실행 중" with the buttons disabled.
}).catch(function (e) {
RESULTS[key] = { buildError: String(e && e.message || e), verdict: { result: "ERROR", reason: String(e && e.message || e) } };
OPEN[key] = true;
});
});
function finish() {
var counts = countKeys(keys);
$("progress").textContent = (ABORT ? MESSAGES.progress.stopped : MESSAGES.progress.done) +
MESSAGES.progress.summaryCounts(counts.done, keys.length, counts.OK, counts.Fail, counts.ERROR);
setBusy(false);
ABORT = false;
renderRows();
}
// finish() on both paths so the controls can never stay locked.
return chain.then(finish, finish);
}
function runAll() {
return runSequence(ITEMS.map(function (it) { return it.key; }), MESSAGES.runLabels.all);
}
function runGroup(gi) {
var group = GROUPS[gi];
return runSequence(group.keys, MESSAGES.group.runLabel(group.label));
}
/* --------------------------------------------------------------- boot */
function validateConfig(cfg) {
if (!cfg || typeof cfg !== "object") throw new Error(MESSAGES.validate.rootNotObject);
if (!cfg.targets) throw new Error(MESSAGES.validate.missingTargets);
var mode = cfg.compareMode || "dual";
if (mode !== "dual" && mode !== "single") {
throw new Error(MESSAGES.validate.invalidCompareMode(mode));
}
if (mode === "single") {
// Deliberately not defaulted to a target: silently picking one would mean
// verifying something the operator did not ask for.
if (!cfg.singleTarget) throw new Error(MESSAGES.validate.singleTargetRequired);
if (!cfg.targets[cfg.singleTarget]) throw new Error(MESSAGES.validate.missingSingleTarget(cfg.singleTarget));
} else {
if (cfg.compareTargets != null && (!Array.isArray(cfg.compareTargets) || cfg.compareTargets.length !== 2 || !cfg.compareTargets[0] || !cfg.compareTargets[1])) {
throw new Error(MESSAGES.validate.invalidCompareTargets);
}
var dualKeys = resolveDualTargetKeys(cfg);
if (!cfg.targets[dualKeys[0]] || !cfg.targets[dualKeys[1]]) {
throw new Error(MESSAGES.validate.missingDualTargets(dualKeys[0], dualKeys[1]));
}
}
if (cfg.compareHeaders != null && !Array.isArray(cfg.compareHeaders)) {
throw new Error(MESSAGES.validate.compareHeadersNotArray);
}
if (cfg.variableGroups != null) {
if (!Array.isArray(cfg.variableGroups)) throw new Error(MESSAGES.validate.variableGroupsNotArray);
var seenGroup = {};
cfg.variableGroups.forEach(function (g, gi) {
if (!g || typeof g !== "object" || !g.name) throw new Error(MESSAGES.validate.variableGroupMissingName(gi));
if (seenGroup[g.name]) throw new Error(MESSAGES.validate.duplicateVariableGroupName(g.name));
seenGroup[g.name] = true;
if (g.variables != null && (typeof g.variables !== "object" || Array.isArray(g.variables))) {
throw new Error(MESSAGES.validate.variableGroupVariablesNotObject(g.name));
}
});
}
if (!Array.isArray(cfg.groups)) throw new Error(MESSAGES.validate.noGroups);
var seen = {};
cfg.groups.forEach(function (g, gi) {
var where = MESSAGES.validate.groupWhere(g.name || gi, g.source || "?");
if (!g.name) throw new Error(MESSAGES.validate.groupMissingName(gi));
if (seen[g.name]) throw new Error(MESSAGES.validate.duplicateGroupName(g.name, seen[g.name], g.source));
seen[g.name] = g.source;
if (!Array.isArray(g.apis)) throw new Error(MESSAGES.validate.apisNotArray(where));
g.apis.forEach(function (api, i) {
if (!api.path) throw new Error(MESSAGES.validate.right_panelissingPath(where, i));
var m = (api.method || "GET").toUpperCase();
if (m !== "GET" && m !== "POST") throw new Error(MESSAGES.validate.apiInvalidMethod(where, i, m));
["compareHeaders", "ignoreHeaders"].forEach(function (f) {
if (api[f] != null && !Array.isArray(api[f])) {
throw new Error(MESSAGES.validate.apiFieldNotArray(where, i, f));
}
});
// A missing expected block is NOT an error: it becomes a runtime ERROR
// verdict for that one API so the rest of the list still runs.
if (api.expected != null) {
if (typeof api.expected !== "object" || Array.isArray(api.expected)) {
throw new Error(MESSAGES.validate.expectedNotObject(where, i));
}
if (api.expected.status != null && isNaN(Number(api.expected.status))) {
throw new Error(MESSAGES.validate.expectedStatusNotNumber(where, i));
}
if (api.expected.headers != null && (typeof api.expected.headers !== "object" || Array.isArray(api.expected.headers))) {
throw new Error(MESSAGES.validate.expectedHeadersNotObject(where, i));
}
if (api.expected.body != null && typeof api.expected.body === "object") {
throw new Error(MESSAGES.validate.expectedBodyNotString(where, i));
}
}
});
});
}
// Always-visible, colour-coded restatement of compareSides(), independent of
// the prose subtitle below it -- a reader should be able to tell dual vs.
// single apart at a glance, without parsing a sentence.
function renderModeChip() {
var line = $("modeLine"), chip = $("modeChip"), target = $("modeTarget");
if (!line || !chip || !target) return;
if (!CONFIG) { line.style.display = "none"; return; }
var sides = compareSides();
line.style.display = "";
if (sides.mode === "single") {
chip.className = "mode-chip single";
chip.textContent = MESSAGES.mode.singleChip;
target.textContent = MESSAGES.mode.singleTarget(sides.leftLabel, sides.rightLabel);
} else {
chip.className = "mode-chip dual";
chip.textContent = MESSAGES.mode.dualChip;
target.textContent = MESSAGES.mode.dualTarget(sides.leftLabel, sides.rightLabel);
}
}
// Reflects the actual comparison being performed, so the header line never
// claims response headers are ignored when compareHeaders is in play.
function renderSubtitle() {
renderModeChip();
var el = $("subtitle");
if (!el || !CONFIG) return;
var sides = compareSides();
if (sides.mode === "single") {
el.textContent = MESSAGES.subtitle.single(sides.leftLabel);
return;
}
var n = (CONFIG.compareHeaders || []).length;
if (n > 0) {
el.textContent = MESSAGES.subtitle.dualWithHeaders(n);
} else {
el.textContent = MESSAGES.subtitle.dualNoHeaders;
}
}
// One flat item list keyed by a stable index; groups only hold key references
// so a result never depends on where an API sits inside its group.
function buildItems(cfg) {
ITEMS = [];
GROUPS = [];
(cfg.groups || []).forEach(function (g, gi) {
var keys = [];
(g.apis || []).forEach(function (api) {
if (api.enabled === false) return;
keys.push(ITEMS.length);
ITEMS.push({ key: ITEMS.length, api: api, groupIndex: gi });
});
GROUPS.push({ name: g.name, label: g.label || g.name, source: g.source, keys: keys });
if (GROUP_OPEN[gi] === undefined) GROUP_OPEN[gi] = true;
});
}
/* --------------------------------------------------------- run interval */
function intervalKey() { return INTERVAL_PREFIX + (CONFIG_FILE || ""); }
function getInterval() {
var raw = Number($("interval").value);
if (!isFinite(raw) || raw < 0) return 0;
return Math.floor(raw);
}
function setInterval_(ms) {
$("interval").value = ms;
}
function loadInterval(cfg) {
var stored = null;
try { stored = sessionStorage.getItem(intervalKey()); } catch (e) { /* private mode */ }
var value = stored !== null && stored !== "" ? Number(stored) : Number(cfg.requestIntervalMs || 0);
setInterval_(isFinite(value) && value >= 0 ? Math.floor(value) : 0);
}
function storeInterval() {
try { sessionStorage.setItem(intervalKey(), String(getInterval())); } catch (e) { /* private mode */ }
}
function delay(ms) {
return new Promise(function (resolve) { setTimeout(resolve, ms); });
}
function jsonOrThrow(res) {
return res.json().then(function (json) {
if (!res.ok) throw new Error(json && json.error ? json.error : MESSAGES.boot.requestFailed(res.status));
return json;
});
}
function configLoadFailed(message) {
showBanner(MESSAGES.boot.configLoadFailedBanner(esc(message)));
$("varsPanel").style.display = "none";
$("listPanel").style.display = "none";
$("modeLine").style.display = "none";
}
function loadConfig(file) {
CONFIG_FILE = file;
return fetch("/config?file=" + encodeURIComponent(file), { cache: "no-store" })
.then(jsonOrThrow)
.then(function (cfg) {
validateConfig(cfg);
CONFIG = cfg;
VARDEFS = normalizeVarDefs(cfg.variables);
VARGROUPS = normalizeVariableGroups(cfg.variableGroups);
var storedGroup = loadStoredVarGroup();
SELECTED_GROUP = (storedGroup && VARGROUPS.some(function (g) { return g.name === storedGroup; }))
? storedGroup
: (VARGROUPS.length ? VARGROUPS[0].name : null);
RESULTS = {}; // a different config means a different API list
OPEN = {};
GROUP_OPEN = {};
buildItems(cfg);
loadInterval(cfg);
$("banner").style.display = "none";
$("varsPanel").style.display = (effectiveVarDefs().length || VARGROUPS.length) ? "" : "none";
$("listPanel").style.display = "";
renderVarGroupSelect();
renderVars();
renderSubtitle();
renderRows();
$("progress").textContent = "";
})
.catch(function (e) {
CONFIG = null;
ITEMS = [];
GROUPS = [];
VARGROUPS = [];
SELECTED_GROUP = null;
configLoadFailed(e.message);
});
}
function boot() {
if (location.protocol === "file:") {
showBanner(MESSAGES.boot.fileProtocolBanner);
return;
}
var previous = CONFIG_FILE;
fetch("/configs", { cache: "no-store" })
.then(jsonOrThrow)
.then(function (list) {
var files = list.files || [];
if (!files.length) throw new Error(MESSAGES.boot.noConfigFiles);
// Keep the current selection across a reload when it still exists.
var selected = (previous && files.indexOf(previous) >= 0) ? previous : list["default"];
if (files.indexOf(selected) < 0) selected = files[0];
var select = $("configFile");
select.innerHTML = files.map(function (f) {
return '";
}).join("");
$("toolbarPanel").style.display = "";
return loadConfig(selected);
})
.catch(function (e) {
$("toolbarPanel").style.display = "none";
configLoadFailed(e.message);
});
}
document.addEventListener("change", function (ev) {
if (!ev.target) return;
if (ev.target.id === "configFile" && !BUSY) {
setBusy(true);
loadConfig(ev.target.value).then(function () { setBusy(false); });
} else if (ev.target.id === "interval") {
setInterval_(getInterval());
storeInterval();
} else if (ev.target.id === "varGroup" && !BUSY) {
SELECTED_GROUP = ev.target.value || null;
storeVarGroup(SELECTED_GROUP);
renderVars();
renderRows();
}
});
document.addEventListener("click", function (ev) {
var t = ev.target;
if (!t || t.tagName !== "BUTTON") return;
if (t.hasAttribute("data-gtoggle")) {
var gi = Number(t.getAttribute("data-gtoggle"));
GROUP_OPEN[gi] = !GROUP_OPEN[gi];
renderRows();
} else if (t.hasAttribute("data-toggle")) {
var ti = Number(t.getAttribute("data-toggle"));
OPEN[ti] = !OPEN[ti];
renderRows();
} else if (t.hasAttribute("data-grun") && !BUSY) {
runGroup(Number(t.getAttribute("data-grun")));
} else if (t.hasAttribute("data-run") && !BUSY) {
var key = Number(t.getAttribute("data-run"));
runSequence([key], MESSAGES.runLabels.single);
} else if (t.hasAttribute("data-curl")) {
// Harmless to allow during a run: this only builds a string and touches
// the clipboard, unlike data-run/data-grun which are disabled by setBusy().
var ckey = Number(t.getAttribute("data-curl"));
var capi = ITEMS[ckey] && ITEMS[ckey].api;
if (!capi) return;
var label = t.textContent;
try {
var cmds = curlCommandsFor(capi, currentVars());
var text = cmds.map(function (c) {
return (cmds.length > 1 ? "# " + c.label + "\n" : "") + c.command;
}).join("\n\n");
copyTextToClipboard(text).then(function () {
flashButtonLabel(t, MESSAGES.clipboard.copiedLabel, label);
}).catch(function (e) {
showBanner(MESSAGES.boot.clipboardFailedBanner(esc(e.message)), true);
});
} catch (e) {
showBanner(MESSAGES.boot.curlBuildFailedBanner(esc(e.message)), true);
}
} else if (t.hasAttribute("data-copybody")) {
var box = t.closest(".body-box");
var pre = box && box.querySelector("pre");
var bodyText = pre ? pre.textContent : "";
var bodyLabel = t.textContent;
copyTextToClipboard(bodyText).then(function () {
flashButtonLabel(t, MESSAGES.clipboard.copiedLabel, bodyLabel);
}).catch(function (e) {
showBanner(MESSAGES.boot.clipboardFailedBanner(esc(e.message)), true);
});
} else if (t.id === "runAll" && !BUSY) {
runAll();
} else if (t.id === "stop") {
ABORT = true;
// Both in-flight calls of the current item are cancelled, not just the
// queue: waiting out a full timeout to honour a stop request is the same
// "nothing is happening" experience this release set out to remove.
abortInflight();
$("progress").textContent = MESSAGES.progress.stopRequested;
} else if (t.id === "reload" && !BUSY) {
boot();
}
});
applyStaticText();
boot();