static generateReportCSV(lhr) {
    // To keep things "official" we follow the CSV specification (RFC4180)
    // The document describes how to deal with escaping commas and quotes etc.
    const CRLF = '\r\n';
    const separator = ',';
    /** @param {string} value @return {string} */
    const escape = value => `"${value.replace(/"/g, '""')}"`;
    /** @param {ReadonlyArray<string | number | null>} row @return {string[]} */
    const rowFormatter = row => row.map(value => {
      if (value === null) return 'null';
      return value.toString();
    }).map(escape);

    const rows = [];
    const topLevelKeys = /** @type {const} */(
      ['requestedUrl', 'finalDisplayedUrl', 'fetchTime', 'gatherMode']);

    // First we have metadata about the LHR.
    rows.push(rowFormatter(topLevelKeys));
    rows.push(rowFormatter(topLevelKeys.map(key => lhr[key] ?? null)));

    // Some spacing.
    rows.push([]);

    // Categories.
    rows.push(['category', 'score']);
    for (const category of Object.values(lhr.categories)) {
      rows.push(rowFormatter([
        category.id,
        category.score,
      ]));
    }

    rows.push([]);

    // Audits.
    rows.push(['category', 'audit', 'score', 'displayValue', 'description']);
    for (const category of Object.values(lhr.categories)) {
      for (const auditRef of category.auditRefs) {
        const audit = lhr.audits[auditRef.id];
        if (!audit) continue;

        rows.push(rowFormatter([
          category.id,
          auditRef.id,
          audit.score,
          audit.displayValue || '',
          audit.description,
        ]));
      }
    }

    return rows
      .map(row => row.join(separator))
      .join(CRLF);
  }
Converts the results to a CSV formatted string
Each row describes the result of 1 audit with
 - the name of the category the audit belongs to
 - the name of the audit
 - a description of the audit
 - the score type that is used for the audit
 - the score value of the audit

@param {LHResult} lhr
@return {string}