| // 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); |
| } |