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 {