blob: 84d62418239bb5e5e0f5bcb28f051c565091e4d5 [file]
// 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 {Artifact, ResultDbV1Client} from './resultdb-client';
import {LRUMemoryCacheObject} from './lru-memory-cache-object';
import {
createArtifactLink,
createTestResultSummary,
} from './checks-fetcher';
import {Link} from '@gerritcodereview/typescript-api/checks';
// The maximum number of bytes to fetch when fetching text artifacts.
const MAX_ARTIFACT_SIZE = 50000; // 50 KB
// 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^="${METADATA_ALT}"] {
font-family: sans-serif;
font-size: 115%;
}
a[alt="${DEFAULT_METADATA_ALT}"] {
color: var(--primary-text-color);
}
a[alt="${GREEN_METADATA_ALT}"] {
color: var(--positive-green-text-color);
}
a[alt="${RED_METADATA_ALT}"] {
color: var(--negative-red-text-color);
}`;
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;
}
ul {
margin: 0;
}
a {
color: var(--link-color);
}`;
/**
* 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 {host} = element.getRootNode();
const messageElement = host.querySelector('.message');
if (messageElement) {
host.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;
resArtifactLinks.push(
...resArtifacts.map(a => createArtifactLink(a, false, runNumber)));
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)}</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 invArtifactLinks = [
...new Map(allInvArtifacts.map(a => [a.name, a])).values(),
].map(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 />' +
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.
md = md.replaceAll(
/(\*\*|__)(([\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) => {
return `<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) => {
return p1;
});
}