From cf0ad6a47b44c0313334de099bcb59c390e31e86 Mon Sep 17 00:00:00 2001 From: Joan Leon Date: Sat, 14 Nov 2026 18:21:35 +0100 Subject: [PATCH] fix(visualizer): export an audit with a rating and no value as an audit Copy as Markdown treated every result with a rating as a metric, so an audit such as Script-Loading came out as a heading with "undefined" and none of its issues or items, and a result whose unit is a score threw before copying anything. The export picks its format with detectRenderer, as the page picks its view. detectRenderer moves to its own module so that the export can import it. A result the page shows as JSON is exported as JSON instead of "No issues found". The tests export the 114 result fixtures and fail on any "undefined" or "NaN". --- CHANGELOG.md | 3 ++ .../SnippetVisualizer/AuditRenderer.test.jsx | 3 +- .../SnippetVisualizer/detectRenderer.js | 7 ++++ .../SnippetVisualizer/exportMarkdown.js | 13 +++++-- .../SnippetVisualizer/exportMarkdown.test.js | 38 +++++++++++++++++++ components/SnippetVisualizer/index.jsx | 9 +---- 6 files changed, 61 insertions(+), 12 deletions(-) create mode 100644 components/SnippetVisualizer/detectRenderer.js create mode 100644 components/SnippetVisualizer/exportMarkdown.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 855a342f..dad661d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,9 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Changed - The prompt that the Copy prompt button of a snippet page copies installs and runs Playwright 1.65, the version the CLI is tested with, which brings Chromium 159. That Chromium does not run `unload` handlers unless the page asks for them with `Permissions-Policy: unload=(self)`, so on a page that does not, Back-Forward-Cache no longer finds an `unload` handler to report. +### Fixed +- Copy as Markdown of the [visualizer](https://webperf-snippets.net/visualizer) exported an audit that has a rating and no value, such as Script-Loading, as a metric: a heading with `undefined` and none of its issues or items, and nothing at all when its unit is a score. The export picks its format as the page picks its view, so the audit comes with its issues and its items, and a result the page shows as JSON is exported as JSON instead of "No issues found". + --- ## [2.6.3] — 2026-11-08 diff --git a/components/SnippetVisualizer/AuditRenderer.test.jsx b/components/SnippetVisualizer/AuditRenderer.test.jsx index 10567a33..2109358a 100644 --- a/components/SnippetVisualizer/AuditRenderer.test.jsx +++ b/components/SnippetVisualizer/AuditRenderer.test.jsx @@ -2,7 +2,8 @@ import { describe, it, expect } from "vitest"; import { readFileSync } from "node:fs"; import { renderToStaticMarkup } from "react-dom/server"; import { AuditRenderer } from "./AuditRenderer"; -import { SnippetVisualizer, detectRenderer } from "./index"; +import { SnippetVisualizer } from "./index"; +import { detectRenderer } from "./detectRenderer"; const fixture = (name) => JSON.parse(readFileSync(new URL(`../../scripts/fixtures/results/seeded-${name}.json`, import.meta.url), "utf8")); diff --git a/components/SnippetVisualizer/detectRenderer.js b/components/SnippetVisualizer/detectRenderer.js new file mode 100644 index 00000000..315a10cc --- /dev/null +++ b/components/SnippetVisualizer/detectRenderer.js @@ -0,0 +1,7 @@ +export function detectRenderer(result) { + if (result.script === "Fonts-Preloaded-Loaded-and-used-above-the-fold") return "fonts"; + // A metric has a value to rate; an audit such as Script-Loading has a rating and no value + if (result.rating != null && typeof result.value === "number") return "cwv"; + if (Array.isArray(result.issues) || Array.isArray(result.items)) return "audit"; + return "raw"; +} diff --git a/components/SnippetVisualizer/exportMarkdown.js b/components/SnippetVisualizer/exportMarkdown.js index ee3d6321..6836668d 100644 --- a/components/SnippetVisualizer/exportMarkdown.js +++ b/components/SnippetVisualizer/exportMarkdown.js @@ -1,3 +1,5 @@ +import { detectRenderer } from "./detectRenderer"; + const RATING_ICON = { good: "✅", "needs-improvement": "⚠️", @@ -100,8 +102,13 @@ function auditMarkdown(result) { return lines.join("\n"); } +function rawMarkdown(result) { + return [`## ${result.script ?? "Result"}`, "", "```json", JSON.stringify(result, null, 2), "```"].join("\n"); +} + +const MARKDOWN = { fonts: fontsMarkdown, cwv: cwvMarkdown, audit: auditMarkdown, raw: rawMarkdown }; + +// The same view the page renders, so the export never reads a field the result does not have export function exportMarkdown(result) { - if (result.script === "Fonts-Preloaded-Loaded-and-used-above-the-fold") return fontsMarkdown(result); - if (result.rating != null) return cwvMarkdown(result); - return auditMarkdown(result); + return MARKDOWN[detectRenderer(result)](result); } diff --git a/components/SnippetVisualizer/exportMarkdown.test.js b/components/SnippetVisualizer/exportMarkdown.test.js new file mode 100644 index 00000000..6a115d33 --- /dev/null +++ b/components/SnippetVisualizer/exportMarkdown.test.js @@ -0,0 +1,38 @@ +import { describe, it, expect } from "vitest"; +import { readFileSync, readdirSync } from "node:fs"; +import { exportMarkdown } from "./exportMarkdown"; + +const dir = new URL("../../scripts/fixtures/results/", import.meta.url); +const read = (file) => JSON.parse(readFileSync(new URL(file, dir), "utf8")); +const fixture = (name) => read(`seeded-${name}.json`); + +describe("exportMarkdown", () => { + it("exports an audit that has a rating and no value as an audit", () => { + const md = exportMarkdown(fixture("Script-Loading")); + expect(md).not.toContain("undefined"); + expect(md).toContain("### Items (3)"); + expect(md).toContain("third.js"); + }); + + it("exports a score that has a rating and no value without failing", () => { + const md = exportMarkdown({ script: "Demo", status: "ok", unit: "score", rating: "good", issues: [] }); + expect(md).toContain("## Demo"); + expect(md).not.toContain("undefined"); + }); + + it("exports a metric with its value and its rating", () => { + // The icon of the rating sits between the value and the rating + expect(exportMarkdown(fixture("CLS"))).toMatch(/^## CLS — 0\.0638 \S+ good$/); + }); + + it("exports a result with neither issues nor items as its JSON, as the page shows it", () => { + const result = { script: "Demo", status: "ok", details: { count: 2 } }; + const md = exportMarkdown(result); + expect(md).toContain("```json\n" + JSON.stringify(result, null, 2) + "\n```"); + expect(md).not.toContain("No issues found"); + }); + + it.each(readdirSync(dir).filter((file) => file.endsWith(".json")))("exports %s with every value defined", (file) => { + expect(exportMarkdown(read(file))).not.toMatch(/\bundefined\b|\bNaN\b/); + }); +}); diff --git a/components/SnippetVisualizer/index.jsx b/components/SnippetVisualizer/index.jsx index 57464c2a..3f0d2193 100644 --- a/components/SnippetVisualizer/index.jsx +++ b/components/SnippetVisualizer/index.jsx @@ -6,6 +6,7 @@ import { FontsRenderer } from "./FontsRenderer"; import { AuditRenderer } from "./AuditRenderer"; import { Icon } from "../Icon"; import { exportMarkdown } from "./exportMarkdown"; +import { detectRenderer } from "./detectRenderer"; const PLACEHOLDER = `Paste the return value of any snippet here. @@ -14,14 +15,6 @@ and choose "Copy object", then paste it here. Or run: JSON.stringify(result) and paste the output.`; -export function detectRenderer(result) { - if (result.script === "Fonts-Preloaded-Loaded-and-used-above-the-fold") return "fonts"; - // A metric has a value to rate; an audit such as Script-Loading has a rating and no value - if (result.rating != null && typeof result.value === "number") return "cwv"; - if (Array.isArray(result.issues) || Array.isArray(result.items)) return "audit"; - return "raw"; -} - function parseInput(text) { if (!text.trim()) return null; try {