blob: 3a03fb2fec9124e932bce5b44aec5138e03961b9 [file] [log] [blame]
// Copyright 2020 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import {sanitize} from './sanitizer';
import {DATA_SYMBOL} from './checks-fetcher';
import {createTestResultSummary} from './checks-fetcher';
import {LRUMemoryCacheObject} from './lru-memory-cache-object';
import {Artifact, ResultDbV1Client} from './resultdb-client';
import {MAX_ARTIFACT_SIZE} from './resultdb-utils';
import {createArtifactLink} from './resultdb-utils';
import {Link} from '@gerritcodereview/typescript-api/checks';
// Stores the last 256 fetch promises.
const promiseCache = new LRUMemoryCacheObject(256);
// These tags are for labeling and styling metadata separately from result data.
// It used to set the alt attribute of <a> elements because sanitize(html):
// * Is necessary when setting innerHTML.
// * Preserves only a limited set of allowlisted tags.
// * The only attribute preserved across these tags is alt of <a> elements.
// The class attribute (or any other alt) cannot be used since it is removed
// upon sanitization.
const METADATA_ALT = 'chromeChecksMetadataTag';
const DEFAULT_METADATA_ALT = METADATA_ALT + 'Default';
const GREEN_METADATA_ALT = METADATA_ALT + 'Green';
const RED_METADATA_ALT = METADATA_ALT + 'Red';
// Styling for the check-result-expanded element. Explicitly style <pre>
// elements otherwise text wrapped in <pre> may overflow.
const EXPANDED_TEST_ROW_STYLE = `
.expanded-row, pre {
word-break: break-all;
white-space: pre-wrap;
font-family: monospace;
font-size: 12px;
}
a {
color: var(--link-color);
}
a[alt^="chromeChecksMetadataTag"] {
font-family: sans-serif;
font-size: 115%;
}
a[alt="chromeChecksMetadataTagDefault"] {
color: var(--primary-text-color);
}
a[alt="chromeChecksMetadataTagGreen"] {
color: var(--positive-green-text-color);
}
a[alt="chromeChecksMetadataTagRed"] {
color: var(--negative-red-text-color);
}
/* Gerrit CSS Resets */
ul {
list-style: initial !important;
margin-left: 1em !important;
}
h3 {
font-weight: bold !important;
font-size: 1.17em !important;
}
`;
const EXPANDED_BUILD_ROW_STYLE = `
.expanded-row, pre {
word-break: break-all;
white-space: pre-wrap;
}
.expanded-row {
line-height: 1.8;
}
pre {
margin-block: 10px;
font-size: 12px;
}
h1, h2, h3, h4, h5, h6, a, strong {
margin-block: 10px;
}
a {
color: var(--link-color);
}
/* Gerrit CSS Resets */
strong {
font-weight: bold !important;
}
ul {
list-style: initial !important;
margin-left: 1em !important;
}
`;
/**
* Called when the check-result-expanded element is added to the page.
*
* @param element: the check-result-expanded element.
*/
export async function installChecksResult(element: any) {
if (!element?.result?.message) return;
// Remove message div with raw message string.
const elShadowRoot = element.getRootNode();
const container = elShadowRoot.querySelector('gr-endpoint-decorator');
const messageElement = elShadowRoot.querySelector('.message');
if (container && messageElement) {
container.removeChild(messageElement);
}
const data = element.result[DATA_SYMBOL];
if (!data) {
formatInnerHTML(
element,
parseMarkdown(element.result.message),
EXPANDED_BUILD_ROW_STYLE
);
return;
}
formatInnerHTML(
element,
`<a alt="${DEFAULT_METADATA_ALT}">Loading...</a>`,
EXPANDED_TEST_ROW_STYLE
);
const {run: checkRun, result: checkResult} = element;
const allInvArtifacts: Artifact[] = [];
const resArtifactLinks: Link[] = [];
const htmlPromises = data.testResults.map(async (r: any, i: number) => {
const testResult = r.result;
const {summaryHtml, name} = testResult;
// Fetch artifacts.
let resArtifacts: Artifact[] = [];
let invArtifacts: Artifact[] = [];
try {
const client = new ResultDbV1Client(data.rdbHost, data.changeId);
({resArtifacts, invArtifacts} = await client.fetchArtifacts(name, true));
} catch (e) {
console.warn('Failed to fetch artifacts', e);
}
// Create links to artifacts.
if (!data.addedArtifactLinks) {
const runNumber = data.testResults.length > 1 ? i + 1 : null;
const artifactLinks = await Promise.all(
resArtifacts.map(async a => createArtifactLink(a, false, runNumber))
);
resArtifactLinks.push(...artifactLinks);
allInvArtifacts.push(...invArtifacts);
}
const processedHtml = await replaceTextArtifacts(
summaryHtml,
resArtifacts,
invArtifacts
);
const resultColor = testResult.expected
? GREEN_METADATA_ALT
: RED_METADATA_ALT;
return (
`<li><h3><a alt="${DEFAULT_METADATA_ALT}">Run #${i + 1}: </a>` +
`<a alt="${resultColor}">${createTestResultSummary(
testResult,
data.variant,
/* includeSnapshotlink= */ true
)}</a>` +
`</h3>${processedHtml}</li>`
);
});
// Because all promises in html are fulfilled, Promise.all is safe to use.
const html = await Promise.all(htmlPromises);
// Update CheckResult links if not already added.
if (!data.addedArtifactLinks) {
// Result artifacts are named 'Run #${runNumber}: ${tooltip}'.
// @ts-ignore
resArtifactLinks.sort((a, b) => a.tooltip.localeCompare(b.tooltip));
// Dedupe invocation-level artifacts.
const invLevelArtifacts = [
...new Map(allInvArtifacts.map(a => [a.name, a])).values(),
];
const invArtifactLinks = await Promise.all(
invLevelArtifacts.map(async a => createArtifactLink(a, true, null))
);
const links = [
...(checkResult.links || []),
...resArtifactLinks,
...invArtifactLinks,
];
checkResult.links = Array.from(
new Map(links.map(l => [l.url, l])).values()
);
// Set addedArtifactLinks = true in case users re-open the result row.
data.addedArtifactLinks = true;
data.plugin.checks().updateResult(checkRun, checkResult);
}
const def = Object.entries(data.variant?.variant?.def || {})
.map(([k, v]) => `${k}: ${v}`)
.join(', ');
const meta = def
? `<a alt="${DEFAULT_METADATA_ALT}"><em>${def}</em></a>`
: '';
// Show any exonerations.
let exonerations = '';
if (data.testExonerations?.length > 0) {
exonerations =
'<br /><br />' +
String(
data.testExonerations.map((e: any) => e.explanationHtml).join('<br />')
);
}
// Use <ul> instead of <ol> since results are labeled 'Run #x'.
formatInnerHTML(
element,
`${meta}${exonerations}<ul>${html.join('')}</ul>`,
EXPANDED_TEST_ROW_STYLE
);
}
/**
* Replaces all <text-artifact/> elements in html with their fetched text
* artifacts. Only elements whose artifact-ids are in artifacts are replaced.
* Promises returned are always fulfilled, even when fetches fail. A failed
* fetch results in an error message in place of the <text-artifact/>.
*
* Note: Because message is parsed by parseFromString, any self-closing
* <text-artifact /> tags without a fetchable artifact-id will be replaced by an
* opening and closing <text-artifact></text-artifact> tags.
*
* TODO(gavinmak): Refactor this method and sanitizer to accept an
* already-parsed node.
*
* @param html: the CheckResult HTML message.
* @param resArtifacts: the CheckResult's list of result artifacts.
* @param invArtifacts: the CheckResult's list of invocation artifacts.
*/
export async function replaceTextArtifacts(
html: string,
resArtifacts: Artifact[],
invArtifacts: Artifact[]
): Promise<string> {
if (!html) {
return '';
}
const resArtifactMap = new Map(
(resArtifacts || []).map(a => [a.artifactId, a])
);
const invArtifactMap = new Map(
(invArtifacts || []).map(a => [a.artifactId, a])
);
const parser = new DOMParser();
const parsed = parser.parseFromString(html, 'text/html');
const textArtifacts = parsed.body.querySelectorAll(
'text-artifact'
) as unknown as Element[];
await Promise.allSettled(
[...textArtifacts].map(async element => {
const artifactMap = element.hasAttribute('inv-level')
? invArtifactMap
: resArtifactMap;
const id = element.getAttribute('artifact-id') as string;
const {fetchUrl, name, sizeBytes} = artifactMap.get(id)!;
if (!fetchUrl) {
return;
}
if (!sizeBytes) {
element.replaceWith(metadataElement(`${id} artifact is empty`));
return;
}
let nodePromise = promiseCache.read(name);
if (!nodePromise) {
const urlObj = new URL(fetchUrl);
urlObj.searchParams.set('n', String(MAX_ARTIFACT_SIZE));
nodePromise = fetch(urlObj.toString()).then(async res => {
const text = await res.text();
if (!res.ok) {
const message = text || res.statusText;
console.warn(
`Failed to fetch ${id} artifact from ${fetchUrl}. ` +
'ResultDB responded with status code ' +
`${res.status}: ${message}`
);
throw new Error(message);
}
const textNode = document.createTextNode(text);
const nodes: Node[] = [textNode];
if (sizeBytes <= MAX_ARTIFACT_SIZE) {
return nodes;
}
// Link to the artifact if its size > MAX_ARTIFACT_SIZE.
const extraBytes = sizeBytes - MAX_ARTIFACT_SIZE;
nodes.push(
metadataElement(
`\n\n...${extraBytes} more byte${extraBytes > 1 ? 's' : ''}...` +
`\nMore information in ${id} artifact`
)
);
return nodes;
});
promiseCache.write(name, nodePromise);
}
try {
// TODO(gavinmak): Use outerHTML to handle cases where text is wrapped in
// HTML.
const nodes = await nodePromise;
// Replacing without copying the nodes may lead to empty artifacts.
const copy = nodes.map((n: Node) => n.cloneNode(true));
element.replaceWith(...copy);
} catch (e: unknown) {
// Evict the rejected promise from the cache.
if (promiseCache.read(name) === nodePromise) {
promiseCache.delete(name);
}
element.replaceWith(
metadataElement(
`Failed to fetch ${id} artifact: ` +
`${(e as Error).message.trim()}. See console logs.`
)
);
}
})
);
return parsed.body.innerHTML;
}
/**
* Creates a metadata element using message.
*
* TODO(gavinmak): Use outerHTML to dedupe all instances of METADATA_TAG above.
*
* @param message: the metadata message.
*/
function metadataElement(message: string): HTMLElement {
const meta = document.createElement('a');
meta.setAttribute('alt', DEFAULT_METADATA_ALT);
meta.textContent = message;
return meta;
}
/**
* Sets element's innerHTML to the sanitized html and styles the element.
*
* @param element: the check-result-expanded element.
* @param html: the CheckResult HTML message.
*/
function formatInnerHTML(
element: Element,
html: string,
styleString: string
): void {
const style = document.createElement('style');
style.textContent = styleString;
const root = element.getRootNode();
root.insertBefore(style, root.childNodes[0]);
element.className = 'expanded-row';
element.innerHTML = sanitize(html);
}
/**
* Convert a markdown string to HTML.
*
* This is a very simple parser and only supports a limited syntax.
*
* TODO(crbug/1204628): Support _emphasis_.
* TODO(crbug/1204628): Support unbracketed links.
* TODO(gavinmak): Remove this method once crbug/1063398 is fixed.
*
* @param md: the markdown to parse.
*/
export function parseMarkdown(md: string) {
function process(md: string) {
// Create links.
md = md.replaceAll(
/\[((?!\]\()[\s\S]*?)?\]\(([^\)]*)\)/gm,
(_m: any, p1: string, p2: string) => `<a href='${p2}'>${p1 || ''}</a>`
);
// Replace ** and __ strings, but skip if in a link.
md = md.replaceAll(
/(?<!href='[^']+)(\*\*|__)(([\S]((?!\1)[ \t\S])*?)?[\S])\1/gm,
(_m: any, _p1: string, p2: string) => `<strong>${p2}</strong>`
);
// Replace # with <h1> and so on up to <h6>.
md = md.replaceAll(/^(#+) (.*)/gm, (m: any, p1: string, p2: string) => {
if (p1.length > 6) {
return m;
}
return `<h${p1.length}>${p2}</h${p1.length}>`;
});
// Create unordered lists.
md = md.replaceAll(/^(?: *)-(?: *)(.*)/gm, (_m, p1) => `<li>${p1}</li>`);
let inUl = false;
const lines = md.split('\n');
return lines
.map((line, idx) => {
const isListEl = line.match(/<li>.*<\/li>/gm);
if (isListEl && !inUl) {
// The first element of the <ul>.
inUl = true;
return `<ul>${line}`;
}
if (idx === lines.length - 1 && isListEl) {
// The last line.
return `${line}</ul>`;
}
if (line && !isListEl && inUl) {
// The first element outside of the <ul>.
inUl = false;
return `</ul>${line}`;
}
return line;
})
.filter(l => l)
.join('\n');
}
// Parse markdown into code block sections.
md = md
.split('```')
.map((section, idx) => {
// Anything outside code blocks should be processed.
if (idx % 2 === 0) {
return process(section);
}
// Remove everything up until the first newline.
section = section.substring(section.indexOf('\n') + 1, section.length);
return `<pre><code>${section}</code></pre>`;
})
.join('');
// Remove all newlines between tags. This is necessary since the
// "white-space: pre-wrap" style will respect newlines.
return md.replaceAll(/(<\/[^>]+>)(\n+)(?=<[^>]+>)/gm, (_m, p1) => p1);
}