summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-02-27 18:21:08 +0200
committerPaul Buetow <paul@buetow.org>2026-02-27 18:21:08 +0200
commit62fc446d15cbf038a12feb4d36ae97a663904d63 (patch)
tree88bccc68da1e6162a99a2c2d5188ccb5e39d3fc1
parent126b5fcce718f8befccdceb21e3e0ae80cd7b32e (diff)
flamegraph: add browser rendering parity QA test
-rw-r--r--internal/flamegraph/livehtml_browser_test.go263
1 files changed, 263 insertions, 0 deletions
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, `<g class="frame"`) {
+ t.Fatalf("svg markup missing frame group")
+ }
+ if !strings.Contains(got.SVGHTML, `data-name="A"`) {
+ t.Fatalf("svg markup missing data-name for A")
+ }
+ if !strings.Contains(got.SVGHTML, `data-x="0.000"`) {
+ t.Fatalf("svg markup missing data-x")
+ }
+ if !strings.Contains(got.SVGHTML, `data-w="720.000"`) {
+ t.Fatalf("svg markup missing data-w")
+ }
+ if !strings.Contains(got.SVGHTML, `data-depth="1"`) {
+ t.Fatalf("svg markup missing data-depth")
+ }
+ if !strings.Contains(got.SVGHTML, `data-base-fill="rgb(`) {
+ t.Fatalf("svg markup missing data-base-fill")
+ }
+ if got.ViewBox != "0 0 1200 128" {
+ t.Fatalf("viewBox = %q, want %q", got.ViewBox, "0 0 1200 128")
+ }
+
+ if got.SingleCount != 1 {
+ t.Fatalf("single-frame case count = %d, want 1", got.SingleCount)
+ }
+ if got.DeepMaxDepth < 50 {
+ t.Fatalf("deep max depth = %d, want at least 50", got.DeepMaxDepth)
+ }
+ if got.WideFrameCount != 1000 {
+ t.Fatalf("wide frame count = %d, want 1000", got.WideFrameCount)
+ }
+}
+
+func assertFrame(t *testing.T, got jsFrame, name string, x, y, w, h float64, depth int) {
+ t.Helper()
+ if got.Name != name {
+ t.Fatalf("frame name = %q, want %q", got.Name, name)
+ }
+ if got.Depth != depth {
+ t.Fatalf("frame %q depth = %d, want %d", got.Name, got.Depth, depth)
+ }
+ const eps = 0.001
+ if diff(got.X, x) > 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 = "<script>"
+ const closeTag = "</script>"
+ 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])
+}