"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 '"; }).join("") + "
' + esc(keys[lc]) + '' + esc(mine) + "
"; } 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 '
' + formatBytes(res.bodyBytes) + " · sha256 " + esc(String(res.bodySha256 || "").slice(0, 16)) + "…
" + "
" + MESSAGES.body.truncatedTextNote(formatBytes(res.bodyBytes)) + "
"; } if (!res.isBinary) return "
" + 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.bodyBase64 && /^image\//i.test(mime)) { return meta + '
' + MESSAGES.body.imageAlt + '
'; } return meta + "
" + MESSAGES.body.binaryNoPreview + "
"; } function columnHtml(label, res, req, values, leftHeaders, rightHeaders, side, judgedMap, hdrTitle) { if (res.error) { return '
' + esc(label) + ' ' + MESSAGES.column.requestFailedTag + '
' + '
' + 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 + '
' : "
" + esc(res.body) + "
"; return '
' + "
" + esc(label) + ' ' + esc(meta) + "
" + '
' + 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) + ' ' + res.status + " " + esc(res.statusText) + " · " + res.elapsedMs + "ms
" + (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 += '
' + esc(v.reason) + "
"; 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) + "" + '' + MESSAGES.group.meta(esc(group.name), esc(group.source || ""), group.keys.length) + "" + '' + 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; $("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 (!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); RESULTS = {}; // a different config means a different API list OPEN = {}; GROUP_OPEN = {}; buildItems(cfg); loadInterval(cfg); $("banner").style.display = "none"; $("varsPanel").style.display = VARDEFS.length ? "" : "none"; $("listPanel").style.display = ""; renderVars(); renderSubtitle(); renderRows(); $("progress").textContent = ""; }) .catch(function (e) { CONFIG = null; ITEMS = []; GROUPS = []; 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(); } }); 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.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();