summaryrefslogtreecommitdiff
path: root/internal/flamegraph/livehtml_interaction_test.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/flamegraph/livehtml_interaction_test.go')
-rw-r--r--internal/flamegraph/livehtml_interaction_test.go255
1 files changed, 255 insertions, 0 deletions
diff --git a/internal/flamegraph/livehtml_interaction_test.go b/internal/flamegraph/livehtml_interaction_test.go
new file mode 100644
index 0000000..1dff8c1
--- /dev/null
+++ b/internal/flamegraph/livehtml_interaction_test.go
@@ -0,0 +1,255 @@
+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"`
+}
+
+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 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; },
+ 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 = {};
+["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");
+
+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.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")
+ 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 snippet failed: %v\n%s", err, string(out))
+ }
+ return strings.TrimSpace(string(out))
+}