From 99b02bf8c389a793df5d5986db05eed7e459f7b1 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Fri, 6 Mar 2026 15:35:24 +0200 Subject: refactor: remove web flamegrapher and keep TUI-only --- internal/flamegraph/doc.go | 2 +- internal/flamegraph/iordatacollector.go | 65 -- internal/flamegraph/layout.go | 78 --- internal/flamegraph/layout_test.go | 77 --- internal/flamegraph/livehtml.go | 842 ----------------------- internal/flamegraph/livehtml_browser_test.go | 314 --------- internal/flamegraph/livehtml_interaction_test.go | 615 ----------------- internal/flamegraph/liveserver.go | 314 --------- internal/flamegraph/liveserver_open_test.go | 179 ----- internal/flamegraph/liveserver_test.go | 380 ---------- internal/flamegraph/nativejson.go | 86 --- internal/flamegraph/nativejson_test.go | 75 -- internal/flamegraph/nativesvg.go | 97 --- internal/flamegraph/nativesvg_test.go | 60 -- internal/flamegraph/svgwriter.go | 151 ---- internal/flamegraph/svgwriter_js.go | 212 ------ internal/flamegraph/svgwriter_jscode.go | 214 ------ internal/flamegraph/svgwriter_test.go | 112 --- internal/flamegraph/webserver.go | 199 ------ internal/flamegraph/webserver_autoreload_test.go | 37 - internal/flamegraph/webserver_timeout_test.go | 43 -- internal/flamegraph/worker.go | 34 - 22 files changed, 1 insertion(+), 4185 deletions(-) delete mode 100644 internal/flamegraph/iordatacollector.go delete mode 100644 internal/flamegraph/layout.go delete mode 100644 internal/flamegraph/layout_test.go delete mode 100644 internal/flamegraph/livehtml.go delete mode 100644 internal/flamegraph/livehtml_browser_test.go delete mode 100644 internal/flamegraph/livehtml_interaction_test.go delete mode 100644 internal/flamegraph/liveserver.go delete mode 100644 internal/flamegraph/liveserver_open_test.go delete mode 100644 internal/flamegraph/liveserver_test.go delete mode 100644 internal/flamegraph/nativejson.go delete mode 100644 internal/flamegraph/nativejson_test.go delete mode 100644 internal/flamegraph/nativesvg.go delete mode 100644 internal/flamegraph/nativesvg_test.go delete mode 100644 internal/flamegraph/svgwriter.go delete mode 100644 internal/flamegraph/svgwriter_js.go delete mode 100644 internal/flamegraph/svgwriter_jscode.go delete mode 100644 internal/flamegraph/svgwriter_test.go delete mode 100644 internal/flamegraph/webserver.go delete mode 100644 internal/flamegraph/webserver_autoreload_test.go delete mode 100644 internal/flamegraph/webserver_timeout_test.go delete mode 100644 internal/flamegraph/worker.go (limited to 'internal/flamegraph') diff --git a/internal/flamegraph/doc.go b/internal/flamegraph/doc.go index 8ff27d2..02429d3 100644 --- a/internal/flamegraph/doc.go +++ b/internal/flamegraph/doc.go @@ -1,2 +1,2 @@ -// Package flamegraph builds aggregated call trees and rendering inputs for I/O flamegraphs. +// Package flamegraph provides TUI flamegraph aggregation primitives. package flamegraph diff --git a/internal/flamegraph/iordatacollector.go b/internal/flamegraph/iordatacollector.go deleted file mode 100644 index a2ae731..0000000 --- a/internal/flamegraph/iordatacollector.go +++ /dev/null @@ -1,65 +0,0 @@ -package flamegraph - -import ( - "context" - "fmt" - "runtime" - "sync" - - "ior/internal/event" -) - -type IorDataCollector struct { - flamegraphName string - Ch chan *event.Pair - Done chan error - workers []worker -} - -func New(flamegraphName ...string) IorDataCollector { - name := "default" - if len(flamegraphName) > 0 && flamegraphName[0] != "" { - name = flamegraphName[0] - } - - f := IorDataCollector{ - flamegraphName: name, - Ch: make(chan *event.Pair, 4096), - Done: make(chan error, 1), - } - numWorkers := runtime.NumCPU() / 4 - if numWorkers == 0 { - numWorkers = 1 - } - for range numWorkers { - f.workers = append(f.workers, newWorker()) - } - return f -} - -func (f IorDataCollector) Start(ctx context.Context) { - go func() { - defer close(f.Done) - var wg sync.WaitGroup - wg.Add(len(f.workers)) - - for i, worker := range f.workers { - fmt.Println("Starting flamegraph worker", i) - go worker.run(ctx, &wg, f.Ch) - } - wg.Wait() - - iod := f.workers[0].iod - if len(f.workers) > 1 { - for i, w := range f.workers[1:] { - iod = iod.merge(w.iod) - fmt.Println("Worker", i+1, "merged") - } - } - if err := iod.serializeToFile(f.flamegraphName); err != nil { - f.Done <- err - return - } - f.Done <- nil - }() -} diff --git a/internal/flamegraph/layout.go b/internal/flamegraph/layout.go deleted file mode 100644 index c319800..0000000 --- a/internal/flamegraph/layout.go +++ /dev/null @@ -1,78 +0,0 @@ -package flamegraph - -import "fmt" - -// FrameLayout captures renderer-agnostic flamegraph geometry for a single frame. -// -// The layout is reusable by non-SVG renderers (for example SDL or WASM UIs) so -// they can render the same hierarchy without depending on SVG internals. -type FrameLayout struct { - Name string - Title string - Fill string - X float64 - Y float64 - Width float64 - Height float64 - Depth int - Total uint64 - Percent float64 -} - -func sanitizeSVGConfig(cfg SVGConfig) SVGConfig { - if cfg.Width <= 0 || cfg.FrameHeight <= 0 || cfg.FontSize <= 0 || cfg.MinWidthPx <= 0 { - return defaultSVGConfig() - } - if cfg.Title == "" { - cfg.Title = defaultSVGConfig().Title - } - return cfg -} - -func canvasHeightFor(cfg SVGConfig, t *trie) int { - return cfg.FrameHeight*(t.maxDepth+1) + 80 -} - -// BuildFrameLayout builds renderer-agnostic frame coordinates from a flamegraph trie. -func BuildFrameLayout(t *trie, cfg SVGConfig) []FrameLayout { - if t == nil || t.root == nil || t.root.total == 0 { - return nil - } - cfg = sanitizeSVGConfig(cfg) - canvasHeight := canvasHeightFor(cfg, t) - out := make([]FrameLayout, 0, len(t.root.children)) - collectFrameLayout(&out, t.root, t.root.total, cfg, 0, 0, canvasHeight, true) - return out -} - -func collectFrameLayout(out *[]FrameLayout, node *trieNode, rootTotal uint64, - cfg SVGConfig, x float64, depth int, canvasHeight int, isRoot bool) { - - if !isRoot { - w := float64(cfg.Width) * (float64(node.total) / float64(rootTotal)) - if w < cfg.MinWidthPx { - return - } - y := float64(canvasHeight - (depth+1)*cfg.FrameHeight) - pct := 100 * float64(node.total) / float64(rootTotal) - *out = append(*out, FrameLayout{ - Name: node.name, - Title: fmt.Sprintf("%s (%d, %.2f%%)", node.name, node.total, pct), - Fill: frameColor(node.name), - X: x, - Y: y, - Width: w, - Height: float64(cfg.FrameHeight - 1), - Depth: depth, - Total: node.total, - Percent: pct, - }) - } - - cursor := x - for _, child := range node.children { - cw := float64(cfg.Width) * (float64(child.total) / float64(rootTotal)) - collectFrameLayout(out, child, rootTotal, cfg, cursor, depth+1, canvasHeight, false) - cursor += cw - } -} diff --git a/internal/flamegraph/layout_test.go b/internal/flamegraph/layout_test.go deleted file mode 100644 index 8fa7398..0000000 --- a/internal/flamegraph/layout_test.go +++ /dev/null @@ -1,77 +0,0 @@ -package flamegraph - -import ( - "math" - "testing" -) - -func almostEqual(a, b float64) bool { - return math.Abs(a-b) < 1e-6 -} - -func TestBuildFrameLayoutBasicGeometry(t *testing.T) { - tr := newTrie() - tr.add([]string{"A"}, 4) - tr.add([]string{"B"}, 1) - tr.computeTotals() - - cfg := defaultSVGConfig() - cfg.Width = 100 - cfg.FrameHeight = 10 - cfg.FontSize = 10 - cfg.MinWidthPx = 1 - - frames := BuildFrameLayout(tr, cfg) - if len(frames) != 2 { - t.Fatalf("frames len = %d, want 2", len(frames)) - } - - a := frames[0] - if a.Name != "A" { - t.Fatalf("first frame name = %q, want %q", a.Name, "A") - } - if !almostEqual(a.X, 0) { - t.Fatalf("A x = %f, want 0", a.X) - } - if !almostEqual(a.Width, 80) { - t.Fatalf("A width = %f, want 80", a.Width) - } - if !almostEqual(a.Percent, 80) { - t.Fatalf("A percent = %f, want 80", a.Percent) - } - if a.Depth != 1 { - t.Fatalf("A depth = %d, want 1", a.Depth) - } - - b := frames[1] - if b.Name != "B" { - t.Fatalf("second frame name = %q, want %q", b.Name, "B") - } - if !almostEqual(b.X, 80) { - t.Fatalf("B x = %f, want 80", b.X) - } - if !almostEqual(b.Width, 20) { - t.Fatalf("B width = %f, want 20", b.Width) - } -} - -func TestBuildFrameLayoutSkipsFramesBelowMinWidth(t *testing.T) { - tr := newTrie() - tr.add([]string{"A"}, 999) - tr.add([]string{"B"}, 1) - tr.computeTotals() - - cfg := defaultSVGConfig() - cfg.Width = 100 - cfg.FrameHeight = 10 - cfg.FontSize = 10 - cfg.MinWidthPx = 1 - - frames := BuildFrameLayout(tr, cfg) - if len(frames) != 1 { - t.Fatalf("frames len = %d, want 1", len(frames)) - } - if frames[0].Name != "A" { - t.Fatalf("remaining frame name = %q, want %q", frames[0].Name, "A") - } -} diff --git a/internal/flamegraph/livehtml.go b/internal/flamegraph/livehtml.go deleted file mode 100644 index 71b955e..0000000 --- a/internal/flamegraph/livehtml.go +++ /dev/null @@ -1,842 +0,0 @@ -package flamegraph - -const liveHTML = ` - - - - - I/O Flame Graph (Live) - - - -
- - - - - - - - LIVE -
- - - - - - -` diff --git a/internal/flamegraph/livehtml_browser_test.go b/internal/flamegraph/livehtml_browser_test.go deleted file mode 100644 index 10252a9..0000000 --- a/internal/flamegraph/livehtml_browser_test.go +++ /dev/null @@ -1,314 +0,0 @@ -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"` - TallViewBox string `json:"tallViewBox"` - TallHeight string `json:"tallHeight"` - PrunedMaxEnd float64 `json:"prunedMaxEnd"` - 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, ` 0.01 { - t.Fatalf("pruned max end = %f, want 1600", got.PrunedMaxEnd) - } - - 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(){}, - getBoundingClientRect: function() { return { height: id === "controls" ? 56 : 0 }; }, - 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 = {}; -["controls", "flamegraph", "status", "btn-pause", "btn-search", "btn-reset-search", "btn-undo-zoom", "btn-reset-zoom", "btn-reset-baseline", "btn-toggle-order"].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.fetch = function() { - return Promise.resolve({ - ok: true, - json: function() { return Promise.resolve({ fields: ["comm", "tracepoint", "path"], snapshot: { n: "", v: 0, t: 0 } }); }, - text: function() { return Promise.resolve("{\"n\":\"\",\"v\":0,\"t\":0}"); } - }); -}; -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, 1200, 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"] || ""; - -window.innerWidth = 1600; -window.innerHeight = 900; -fgRender(knownTree); -const tallViewBox = elements["flamegraph"].attrs["viewBox"] || ""; -const tallHeight = elements["flamegraph"].style.height || ""; - -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, 1200, 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, 1200, 0, wideCanvas, true, wideFrames, ""); - -const prunedTree = { - n: "", - v: 0, - t: 100, - c: [ - { n: "A", v: 0, t: 60 }, - { n: "B", v: 0, t: 20 } - ] -}; -fgRender(prunedTree); -const prunedHTML = elements["flamegraph"].innerHTML; -const prunedMatches = prunedHTML.match(/data-x=\"([0-9.]+)\" data-w=\"([0-9.]+)\"/g) || []; -let prunedMaxEnd = 0; -for (const m of prunedMatches) { - const parts = m.match(/data-x=\"([0-9.]+)\" data-w=\"([0-9.]+)\"/); - if (!parts) continue; - const end = Number(parts[1]) + Number(parts[2]); - if (end > prunedMaxEnd) { - prunedMaxEnd = end; - } -} - -console.log(JSON.stringify({ - colors, - knownFrames, - svgHTML, - viewBox, - tallViewBox, - tallHeight, - prunedMaxEnd, - 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]) -} diff --git a/internal/flamegraph/livehtml_interaction_test.go b/internal/flamegraph/livehtml_interaction_test.go deleted file mode 100644 index 4c947f5..0000000 --- a/internal/flamegraph/livehtml_interaction_test.go +++ /dev/null @@ -1,615 +0,0 @@ -package flamegraph - -import ( - "encoding/json" - "fmt" - "os" - "os/exec" - "strings" - "testing" -) - -type zoomSearchStateResult struct { - BeforePath string `json:"beforePath"` - AfterPath string `json:"afterPath"` - DeepPathStable bool `json:"deepPathStable"` - SearchPersisted bool `json:"searchPersisted"` - ZoomedBranchStable bool `json:"zoomedBranchStable"` - NonZoomedHidden bool `json:"nonZoomedHidden"` - NewChildVisible bool `json:"newChildVisible"` - PauseUnpauseKeeps bool `json:"pauseUnpauseKeeps"` -} - -type pauseKeyboardResult struct { - PausedBySpace bool `json:"pausedBySpace"` - NoUpdateWhilePaused bool `json:"noUpdateWhilePaused"` - ZoomSearchWhilePaused bool `json:"zoomSearchWhilePaused"` - UnpauseRendersLatest bool `json:"unpauseRendersLatest"` - RapidToggleStable bool `json:"rapidToggleStable"` - SlashSearchWorks bool `json:"slashSearchWorks"` - EscapeResets bool `json:"escapeResets"` - ButtonMatchesKeyboard bool `json:"buttonMatchesKeyboard"` - TypingIgnoresShortcuts bool `json:"typingIgnoresShortcuts"` -} - -type resetBaselineResult struct { - HotkeyPrevented bool `json:"hotkeyPrevented"` - ShiftHotkeyIgnored bool `json:"shiftHotkeyIgnored"` - HotkeyResetApplied bool `json:"hotkeyResetApplied"` - ButtonResetApplied bool `json:"buttonResetApplied"` - ResetCallsValid bool `json:"resetCallsValid"` -} - -type orderToggleResult struct { - OrderButtonUpdated bool `json:"orderButtonUpdated"` - OrderCallValid bool `json:"orderCallValid"` - OrderSnapshotShown bool `json:"orderSnapshotShown"` -} - -func TestLiveHTMLJSZoomSearchStatePreservedAcrossUpdates(t *testing.T) { - if _, err := exec.LookPath("node"); err != nil { - t.Skip("node not available") - } - - snippet := ` -const fg = liveFlamegraphState; - -const frameA = makeFrame("A", "A", 1, 0, 700); -const frameAChild = makeFrame("Achild", "A\u001fAchild", 2, 0, 400); -const frameB = makeFrame("B", "B", 1, 700, 500); -fg.frames = [frameA, frameAChild, frameB]; -fg.rootWidth = 1200; - -fgZoom(frameA); -const beforePath = fg.zoomRange.path; -prompt = function(){ return "A"; }; -fgSearch(); - -const frameA2 = makeFrame("A", "A", 1, 0, 800); -const frameAChild2 = makeFrame("Achild", "A\u001fAchild", 2, 0, 500); -const frameAnew2 = makeFrame("Anew", "A\u001fAnew", 2, 500, 300); -const frameB2 = makeFrame("B", "B", 1, 800, 400); -fg.frames = [frameA2, frameAChild2, frameAnew2, frameB2]; -fg.rootWidth = 1200; -fgApplyZoom(); -prompt = function(_msg, prev){ return prev || "A"; }; -fgSearch(); - -const afterPath = fg.zoomRange.path; -const searchPersisted = frameA2.querySelector("rect").getAttribute("fill") === fg.matchColor; -const nonZoomedHidden = frameB2.style.display === "none"; -const newChildVisible = frameAnew2.style.display !== "none"; -const zoomedBranchStable = frameA2.style.display !== "none" && frameAChild2.style.display !== "none"; - -const deep1 = makeFrame("A2", "A\u001fA1\u001fA2", 3, 100, 200); -fg.frames = [deep1]; -fg.rootWidth = 1200; -fgZoom(deep1); -const deepPath = fg.zoomRange.path; - -const deep2 = makeFrame("A2", "A\u001fA1\u001fA2", 3, 120, 240); -fg.frames = [deep2]; -fgApplyZoom(); -const deep3 = makeFrame("A2", "A\u001fA1\u001fA2", 3, 140, 260); -fg.frames = [deep3]; -fgApplyZoom(); -const deepPathStable = fg.zoomRange.path === deepPath && deep3.style.display !== "none"; - -fg.pendingData = "{\"n\":\"\",\"v\":0,\"t\":0}"; -fgTogglePause(); -fgTogglePause(); -const pauseUnpauseKeeps = fg.zoomRange.path === deepPath; - -console.log(JSON.stringify({ - beforePath, - afterPath, - deepPathStable, - searchPersisted, - zoomedBranchStable, - nonZoomedHidden, - newChildVisible, - pauseUnpauseKeeps -})); -` - - out := runLiveHTMLNodeSnippet(t, snippet) - var got zoomSearchStateResult - if err := json.Unmarshal([]byte(out), &got); err != nil { - t.Fatalf("decode node result: %v\nraw:\n%s", err, out) - } - - if got.BeforePath != "A" || got.AfterPath != "A" { - t.Fatalf("zoom path changed unexpectedly: before=%q after=%q", got.BeforePath, got.AfterPath) - } - if !got.SearchPersisted { - t.Fatalf("expected search highlight to persist across update") - } - if !got.ZoomedBranchStable { - t.Fatalf("expected zoomed branch to remain visible across update") - } - if !got.NonZoomedHidden { - t.Fatalf("expected non-zoomed branch to be hidden while zoomed") - } - if !got.NewChildVisible { - t.Fatalf("expected newly added child in zoomed branch to remain visible") - } - if !got.DeepPathStable { - t.Fatalf("expected deep zoom path to remain stable across multiple updates") - } - if !got.PauseUnpauseKeeps { - t.Fatalf("expected pause/unpause to preserve zoom state") - } -} - -func TestLiveHTMLJSPauseResumeAndKeyboard(t *testing.T) { - if _, err := exec.LookPath("node"); err != nil { - t.Skip("node not available") - } - - snippet := ` -const fg = liveFlamegraphState; -const keydown = __docListeners["keydown"]; - -function keyEvent(key, code, target) { - let prevented = false; - keydown({ - key: key, - code: code, - target: target || { tagName: "BODY", isContentEditable: false }, - preventDefault: function(){ prevented = true; } - }); - return prevented; -} - -let promptCalls = 0; -prompt = function(_msg, prev) { - promptCalls++; - return prev || "needle"; -}; - -const pausePayload = "{\"n\":\"\",\"v\":0,\"t\":10,\"c\":[{\"n\":\"latest\",\"v\":10,\"t\":10}]}"; -const beforeHTML = fg.svg.innerHTML; -const pausedBySpacePrevented = keyEvent(" ", "Space"); -const pausedBySpace = pausedBySpacePrevented && fg.paused && fg.pauseBtn.textContent === "Resume" && fg.status.textContent.indexOf("PAUSED") === 0; - -fg.eventSource.onmessage({ data: pausePayload }); -const noUpdateWhilePaused = fg.pendingData === pausePayload && fg.svg.innerHTML === beforeHTML; - -const pausedFrame = makeFrame("needle", "needle", 1, 0, 1200); -fg.frames = [pausedFrame]; -fg.rootWidth = 1200; -fgZoom(pausedFrame); -prompt = function(_msg, prev) { - promptCalls++; - return prev || "needle"; -}; -fgSearch(); -const zoomSearchWhilePaused = fg.zoomRange && fg.zoomRange.path === "needle" && - pausedFrame.querySelector("rect").getAttribute("fill") === fg.matchColor; - -const resumedBySpacePrevented = keyEvent(" ", "Space"); -const unpauseRendersLatest = resumedBySpacePrevented && !fg.paused && fg.pendingData === null && - fg.pauseBtn.textContent === "Pause" && fg.svg.innerHTML.indexOf('data-name="latest"') >= 0; - -let rapidToggleStable = true; -for (let i = 0; i < 20; i++) { - try { - fgTogglePause(); - } catch (err) { - rapidToggleStable = false; - } -} -if (fg.paused) { - fgTogglePause(); -} -rapidToggleStable = rapidToggleStable && !fg.paused && fg.pauseBtn.textContent === "Pause"; - -promptCalls = 0; -prompt = function() { - promptCalls++; - return "slash"; -}; -const slashPrevented = keyEvent("/", "Slash"); -const slashSearchWorks = slashPrevented && promptCalls === 1 && fg.searchQuery === "slash"; - -const escFrame = makeFrame("slash", "slash", 1, 0, 1200); -fg.frames = [escFrame]; -fg.rootWidth = 1200; -fgZoom(escFrame); -fgSearch(); -const escapePrevented = keyEvent("Escape", "Escape"); -const escapeResets = escapePrevented && fg.zoomRange === null && - escFrame.querySelector("rect").getAttribute("fill") === escFrame.dataset.baseFill; - -let buttonPromptCalls = 0; -prompt = function() { - buttonPromptCalls++; - return "button"; -}; -document.getElementById("btn-pause").listeners.click(); -const pauseViaButton = fg.paused && fg.pauseBtn.textContent === "Resume"; -document.getElementById("btn-pause").listeners.click(); -const resumeViaButton = !fg.paused && fg.pauseBtn.textContent === "Pause"; -document.getElementById("btn-search").listeners.click(); -const searchViaButton = buttonPromptCalls === 1 && fg.searchQuery === "button"; - -const btnFrame = makeFrame("button", "button", 1, 0, 1200); -fg.frames = [btnFrame]; -fg.rootWidth = 1200; -fgZoom(btnFrame); -document.getElementById("btn-reset-search").listeners.click(); -document.getElementById("btn-reset-zoom").listeners.click(); -const resetViaButton = fg.zoomRange === null && - btnFrame.querySelector("rect").getAttribute("fill") === btnFrame.dataset.baseFill; -const buttonMatchesKeyboard = pauseViaButton && resumeViaButton && searchViaButton && resetViaButton; - -const typingTarget = { tagName: "INPUT", isContentEditable: false }; -fg.searchQuery = "typed"; -fg.zoomRange = { path: "typed", x: 0, w: 1200, depth: 1 }; -promptCalls = 0; -const typingSpacePrevented = keyEvent(" ", "Space", typingTarget); -const typingSlashPrevented = keyEvent("/", "Slash", typingTarget); -const typingEscapePrevented = keyEvent("Escape", "Escape", typingTarget); -const typingIgnoresShortcuts = !typingSpacePrevented && !typingSlashPrevented && !typingEscapePrevented && - !fg.paused && promptCalls === 0 && fg.zoomRange !== null && fg.searchQuery === "typed"; - -console.log(JSON.stringify({ - pausedBySpace, - noUpdateWhilePaused, - zoomSearchWhilePaused, - unpauseRendersLatest, - rapidToggleStable, - slashSearchWorks, - escapeResets, - buttonMatchesKeyboard, - typingIgnoresShortcuts -})); -` - - out := runLiveHTMLNodeSnippet(t, snippet) - var got pauseKeyboardResult - if err := json.Unmarshal([]byte(out), &got); err != nil { - t.Fatalf("decode node result: %v\nraw:\n%s", err, out) - } - - if !got.PausedBySpace { - t.Fatalf("expected Space shortcut to pause and update status/button state") - } - if !got.NoUpdateWhilePaused { - t.Fatalf("expected stream updates to queue while paused without rerendering") - } - if !got.ZoomSearchWhilePaused { - t.Fatalf("expected zoom and search to work while paused") - } - if !got.UnpauseRendersLatest { - t.Fatalf("expected unpause to render latest queued update immediately") - } - if !got.RapidToggleStable { - t.Fatalf("expected rapid pause/unpause toggles to remain stable") - } - if !got.SlashSearchWorks { - t.Fatalf("expected '/' shortcut to open search flow") - } - if !got.EscapeResets { - t.Fatalf("expected Escape shortcut to reset zoom/search highlighting") - } - if !got.ButtonMatchesKeyboard { - t.Fatalf("expected button actions to match keyboard behavior") - } - if !got.TypingIgnoresShortcuts { - t.Fatalf("expected keyboard shortcuts to be ignored while typing in an input") - } -} - -func TestLiveHTMLJSResetBaselineHotkeyAndButton(t *testing.T) { - if _, err := exec.LookPath("node"); err != nil { - t.Skip("node not available") - } - - snippet := ` -const fg = liveFlamegraphState; -const keydown = __docListeners["keydown"]; - -function keyEvent(key, code, target) { - let prevented = false; - keydown({ - key: key, - code: code, - target: target || { tagName: "BODY", isContentEditable: false }, - preventDefault: function(){ prevented = true; } - }); - return prevented; -} - -const frame = makeFrame("needle", "needle", 1, 0, 1200); -fg.frames = [frame]; -fg.rootWidth = 1200; -fgZoom(frame); -prompt = function(){ return "needle"; }; -fgSearch(); - -const resetPayload = "{\"n\":\"\",\"v\":0,\"t\":0}"; -const resetCalls = []; -fetch = function(url, opts) { - resetCalls.push({ - url: url, - method: (opts && opts.method) || "GET" - }); - return Promise.resolve({ - ok: true, - text: function() { return Promise.resolve(resetPayload); } - }); -}; - -const hotkeyPrevented = keyEvent("r", "KeyR"); -const shiftHotkeyPrevented = keyEvent("R", "KeyR"); -const shiftHotkeyIgnored = !shiftHotkeyPrevented && resetCalls.length === 1; - -setTimeout(function() { - const hotkeyResetApplied = fg.zoomRange === null && fg.searchQuery === "" && fg.frames.length === 0; - - const frame2 = makeFrame("again", "again", 1, 0, 1200); - fg.frames = [frame2]; - fg.rootWidth = 1200; - fgZoom(frame2); - fg.searchQuery = "again"; - document.getElementById("btn-reset-baseline").listeners.click(); - - setTimeout(function() { - const buttonResetApplied = fg.zoomRange === null && fg.searchQuery === "" && fg.frames.length === 0; - const resetCallsValid = resetCalls.length === 2 && - resetCalls[0].url === "/reset" && resetCalls[0].method === "POST" && - resetCalls[1].url === "/reset" && resetCalls[1].method === "POST"; - - console.log(JSON.stringify({ - hotkeyPrevented, - shiftHotkeyIgnored, - hotkeyResetApplied, - buttonResetApplied, - resetCallsValid - })); - }, 0); -}, 0); -` - - out := runLiveHTMLNodeSnippet(t, snippet) - var got resetBaselineResult - if err := json.Unmarshal([]byte(out), &got); err != nil { - t.Fatalf("decode node result: %v\nraw:\n%s", err, out) - } - - if !got.HotkeyPrevented { - t.Fatalf("expected reset hotkey to prevent default browser handling") - } - if !got.ShiftHotkeyIgnored { - t.Fatalf("expected uppercase 'R' to be ignored for baseline reset") - } - if !got.HotkeyResetApplied { - t.Fatalf("expected 'r' hotkey to reset baseline and clear UI state") - } - if !got.ButtonResetApplied { - t.Fatalf("expected Reset Baseline button to clear UI state") - } - if !got.ResetCallsValid { - t.Fatalf("expected reset interactions to POST /reset") - } -} - -func TestLiveHTMLJSOrderToggle(t *testing.T) { - if _, err := exec.LookPath("node"); err != nil { - t.Skip("node not available") - } - - snippet := ` -const fg = liveFlamegraphState; -const orderCalls = []; -fetch = function(url, opts) { - orderCalls.push({ - url: url, - method: (opts && opts.method) || "GET", - body: (opts && opts.body) || "" - }); - return Promise.resolve({ - ok: true, - json: function() { - return Promise.resolve({ - fields: ["path", "tracepoint", "comm"], - snapshot: { - n: "", - v: 0, - t: 1, - c: [{ n: "/tmp", v: 1, t: 1 }] - } - }); - } - }); -}; - -document.getElementById("btn-toggle-order").listeners.click(); - -setTimeout(function() { - const orderButtonUpdated = document.getElementById("btn-toggle-order").textContent.indexOf("path > tracepoint > comm") >= 0; - const orderSnapshotShown = fg.svg.innerHTML.indexOf('data-name="/tmp"') >= 0; - const req = orderCalls[0] || {}; - let bodyFields = []; - try { - bodyFields = JSON.parse(req.body || "{}").fields || []; - } catch (err) { - bodyFields = []; - } - const orderCallValid = orderCalls.length === 1 && - req.url === "/order" && - req.method === "POST" && - JSON.stringify(bodyFields) === JSON.stringify(["path", "tracepoint", "comm"]); - - console.log(JSON.stringify({ - orderButtonUpdated, - orderCallValid, - orderSnapshotShown - })); -}, 0); -` - - out := runLiveHTMLNodeSnippet(t, snippet) - var got orderToggleResult - if err := json.Unmarshal([]byte(out), &got); err != nil { - t.Fatalf("decode node result: %v\nraw:\n%s", err, out) - } - - if !got.OrderButtonUpdated { - t.Fatalf("expected toggle button label to update to next order") - } - if !got.OrderCallValid { - t.Fatalf("expected toggle to POST /order with next preset fields") - } - if !got.OrderSnapshotShown { - t.Fatalf("expected returned order snapshot to render immediately") - } -} - -func runLiveHTMLNodeSnippet(t *testing.T, snippet string) 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(){} }, - listeners: {}, - addEventListener: function(event, cb) { this.listeners[event] = cb; }, - getBoundingClientRect: function() { return { height: id === "controls" ? 56 : 0 }; }, - setAttribute: function(k, v) { this.attrs[k] = String(v); }, - getAttribute: function(k) { return this.attrs[k] || ""; }, - querySelectorAll: function() { return []; }, - querySelector: function() { return null; } - }; -} - -function makeRect(fill) { - return { - attrs: { fill: fill || "" }, - dataset: {}, - style: {}, - setAttribute: function(k, v) { this.attrs[k] = String(v); }, - getAttribute: function(k) { return this.attrs[k] || ""; } - }; -} - -function makeText(name) { - return { - textContent: name || "", - dataset: { full: name || "", hidden: "0", ox: "0" }, - style: {}, - setAttribute: function(k, v) { - this[k] = String(v); - }, - getAttribute: function(k) { - return this[k] || ""; - } - }; -} - -function makeFrame(name, path, depth, x, w) { - const rect = makeRect("rgb(1,2,3)"); - rect.dataset.ox = String(x); - rect.dataset.ow = String(w); - rect.setAttribute("x", String(x)); - rect.setAttribute("width", String(w)); - - const text = makeText(name); - text.dataset.ox = String(x + 3); - text.setAttribute("x", String(x + 3)); - - const title = { textContent: name + " title" }; - return { - dataset: { - name: name, - path: path, - depth: String(depth), - x: String(x), - w: String(w), - ox: String(x), - ow: String(w), - baseFill: "rgb(1,2,3)" - }, - style: {}, - listeners: {}, - addEventListener: function(event, cb) { this.listeners[event] = cb; }, - querySelector: function(selector) { - if (selector === "rect") return rect; - if (selector === "text") return text; - if (selector === "title") return title; - return null; - }, - querySelectorAll: function() { return []; }, - }; -} - -const elements = {}; -["controls", "flamegraph", "status", "btn-pause", "btn-search", "btn-reset-search", "btn-undo-zoom", "btn-reset-zoom", "btn-reset-baseline", "btn-toggle-order"].forEach((id) => { - elements[id] = makeElement(id); -}); -elements["body"] = makeElement("body"); - -const docListeners = {}; -global.document = { - body: elements["body"], - getElementById: function(id) { - if (!elements[id]) elements[id] = makeElement(id); - return elements[id]; - }, - addEventListener: function(event, cb) { docListeners[event] = cb; }, -}; -global.window = global; -global.prompt = function(){ return ""; }; -global.fetch = function() { - return Promise.resolve({ - ok: true, - json: function() { return Promise.resolve({ fields: ["comm", "tracepoint", "path"], snapshot: { n: "", v: 0, t: 0 } }); }, - text: function() { return Promise.resolve("{\"n\":\"\",\"v\":0,\"t\":0}"); } - }); -}; -global.requestAnimationFrame = function(cb){ cb(); }; -global.EventSource = function() { - this.onmessage = null; - this.onerror = null; -}; -window.addEventListener = function(){}; - -vm.runInThisContext(liveScript); - -global.makeFrame = makeFrame; -global.__docListeners = docListeners; - -%s -`, script, snippet) - - tmp, err := os.CreateTemp("", "livehtml-node-snippet-*.cjs")