mirror of
https://github.com/github/awesome-copilot.git
synced 2026-08-01 23:12:29 +00:00
5b6da4c588
* Add PR Artifact Explorer canvas Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 33fefd65-ed18-4eda-9c7d-48008f4a9c9d * Address PR #2341 review feedback and codespell CI failures PR review fixes: - zip.mjs: bounded streaming inflate for readZipEntry (output cap = entry.uncompressedSize) to prevent memory exhaustion from malformed entries - zip.mjs: new bounded verify Transform in streamZipEntry that enforces decompressed byte cap and verifies CRC32 on flush - zip.mjs: new readEntryPrefix() helper that inflates only up to a byte cap for indexing use cases - preview.mjs: move URL/path parsing inside the try block so URIError becomes a 400 response instead of an unhandled rejection - cache.mjs: buildMetadata uses bounded readEntryPrefix (8 KiB) instead of full readEntry+slice, eliminating unbounded decompression during indexing - cache.mjs: clearArtifactCache aborts and awaits in-flight downloads via AbortController before removing cache dirs so clear cannot be repopulated - server.mjs / preview.mjs: track static preview servers by canvas origin and stop them on canvas close, preventing loopback server leaks - trx-preview.js: prefer authoritative ResultSummary.outcome when it is a recognized TRX value; only infer from counters when unknown - extension.mjs: set_account validates the requested id resolves to the active account before persisting; unknown ids now return an error - server.mjs: reject progressive-pull offsets that are not multiples of PROGRESSIVE_PULL_BATCH_SIZE to prevent caching incomplete pulls as complete - README.md: remove Markdown-rendering claim (files are shown as escaped text) CI fix: - .codespellrc: skip vendored asciinema-player(*.min.js) and primer-*.css bundles Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 47369ff4-859a-425f-8a47-763cc1a5f25f * Address second round of PR #2341 review feedback - github.mjs: request isDraft in the pullRequestSignals GraphQL enrichment and propagate draft into search-sourced pulls (search REST API has no draft field) - cache.mjs: give each writeJsonAtomic write a unique temp filename via a module sequence and clean up the temp file on failure to avoid concurrent-write races - cache.mjs: deleteCachedArtifact now aborts and awaits any in-flight download for the artifact before removing files; internal mismatched-metadata purge uses a raw removeArtifactFiles helper to avoid aborting its own operation - accounts.mjs: skip non-github.com CLI accounts before reading tokens so GitHub Enterprise Server credentials are never sent to api.github.com - preview.mjs: require the requested entry to be the root index.html before launching a static preview (previously any nested HTML file could start one) - zip.mjs: validate the EOCD comment length ends exactly at the archive tail so a file comment containing the EOCD signature is not mistaken for the record Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 47369ff4-859a-425f-8a47-763cc1a5f25f * Address remaining PR artifact explorer feedback Fix malformed preference normalization, replace artifact-presence request fanout with cached repository artifact pagination, restrict static previews to the root index, and derive completed TRX outcomes from counters. Add focused regression coverage for all four behaviors. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: db486df1-17f1-4623-ac7e-61eff2c76da4 * Fix PR artifact explorer review feedback Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6ee2bc81-2114-4b1f-987a-cb47ea35e132 * Stabilize artifact discovery and cache cleanup Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6ee2bc81-2114-4b1f-987a-cb47ea35e132 --------- Co-authored-by: David Pine <7679720+IEvangelist@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 33fefd65-ed18-4eda-9c7d-48008f4a9c9d Copilot-Session: 47369ff4-859a-425f-8a47-763cc1a5f25f Copilot-Session: db486df1-17f1-4623-ac7e-61eff2c76da4 Copilot-Session: 6ee2bc81-2114-4b1f-987a-cb47ea35e132
769 lines
26 KiB
JavaScript
769 lines
26 KiB
JavaScript
(() => {
|
|
"use strict";
|
|
|
|
const MAX_RENDERED_RESULTS = 2_000;
|
|
const MAX_DETAIL_CHARS = 24_000;
|
|
const MAX_HIGHLIGHTED_RAW_CHARS = 4 * 1024 * 1024;
|
|
const rawSources = new WeakMap();
|
|
|
|
function escapeHtml(value) {
|
|
return String(value ?? "")
|
|
.replaceAll("&", "&")
|
|
.replaceAll("<", "<")
|
|
.replaceAll(">", ">")
|
|
.replaceAll('"', """)
|
|
.replaceAll("'", "'");
|
|
}
|
|
|
|
function icon(name) {
|
|
const safeName = /^[a-z-]+$/.test(name) ? name : "file";
|
|
return `<span class="octicon icon-${safeName}" aria-hidden="true"></span>`;
|
|
}
|
|
|
|
function formatDate(value) {
|
|
const date = new Date(value);
|
|
return Number.isNaN(date.getTime())
|
|
? ""
|
|
: new Intl.DateTimeFormat(undefined, {
|
|
dateStyle: "medium",
|
|
timeStyle: "short",
|
|
}).format(date);
|
|
}
|
|
|
|
function elements(parent, localName) {
|
|
if (!parent?.getElementsByTagName) return [];
|
|
return [...parent.getElementsByTagName("*")].filter(
|
|
(element) => element.localName === localName,
|
|
);
|
|
}
|
|
|
|
function firstElement(parent, localName) {
|
|
return elements(parent, localName)[0] ?? null;
|
|
}
|
|
|
|
function directChild(parent, localName) {
|
|
return (
|
|
[...(parent?.children ?? [])].find(
|
|
(element) => element.localName === localName,
|
|
) ?? null
|
|
);
|
|
}
|
|
|
|
function elementText(element) {
|
|
return String(element?.textContent ?? "").trim();
|
|
}
|
|
|
|
function attribute(element, name) {
|
|
return String(element?.getAttribute(name) ?? "").trim();
|
|
}
|
|
|
|
function outcomeCategory(outcome) {
|
|
const normalized = String(outcome ?? "")
|
|
.toLocaleLowerCase()
|
|
.replace(/[^a-z]/g, "");
|
|
if (normalized === "passed") return "passed";
|
|
if (
|
|
[
|
|
"failed",
|
|
"error",
|
|
"timeout",
|
|
"aborted",
|
|
"passedbutrunaborted",
|
|
"disconnected",
|
|
].includes(normalized)
|
|
) {
|
|
return "failed";
|
|
}
|
|
if (
|
|
["notexecuted", "notrunnable", "inconclusive", "pending"].includes(
|
|
normalized,
|
|
)
|
|
) {
|
|
return "skipped";
|
|
}
|
|
return "other";
|
|
}
|
|
|
|
function outcomeIcon(category) {
|
|
if (category === "passed") return "check-circle";
|
|
if (category === "failed") return "x-circle";
|
|
if (category === "skipped") return "clock";
|
|
return "workflow";
|
|
}
|
|
|
|
const KNOWN_TRX_OUTCOMES = new Set([
|
|
"passed", "failed", "completed", "error", "warning",
|
|
"inconclusive", "aborted", "timeout", "inprogress", "notexecuted",
|
|
]);
|
|
|
|
function summarizedRunOutcome(report) {
|
|
const normalized = String(report.outcome ?? "").toLowerCase();
|
|
if (!KNOWN_TRX_OUTCOMES.has(normalized) || normalized === "completed") {
|
|
if (report.summaryCounts.failed > 0) return "Failed";
|
|
if (report.summaryCounts.passed > 0) return "Passed";
|
|
if (report.summaryCounts.skipped > 0) return "Skipped";
|
|
}
|
|
return report.outcome;
|
|
}
|
|
|
|
function parseDuration(value) {
|
|
const duration = String(value ?? "").trim();
|
|
if (!duration) return null;
|
|
|
|
const clock = duration.match(
|
|
/^(?:(\d+)\.)?(\d{1,2}):(\d{2}):(\d{2}(?:\.\d+)?)$/,
|
|
);
|
|
if (clock) {
|
|
const [, days = "0", hours, minutes, seconds] = clock;
|
|
return (
|
|
Number(days) * 86_400_000 +
|
|
Number(hours) * 3_600_000 +
|
|
Number(minutes) * 60_000 +
|
|
Number(seconds) * 1_000
|
|
);
|
|
}
|
|
|
|
const iso = duration.match(
|
|
/^P(?:(\d+(?:\.\d+)?)D)?(?:T(?:(\d+(?:\.\d+)?)H)?(?:(\d+(?:\.\d+)?)M)?(?:(\d+(?:\.\d+)?)S)?)?$/i,
|
|
);
|
|
if (!iso) return null;
|
|
const [, days = "0", hours = "0", minutes = "0", seconds = "0"] = iso;
|
|
return (
|
|
Number(days) * 86_400_000 +
|
|
Number(hours) * 3_600_000 +
|
|
Number(minutes) * 60_000 +
|
|
Number(seconds) * 1_000
|
|
);
|
|
}
|
|
|
|
function formatDuration(durationMs) {
|
|
if (!Number.isFinite(durationMs)) return "";
|
|
if (durationMs < 1_000) {
|
|
return `${Math.max(0, Math.round(durationMs))} ms`;
|
|
}
|
|
if (durationMs < 60_000) {
|
|
const seconds = durationMs / 1_000;
|
|
return `${seconds
|
|
.toFixed(seconds < 10 ? 2 : 1)
|
|
.replace(/\.0+$|(\.\d*[1-9])0+$/, "$1")} s`;
|
|
}
|
|
if (durationMs < 3_600_000) {
|
|
const minutes = Math.floor(durationMs / 60_000);
|
|
const seconds = Math.floor((durationMs % 60_000) / 1_000);
|
|
return `${minutes}m ${seconds}s`;
|
|
}
|
|
const hours = Math.floor(durationMs / 3_600_000);
|
|
const minutes = Math.floor((durationMs % 3_600_000) / 60_000);
|
|
return `${hours}h ${minutes}m`;
|
|
}
|
|
|
|
function parseReport(value) {
|
|
const source = String(value ?? "");
|
|
if (/<!DOCTYPE|<!ENTITY/i.test(source)) {
|
|
throw new Error(
|
|
"DTD and entity declarations are not supported in test result previews.",
|
|
);
|
|
}
|
|
|
|
const documentNode = new DOMParser().parseFromString(
|
|
source,
|
|
"application/xml",
|
|
);
|
|
const parserError = [...documentNode.getElementsByTagName("*")].find(
|
|
(element) => element.localName?.toLocaleLowerCase() === "parsererror",
|
|
);
|
|
if (parserError) {
|
|
const detail = elementText(parserError).replace(/\s+/g, " ").slice(0, 240);
|
|
throw new Error(detail || "The XML document is malformed.");
|
|
}
|
|
|
|
const root = documentNode.documentElement;
|
|
if (root?.localName !== "TestRun") {
|
|
throw new Error(
|
|
"The XML document does not contain a TRX TestRun root element.",
|
|
);
|
|
}
|
|
|
|
const definitions = new Map();
|
|
for (const unitTest of elements(root, "UnitTest")) {
|
|
const testId = attribute(unitTest, "id").toLocaleLowerCase();
|
|
if (!testId) continue;
|
|
const method = firstElement(unitTest, "TestMethod");
|
|
definitions.set(testId, {
|
|
name: attribute(unitTest, "name"),
|
|
className: attribute(method, "className"),
|
|
methodName: attribute(method, "name"),
|
|
codeBase: attribute(method, "codeBase"),
|
|
adapterTypeName: attribute(method, "adapterTypeName"),
|
|
});
|
|
}
|
|
|
|
const testLists = new Map();
|
|
for (const testList of elements(root, "TestList")) {
|
|
const id = attribute(testList, "id").toLocaleLowerCase();
|
|
if (id) testLists.set(id, attribute(testList, "name"));
|
|
}
|
|
|
|
const results = [];
|
|
const resultsElement = directChild(root, "Results");
|
|
const isResultElement = (element) =>
|
|
element.localName?.endsWith("TestResult") ||
|
|
element.localName === "TestResultAggregation";
|
|
|
|
const readResult = (element, depth) => {
|
|
const testId = attribute(element, "testId").toLocaleLowerCase();
|
|
const definition = definitions.get(testId) ?? {};
|
|
const output = directChild(element, "Output");
|
|
const errorInfo = firstElement(output, "ErrorInfo");
|
|
const outcome = attribute(element, "outcome") || "Unknown";
|
|
const testListId = attribute(element, "testListId").toLocaleLowerCase();
|
|
const durationValue = attribute(element, "duration");
|
|
const resultFiles = elements(output, "ResultFile")
|
|
.map((file) => attribute(file, "path"))
|
|
.filter(Boolean);
|
|
const properties = elements(output, "Property")
|
|
.map((property) => {
|
|
const key =
|
|
elementText(directChild(property, "Key")) ||
|
|
attribute(property, "name");
|
|
const propertyValue =
|
|
elementText(directChild(property, "Value")) ||
|
|
attribute(property, "value");
|
|
return key ? { key, value: propertyValue } : null;
|
|
})
|
|
.filter(Boolean);
|
|
|
|
return {
|
|
depth,
|
|
outcome,
|
|
category: outcomeCategory(outcome),
|
|
testName:
|
|
attribute(element, "testName") ||
|
|
definition.name ||
|
|
definition.methodName ||
|
|
"Unnamed test",
|
|
className: definition.className || attribute(element, "className"),
|
|
methodName: definition.methodName,
|
|
codeBase: definition.codeBase,
|
|
adapterTypeName: definition.adapterTypeName,
|
|
computerName: attribute(element, "computerName"),
|
|
startTime: attribute(element, "startTime"),
|
|
endTime: attribute(element, "endTime"),
|
|
durationMs: parseDuration(durationValue),
|
|
executionId: attribute(element, "executionId"),
|
|
testId: attribute(element, "testId"),
|
|
testListName: testLists.get(testListId) ?? "",
|
|
dataRowInfo: attribute(element, "dataRowInfo"),
|
|
message: elementText(firstElement(errorInfo, "Message")),
|
|
stackTrace: elementText(firstElement(errorInfo, "StackTrace")),
|
|
stdout: elementText(firstElement(output, "StdOut")),
|
|
stderr: elementText(firstElement(output, "StdErr")),
|
|
debugTrace: elementText(firstElement(output, "DebugTrace")),
|
|
resultFiles,
|
|
properties,
|
|
};
|
|
};
|
|
|
|
const visitResults = (parent, depth = 0) => {
|
|
for (const element of parent?.children ?? []) {
|
|
if (!isResultElement(element)) continue;
|
|
results.push(readResult(element, depth));
|
|
const innerResults = directChild(element, "InnerResults");
|
|
if (innerResults) visitResults(innerResults, depth + 1);
|
|
}
|
|
};
|
|
visitResults(resultsElement);
|
|
|
|
const countersElement = firstElement(root, "Counters");
|
|
const counters = {};
|
|
for (const counterAttribute of countersElement?.attributes ?? []) {
|
|
const number = Number(counterAttribute.value);
|
|
if (Number.isFinite(number) && number >= 0) {
|
|
counters[counterAttribute.name] = number;
|
|
}
|
|
}
|
|
|
|
const resultCounts = results.reduce(
|
|
(counts, result) => {
|
|
counts[result.category] += 1;
|
|
return counts;
|
|
},
|
|
{ passed: 0, failed: 0, skipped: 0, other: 0 },
|
|
);
|
|
const sumCounters = (names, fallback) => {
|
|
const values = names
|
|
.filter((name) => Number.isFinite(counters[name]))
|
|
.map((name) => counters[name]);
|
|
return values.length > 0
|
|
? values.reduce((total, number) => total + number, 0)
|
|
: fallback;
|
|
};
|
|
|
|
const times = firstElement(root, "Times");
|
|
const startTime = attribute(times, "start");
|
|
const finishTime = attribute(times, "finish");
|
|
const startTimestamp = new Date(startTime).getTime();
|
|
const finishTimestamp = new Date(finishTime).getTime();
|
|
const durationMs =
|
|
Number.isFinite(startTimestamp) &&
|
|
Number.isFinite(finishTimestamp) &&
|
|
finishTimestamp >= startTimestamp
|
|
? finishTimestamp - startTimestamp
|
|
: null;
|
|
const summaryElement = firstElement(root, "ResultSummary");
|
|
|
|
return {
|
|
name: attribute(root, "name") || "Test run",
|
|
runId: attribute(root, "id"),
|
|
user: attribute(root, "runUser"),
|
|
computerName: attribute(root, "computerName"),
|
|
outcome: attribute(summaryElement, "outcome") || "Unknown",
|
|
startTime,
|
|
finishTime,
|
|
durationMs,
|
|
resultCounts,
|
|
summaryCounts: {
|
|
total: Number.isFinite(counters.total) ? counters.total : results.length,
|
|
passed: sumCounters(["passed"], resultCounts.passed),
|
|
failed: sumCounters(
|
|
[
|
|
"failed",
|
|
"error",
|
|
"timeout",
|
|
"aborted",
|
|
"passedButRunAborted",
|
|
"disconnected",
|
|
],
|
|
resultCounts.failed,
|
|
),
|
|
skipped: sumCounters(
|
|
["notExecuted", "notRunnable", "inconclusive", "pending"],
|
|
resultCounts.skipped,
|
|
),
|
|
},
|
|
results,
|
|
};
|
|
}
|
|
|
|
function renderMetadataItem(label, value, { mono = false } = {}) {
|
|
if (!value) return "";
|
|
return `
|
|
<div class="trx-meta-item">
|
|
<dt>${escapeHtml(label)}</dt>
|
|
<dd${mono ? ' class="mono"' : ""}>${escapeHtml(value)}</dd>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function truncateDetail(value) {
|
|
const text = String(value ?? "");
|
|
if (text.length <= MAX_DETAIL_CHARS) {
|
|
return { text, truncated: false };
|
|
}
|
|
return {
|
|
text: text.slice(0, MAX_DETAIL_CHARS),
|
|
truncated: true,
|
|
};
|
|
}
|
|
|
|
function renderDetailBlock(label, value) {
|
|
if (!value) return "";
|
|
const detail = truncateDetail(value);
|
|
return `
|
|
<section class="trx-output-block">
|
|
<h4>${escapeHtml(label)}</h4>
|
|
<pre>${escapeHtml(detail.text)}</pre>
|
|
${
|
|
detail.truncated
|
|
? `<p class="trx-truncation-note">Output truncated after ${MAX_DETAIL_CHARS.toLocaleString()} characters. View the raw XML for the full value.</p>`
|
|
: ""
|
|
}
|
|
</section>
|
|
`;
|
|
}
|
|
|
|
function renderResult(result, open = false) {
|
|
const secondaryParts = [];
|
|
if (result.className) secondaryParts.push(result.className);
|
|
if (result.dataRowInfo) secondaryParts.push(`Data row ${result.dataRowInfo}`);
|
|
if (result.category === "failed" && result.message) {
|
|
secondaryParts.push(
|
|
result.message.length > 180
|
|
? `${result.message.slice(0, 179)}...`
|
|
: result.message,
|
|
);
|
|
}
|
|
const searchValue = [
|
|
result.testName,
|
|
result.className,
|
|
result.methodName,
|
|
result.outcome,
|
|
result.message,
|
|
]
|
|
.filter(Boolean)
|
|
.join(" ")
|
|
.toLocaleLowerCase()
|
|
.slice(0, 2_000);
|
|
const resultDuration = formatDuration(result.durationMs);
|
|
const metadata = [
|
|
renderMetadataItem("Class", result.className, { mono: true }),
|
|
renderMetadataItem("Method", result.methodName, { mono: true }),
|
|
renderMetadataItem("Duration", resultDuration),
|
|
renderMetadataItem("Computer", result.computerName),
|
|
renderMetadataItem(
|
|
"Started",
|
|
result.startTime ? formatDate(result.startTime) : "",
|
|
),
|
|
renderMetadataItem(
|
|
"Finished",
|
|
result.endTime ? formatDate(result.endTime) : "",
|
|
),
|
|
renderMetadataItem("Test list", result.testListName),
|
|
renderMetadataItem("Adapter", result.adapterTypeName, { mono: true }),
|
|
renderMetadataItem("Code base", result.codeBase, { mono: true }),
|
|
renderMetadataItem("Test ID", result.testId, { mono: true }),
|
|
renderMetadataItem("Execution ID", result.executionId, { mono: true }),
|
|
].join("");
|
|
const resultFiles =
|
|
result.resultFiles.length > 0
|
|
? `
|
|
<section class="trx-result-files">
|
|
<h4>Result files</h4>
|
|
<ul>${result.resultFiles
|
|
.map((path) => `<li class="mono">${escapeHtml(path)}</li>`)
|
|
.join("")}</ul>
|
|
</section>
|
|
`
|
|
: "";
|
|
const properties =
|
|
result.properties.length > 0
|
|
? `
|
|
<section class="trx-properties">
|
|
<h4>Properties</h4>
|
|
<dl>${result.properties
|
|
.map(
|
|
({ key, value }) => `
|
|
<div>
|
|
<dt>${escapeHtml(key)}</dt>
|
|
<dd>${escapeHtml(value)}</dd>
|
|
</div>
|
|
`,
|
|
)
|
|
.join("")}</dl>
|
|
</section>
|
|
`
|
|
: "";
|
|
const details = [
|
|
metadata ? `<dl class="trx-result-metadata">${metadata}</dl>` : "",
|
|
renderDetailBlock("Error message", result.message),
|
|
renderDetailBlock("Stack trace", result.stackTrace),
|
|
renderDetailBlock("Standard output", result.stdout),
|
|
renderDetailBlock("Standard error", result.stderr),
|
|
renderDetailBlock("Debug trace", result.debugTrace),
|
|
resultFiles,
|
|
properties,
|
|
].join("");
|
|
|
|
return `
|
|
<details
|
|
class="trx-result trx-outcome-${result.category}"
|
|
data-trx-result
|
|
data-trx-category="${result.category}"
|
|
data-trx-search="${escapeHtml(searchValue)}"
|
|
style="--trx-depth: ${Math.min(result.depth, 4)}"
|
|
${open ? "open" : ""}
|
|
>
|
|
<summary>
|
|
<span class="trx-result-chevron">${icon("chevron-right")}</span>
|
|
<span class="trx-result-status">${icon(outcomeIcon(result.category))}</span>
|
|
<span class="trx-result-copy">
|
|
<span class="trx-result-name">${escapeHtml(result.testName)}</span>
|
|
${
|
|
secondaryParts.length > 0
|
|
? `<span class="trx-result-secondary">${escapeHtml(secondaryParts.join(" | "))}</span>`
|
|
: ""
|
|
}
|
|
</span>
|
|
<span class="trx-result-duration">${escapeHtml(resultDuration)}</span>
|
|
<span class="trx-outcome-badge trx-outcome-${result.category}">${escapeHtml(result.outcome)}</span>
|
|
</summary>
|
|
<div class="trx-result-details">
|
|
${details || '<p class="trx-detail-empty">No additional details were recorded for this result.</p>'}
|
|
</div>
|
|
</details>
|
|
`;
|
|
}
|
|
|
|
function renderMetric(label, value, category, iconName) {
|
|
return `
|
|
<div class="trx-metric trx-metric-${category}">
|
|
<span class="trx-metric-icon">${icon(iconName)}</span>
|
|
<span class="trx-metric-value">${Number(value).toLocaleString()}</span>
|
|
<span class="trx-metric-label">${escapeHtml(label)}</span>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function renderFilter(label, category, count, pressed = false) {
|
|
return `
|
|
<button
|
|
class="trx-filter"
|
|
type="button"
|
|
data-trx-filter="${category}"
|
|
aria-pressed="${pressed}"
|
|
>
|
|
${escapeHtml(label)}
|
|
<span class="Counter Counter--secondary">${Number(count).toLocaleString()}</span>
|
|
</button>
|
|
`;
|
|
}
|
|
|
|
function renderParseError(source, message, highlightXml) {
|
|
const highlighted =
|
|
source.length <= MAX_HIGHLIGHTED_RAW_CHARS
|
|
? highlightXml(source)
|
|
: escapeHtml(source);
|
|
return `
|
|
<div class="trx-preview">
|
|
<section class="trx-error" role="alert">
|
|
<span class="trx-error-icon">${icon("x-circle")}</span>
|
|
<div>
|
|
<h2>Unable to parse test results</h2>
|
|
<p>${escapeHtml(message)}</p>
|
|
</div>
|
|
</section>
|
|
<section class="trx-raw-panel">
|
|
<div class="trx-section-heading">
|
|
<div>
|
|
<span class="trx-eyebrow">Fallback</span>
|
|
<h3>Raw XML</h3>
|
|
</div>
|
|
</div>
|
|
<pre class="text-preview syntax-code syntax-xml trx-raw-source"><code>${highlighted}</code></pre>
|
|
</section>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function render(value, preview, helpers = {}) {
|
|
const source = String(value ?? "");
|
|
const highlightXml =
|
|
typeof helpers.highlightXml === "function"
|
|
? helpers.highlightXml
|
|
: escapeHtml;
|
|
let report;
|
|
try {
|
|
report = parseReport(source);
|
|
} catch (error) {
|
|
preview.innerHTML = renderParseError(
|
|
source,
|
|
error instanceof Error ? error.message : "The TRX document is invalid.",
|
|
highlightXml,
|
|
);
|
|
return;
|
|
}
|
|
|
|
const categoryOrder = { failed: 0, other: 1, skipped: 2, passed: 3 };
|
|
const renderedResults = [...report.results]
|
|
.map((result, index) => ({ result, index }))
|
|
.sort(
|
|
(left, right) =>
|
|
categoryOrder[left.result.category] -
|
|
categoryOrder[right.result.category] || left.index - right.index,
|
|
)
|
|
.slice(0, MAX_RENDERED_RESULTS)
|
|
.map(({ result }) => result);
|
|
const runOutcome = summarizedRunOutcome(report);
|
|
const outcome = outcomeCategory(runOutcome);
|
|
const limited = renderedResults.length < report.results.length;
|
|
const runDuration = formatDuration(report.durationMs);
|
|
const runMetadata = [
|
|
renderMetadataItem(
|
|
"Started",
|
|
report.startTime ? formatDate(report.startTime) : "",
|
|
),
|
|
renderMetadataItem(
|
|
"Finished",
|
|
report.finishTime ? formatDate(report.finishTime) : "",
|
|
),
|
|
renderMetadataItem("Duration", runDuration),
|
|
renderMetadataItem("Run by", report.user),
|
|
renderMetadataItem("Computer", report.computerName),
|
|
renderMetadataItem("Run ID", report.runId, { mono: true }),
|
|
].join("");
|
|
|
|
preview.innerHTML = `
|
|
<div
|
|
class="trx-preview"
|
|
data-trx-total="${report.results.length}"
|
|
data-trx-rendered="${renderedResults.length}"
|
|
>
|
|
<div data-trx-panel="structured">
|
|
<section class="trx-overview">
|
|
<div class="trx-run-heading">
|
|
<div>
|
|
<span class="trx-eyebrow">Test run</span>
|
|
<h2>${escapeHtml(report.name)}</h2>
|
|
</div>
|
|
<span class="trx-run-outcome trx-outcome-${outcome}">
|
|
${icon(outcomeIcon(outcome))}
|
|
${escapeHtml(runOutcome)}
|
|
</span>
|
|
</div>
|
|
<div class="trx-metrics">
|
|
${renderMetric("Total", report.summaryCounts.total, "all", "workflow")}
|
|
${renderMetric("Passed", report.summaryCounts.passed, "passed", "check-circle")}
|
|
${renderMetric("Failed", report.summaryCounts.failed, "failed", "x-circle")}
|
|
${renderMetric("Skipped", report.summaryCounts.skipped, "skipped", "clock")}
|
|
</div>
|
|
${runMetadata ? `<dl class="trx-run-metadata">${runMetadata}</dl>` : ""}
|
|
</section>
|
|
|
|
<section class="trx-results-panel">
|
|
<div class="trx-section-heading">
|
|
<div>
|
|
<span class="trx-eyebrow">Details</span>
|
|
<h3>Test results</h3>
|
|
<p data-trx-result-count aria-live="polite"></p>
|
|
</div>
|
|
<button class="app-button app-button-small" type="button" data-trx-view="raw">
|
|
${icon("code")}
|
|
View raw XML
|
|
</button>
|
|
</div>
|
|
<div class="trx-controls">
|
|
<label class="trx-search">
|
|
${icon("search")}
|
|
<span class="sr-only">Search test results</span>
|
|
<input
|
|
class="form-control"
|
|
type="search"
|
|
placeholder="Search tests..."
|
|
autocomplete="off"
|
|
data-trx-search-input
|
|
/>
|
|
</label>
|
|
<div class="trx-filters" role="group" aria-label="Filter test outcomes">
|
|
${renderFilter("All", "all", report.results.length, true)}
|
|
${renderFilter("Passed", "passed", report.resultCounts.passed)}
|
|
${renderFilter("Failed", "failed", report.resultCounts.failed)}
|
|
${renderFilter("Skipped", "skipped", report.resultCounts.skipped)}
|
|
${renderFilter("Other", "other", report.resultCounts.other)}
|
|
</div>
|
|
</div>
|
|
${
|
|
limited
|
|
? `
|
|
<div class="trx-limit-note">
|
|
${icon("eye")}
|
|
Showing the first ${renderedResults.length.toLocaleString()} of ${report.results.length.toLocaleString()} result records to keep the preview responsive. Raw XML contains the complete report.
|
|
</div>
|
|
`
|
|
: ""
|
|
}
|
|
<div class="trx-result-list">
|
|
${renderedResults
|
|
.map((result, index) =>
|
|
renderResult(
|
|
result,
|
|
index === 0 && result.category === "failed",
|
|
),
|
|
)
|
|
.join("")}
|
|
</div>
|
|
<div class="trx-no-results" data-trx-no-results hidden>
|
|
${icon("search")}
|
|
<h4>No matching tests</h4>
|
|
<p>Try another search or outcome filter.</p>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
|
|
<section class="trx-raw-panel" data-trx-panel="raw" hidden>
|
|
<div class="trx-section-heading">
|
|
<div>
|
|
<span class="trx-eyebrow">Source</span>
|
|
<h3>Raw XML</h3>
|
|
</div>
|
|
<button class="app-button app-button-small" type="button" data-trx-view="structured">
|
|
${icon("chevron-left")}
|
|
Back to results
|
|
</button>
|
|
</div>
|
|
<div class="trx-limit-note" data-trx-raw-note hidden>
|
|
${icon("eye")}
|
|
Syntax highlighting is disabled for large reports to keep the source view responsive.
|
|
</div>
|
|
<pre class="text-preview syntax-code syntax-xml trx-raw-source"><code data-trx-raw-code></code></pre>
|
|
</section>
|
|
</div>
|
|
`;
|
|
const trxPreview = preview.querySelector(".trx-preview");
|
|
rawSources.set(trxPreview, { source, highlightXml });
|
|
applyFilters(trxPreview);
|
|
}
|
|
|
|
function showView(preview, viewName) {
|
|
if (!preview) return;
|
|
if (viewName === "raw") {
|
|
const rawSource = rawSources.get(preview);
|
|
const code = preview.querySelector("[data-trx-raw-code]");
|
|
if (rawSource && code && code.dataset.trxLoaded !== "true") {
|
|
if (rawSource.source.length > MAX_HIGHLIGHTED_RAW_CHARS) {
|
|
code.textContent = rawSource.source;
|
|
const note = preview.querySelector("[data-trx-raw-note]");
|
|
if (note) note.hidden = false;
|
|
} else {
|
|
code.innerHTML = rawSource.highlightXml(rawSource.source);
|
|
}
|
|
code.dataset.trxLoaded = "true";
|
|
}
|
|
}
|
|
for (const panel of preview.querySelectorAll("[data-trx-panel]")) {
|
|
panel.hidden = panel.dataset.trxPanel !== viewName;
|
|
}
|
|
}
|
|
|
|
function applyFilters(preview) {
|
|
if (!preview) return;
|
|
const query = String(
|
|
preview.querySelector("[data-trx-search-input]")?.value ?? "",
|
|
)
|
|
.trim()
|
|
.toLocaleLowerCase();
|
|
const terms = query.split(/\s+/).filter(Boolean);
|
|
const category =
|
|
preview.querySelector('[data-trx-filter][aria-pressed="true"]')?.dataset
|
|
.trxFilter ?? "all";
|
|
const results = [...preview.querySelectorAll("[data-trx-result]")];
|
|
let visible = 0;
|
|
for (const result of results) {
|
|
const matchesCategory =
|
|
category === "all" || result.dataset.trxCategory === category;
|
|
const searchValue = result.dataset.trxSearch ?? "";
|
|
const matchesQuery = terms.every((term) => searchValue.includes(term));
|
|
result.hidden = !(matchesCategory && matchesQuery);
|
|
if (!result.hidden) visible += 1;
|
|
}
|
|
|
|
const total = Number(preview.dataset.trxTotal) || results.length;
|
|
const rendered = Number(preview.dataset.trxRendered) || results.length;
|
|
const count = preview.querySelector("[data-trx-result-count]");
|
|
if (count) {
|
|
count.textContent =
|
|
total > rendered
|
|
? `${visible.toLocaleString()} matching in the first ${rendered.toLocaleString()} of ${total.toLocaleString()} results`
|
|
: `${visible.toLocaleString()} of ${total.toLocaleString()} results`;
|
|
}
|
|
const noResults = preview.querySelector("[data-trx-no-results]");
|
|
if (noResults) noResults.hidden = visible > 0;
|
|
}
|
|
|
|
globalThis.TrxPreview = Object.freeze({
|
|
applyFilters,
|
|
parse: parseReport,
|
|
render,
|
|
showView,
|
|
});
|
|
})();
|