From 62fc446d15cbf038a12feb4d36ae97a663904d63 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Fri, 27 Feb 2026 18:21:08 +0200 Subject: flamegraph: add browser rendering parity QA test --- internal/flamegraph/livehtml_browser_test.go | 263 +++++++++++++++++++++++++++ 1 file changed, 263 insertions(+) create mode 100644 internal/flamegraph/livehtml_browser_test.go diff --git a/internal/flamegraph/livehtml_browser_test.go b/internal/flamegraph/livehtml_browser_test.go new file mode 100644 index 0000000..d8f1951 --- /dev/null +++ b/internal/flamegraph/livehtml_browser_test.go @@ -0,0 +1,263 @@ +package flamegraph + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "strings" + "testing" +) + +type jsFrame struct { + Name string `json:"name"` + X float64 `json:"x"` + Y float64 `json:"y"` + W float64 `json:"w"` + H float64 `json:"h"` + Depth int `json:"depth"` +} + +type liveJSResult struct { + Colors map[string]string `json:"colors"` + KnownFrames []jsFrame `json:"knownFrames"` + SVGHTML string `json:"svgHTML"` + ViewBox string `json:"viewBox"` + SingleCount int `json:"singleCount"` + DeepMaxDepth int `json:"deepMaxDepth"` + WideFrameCount int `json:"wideFrameCount"` +} + +func TestLiveHTMLJSRenderingParity(t *testing.T) { + if _, err := exec.LookPath("node"); err != nil { + t.Skip("node not available") + } + + out := runLiveHTMLJSHarness(t) + var got liveJSResult + if err := json.Unmarshal([]byte(out), &got); err != nil { + t.Fatalf("unmarshal node output: %v\nraw:\n%s", err, out) + } + + names := []string{"read", "write", "io_uring_enter", "nested/path"} + for _, name := range names { + want := frameColor(name) + if got.Colors[name] != want { + t.Fatalf("fgFrameColor(%q) = %q, want %q", name, got.Colors[name], want) + } + } + + if len(got.KnownFrames) != 3 { + t.Fatalf("known frame count = %d, want 3", len(got.KnownFrames)) + } + assertFrame(t, got.KnownFrames[0], "A", 0, 96, 720, 15, 1) + assertFrame(t, got.KnownFrames[1], "A1", 0, 80, 720, 15, 2) + assertFrame(t, got.KnownFrames[2], "B", 720, 96, 480, 15, 1) + + if !strings.Contains(got.SVGHTML, ` eps || diff(got.Y, y) > eps || diff(got.W, w) > eps || diff(got.H, h) > eps { + t.Fatalf("frame %q geometry = {x:%f y:%f w:%f h:%f}, want {x:%f y:%f w:%f h:%f}", + got.Name, got.X, got.Y, got.W, got.H, x, y, w, h) + } +} + +func diff(a, b float64) float64 { + if a > b { + return a - b + } + return b - a +} + +func runLiveHTMLJSHarness(t *testing.T) string { + t.Helper() + + script := extractLiveHTMLScript(t) + harness := fmt.Sprintf(` +const vm = require("vm"); +const liveScript = %q; + +function makeElement(id) { + return { + id, + textContent: "", + innerHTML: "", + style: {}, + dataset: {}, + attrs: {}, + classList: { toggle: function(){}, add: function(){}, remove: function(){} }, + addEventListener: function(){}, + setAttribute: function(k, v) { this.attrs[k] = String(v); }, + getAttribute: function(k) { return this.attrs[k] || ""; }, + querySelectorAll: function() { return []; }, + querySelector: function() { return null; } + }; +} + +const elements = {}; +["flamegraph", "status", "btn-pause", "btn-search", "btn-reset-search", "btn-undo-zoom", "btn-reset-zoom"].forEach((id) => { + elements[id] = makeElement(id); +}); +elements["body"] = makeElement("body"); + +global.document = { + body: elements["body"], + getElementById: function(id) { + if (!elements[id]) elements[id] = makeElement(id); + return elements[id]; + }, + addEventListener: function(){}, +}; +global.window = global; +global.prompt = function(){ return ""; }; +global.requestAnimationFrame = function(cb){ cb(); }; +global.EventSource = function() { + this.onmessage = null; + this.onerror = null; +}; +window.addEventListener = function(){}; + +vm.runInThisContext(liveScript); + +const names = ["read", "write", "io_uring_enter", "nested/path"]; +const colors = {}; +for (const n of names) { + colors[n] = fgFrameColor(n); +} + +const knownTree = { + n: "", + v: 0, + t: 10, + c: [ + { n: "A", v: 0, t: 6, c: [{ n: "A1", v: 6, t: 6 }] }, + { n: "B", v: 4, t: 4 } + ] +}; +const maxDepth = fgMaxDepth(knownTree, 0); +const canvasHeight = (liveFlamegraphState.cfg.frameHeight * (maxDepth + 1)) + 80; +const knownFramesRaw = []; +fgBuildFrames(knownTree, knownTree.t, 0, 0, canvasHeight, true, knownFramesRaw, ""); +const knownFrames = knownFramesRaw.map((f) => ({ + name: f.name, + x: Number(f.x.toFixed(3)), + y: Number(f.y.toFixed(3)), + w: Number(f.w.toFixed(3)), + h: Number(f.h.toFixed(3)), + depth: f.depth, +})); + +fgRender(knownTree); +const svgHTML = elements["flamegraph"].innerHTML; +const viewBox = elements["flamegraph"].attrs["viewBox"] || ""; + +const singleTree = { n: "", v: 0, t: 1, c: [{ n: "only", v: 1, t: 1 }] }; +const singleFrames = []; +const singleCanvas = (liveFlamegraphState.cfg.frameHeight * (fgMaxDepth(singleTree, 0) + 1)) + 80; +fgBuildFrames(singleTree, singleTree.t, 0, 0, singleCanvas, true, singleFrames, ""); + +let deepTree = { n: "", v: 0, t: 1, c: [] }; +let cursor = deepTree; +for (let i = 0; i < 55; i++) { + const child = { n: "d" + i, v: i === 54 ? 1 : 0, t: 1, c: [] }; + cursor.c = [child]; + cursor = child; +} +const deepMaxDepth = fgMaxDepth(deepTree, 0); + +const wideChildren = []; +for (let i = 0; i < 1000; i++) { + wideChildren.push({ n: "w" + i, v: 1, t: 1 }); +} +const wideTree = { n: "", v: 0, t: 1000, c: wideChildren }; +const wideCanvas = (liveFlamegraphState.cfg.frameHeight * (fgMaxDepth(wideTree, 0) + 1)) + 80; +const wideFrames = []; +fgBuildFrames(wideTree, wideTree.t, 0, 0, wideCanvas, true, wideFrames, ""); + +console.log(JSON.stringify({ + colors, + knownFrames, + svgHTML, + viewBox, + singleCount: singleFrames.length, + deepMaxDepth, + wideFrameCount: wideFrames.length, +})); +`, script) + + tmp, err := os.CreateTemp("", "livehtml-js-*.cjs") + if err != nil { + t.Fatalf("create temp script: %v", err) + } + defer os.Remove(tmp.Name()) + + if _, err := tmp.WriteString(harness); err != nil { + _ = tmp.Close() + t.Fatalf("write temp script: %v", err) + } + if err := tmp.Close(); err != nil { + t.Fatalf("close temp script: %v", err) + } + + out, err := exec.Command("node", tmp.Name()).CombinedOutput() + if err != nil { + t.Fatalf("node harness failed: %v\n%s", err, string(out)) + } + return strings.TrimSpace(string(out)) +} + +func extractLiveHTMLScript(t *testing.T) string { + t.Helper() + const openTag = "" + start := strings.Index(liveHTML, openTag) + if start < 0 { + t.Fatalf("script tag not found in liveHTML") + } + start += len(openTag) + end := strings.Index(liveHTML[start:], closeTag) + if end < 0 { + t.Fatalf("closing script tag not found in liveHTML") + } + return strings.TrimSpace(liveHTML[start : start+end]) +} -- cgit v1.2.3