diff --git a/.gitignore b/.gitignore index e9370b1..c96b1f7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ /target /cpp/list_example_cpp __pycache__ + +.coverage diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..020170f --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,8 @@ +# Dev/test tooling for python/ and scripts/dump_debug_graph.py. +# The system python3 is too new for Debian's packaged debugpy, so these +# need a venv: +# python3 -m venv .venv && .venv/bin/pip install -r requirements-dev.txt +debugpy==1.8.21 +pytest==9.1.1 +pytest-cov==7.1.0 +ruff==0.15.21 diff --git a/scripts/dump_debug_graph.py b/scripts/dump_debug_graph.py index 37188cc..e67e54b 100755 --- a/scripts/dump_debug_graph.py +++ b/scripts/dump_debug_graph.py @@ -418,7 +418,12 @@ def main(): expr="$debug_graph(top)", verify=verify_top_delves_into_list, ) - ok_rust = dump("rust", "rust-gdb", str(REPO / "target" / "debug" / "list_example"), "src/main.rs", 51, out_dir) + ok_rust = dump("rust", "rust-gdb", str(REPO / "target" / "debug" / "list_example"), "src/main.rs", 57, out_dir) + ok_rust_top = dump( + "rust_top", "rust-gdb", str(REPO / "target" / "debug" / "list_example"), "src/main.rs", 57, out_dir, + expr="$debug_graph(top)", + verify=verify_top_delves_into_list, + ) python_bin = find_debugpy_python() python_script = REPO / "python" / "main.py" @@ -429,7 +434,7 @@ def main(): verify=verify_top_delves_into_list_py, ) - if not (ok_cpp and ok_cpp_top and ok_rust and ok_python and ok_python_top): + if not (ok_cpp and ok_cpp_top and ok_rust and ok_rust_top and ok_python and ok_python_top): sys.exit(1) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..69a1921 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,6 @@ +import sys +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(REPO / "scripts")) +sys.path.insert(0, str(REPO / "debuggers")) diff --git a/tests/fake_gdb.py b/tests/fake_gdb.py new file mode 100644 index 0000000..51560bd --- /dev/null +++ b/tests/fake_gdb.py @@ -0,0 +1,188 @@ +"""Minimal fake of the `gdb` Python API -- just enough surface for +debuggers/gdb_debug_graph.py to run its DWARF-walking logic against +synthetic Type/Value/Frame objects, without a real gdb process attached to +a real binary. + +Addresses are plain ints supplied by the test (not derived from id()), so +a test can deliberately construct two differently-typed pointers ("const +int*" and "Node*") that happen to share the same address -- exactly +the "top points at the first member of the node it coincides with" +scenario gdb_debug_graph.py's _visit_pointer() is built to handle. +""" + +TYPE_CODE_STRUCT = 1 +TYPE_CODE_UNION = 2 +TYPE_CODE_PTR = 3 +TYPE_CODE_INT = 4 +TYPE_CODE_CHAR = 5 +TYPE_CODE_ARRAY = 6 + + +class error(Exception): # noqa: N801 - matches gdb's own (lowercase) exception name + pass + + +class GdbError(Exception): + pass + + +class Field: + def __init__(self, name, artificial=False, is_base_class=False): + self.name = name + self.artificial = artificial + self.is_base_class = is_base_class + + +class Type: + def __init__(self, code, tag=None, fields=None, target=None, sizeof=8, display=None): + self.code = code + self.tag = tag + self._fields = fields or [] + self._target = target + self.sizeof = sizeof + self._display = display if display is not None else (tag or "") + + def fields(self): + return self._fields + + def strip_typedefs(self): + return self + + def target(self): + if self._target is None: + raise error("type has no target (not a pointer/array)") + return self._target + + def __str__(self): + return self._display + + +class Value: + """`address` is this value's own location (meaningful for struct/union + values, read via `.address`); `ptr_int`/`pointee` describe what a + *pointer*-typed value points at (read via `int(value)`/`.dereference()`). + A struct Value and a "pointer to that struct" Value are two separate + objects the test wires together explicitly, same as real gdb.""" + + def __init__(self, type_, *, address=None, ptr_int=None, pointee=None, fields=None, py_value=None): + self.type = type_ + self._address = address + self._ptr_int = ptr_int + self._pointee = pointee + self._fields = fields or {} + self._py_value = py_value + + def __getitem__(self, name): + try: + return self._fields[name] + except KeyError: + raise error("there is no member named %s" % name) + + def __int__(self): + if self._ptr_int is None: + raise error("value is not a pointer-like value") + return self._ptr_int + + @property + def address(self): + if self._address is None: + raise error("value has no address") + return Value(Type(TYPE_CODE_PTR, target=self.type), ptr_int=self._address, pointee=self) + + def dereference(self): + if self._pointee is None: + raise error("attempt to dereference a generic pointer") + return self._pointee + + def string(self, length=None): + if not isinstance(self._py_value, str): + raise error("value is not a string") + return self._py_value if length is None else self._py_value[:length] + + def __str__(self): + return str(self._py_value) + + +# ---- pretty-printer support ---- + +def default_visualizer(val): + """Overridden per-test via monkeypatch; no printer by default.""" + return None + + +# ---- frame/block/symbol support (for local/global auto-discovery) ---- + +class Symbol: + def __init__(self, name, value, is_variable=True, is_argument=False): + self.name = name + self._value = value + self.is_variable = is_variable + self.is_argument = is_argument + + def value(self, frame): + return self._value + + +class Block: + def __init__(self, symbols=(), superblock=None, is_global=False, is_static=False): + self._symbols = list(symbols) + self.superblock = superblock + self.is_global = is_global + self.is_static = is_static + + def __iter__(self): + return iter(self._symbols) + + +class Symtab: + def __init__(self, global_block=None, static_block=None): + self._global_block = global_block + self._static_block = static_block + + def global_block(self): + return self._global_block + + def static_block(self): + return self._static_block + + +class Sal: + def __init__(self, symtab): + self.symtab = symtab + + +class Frame: + def __init__(self, block, symtab=None): + self._block = block + self._symtab = symtab + + def block(self): + return self._block + + def find_sal(self): + return Sal(self._symtab) + + +def selected_frame(): + """Overridden per-test via monkeypatch; no frame by default.""" + raise error("No frame selected") + + +def parse_and_eval(expr): + """Approximates gdb's expression parser for exactly the one shape + _as_debugger_string() ever produces: a double-quoted, backslash-escaped + string literal. Returns a Value whose .string() recovers the original + (unescaped) text, like a real char* gdb builds from that literal would.""" + if not (expr.startswith('"') and expr.endswith('"') and len(expr) >= 2): + raise error("unsupported fake parse_and_eval input: %r" % expr) + inner = expr[1:-1] + unescaped = inner.replace('\\"', '"').replace("\\\\", "\\") + return Value(Type(TYPE_CODE_PTR, target=Type(TYPE_CODE_CHAR, sizeof=1)), py_value=unescaped) + + +pretty_printers = [] + + +class Function: + def __init__(self, name): + self._name = name diff --git a/tests/js/load-visualizer.js b/tests/js/load-visualizer.js new file mode 100644 index 0000000..6298fcc --- /dev/null +++ b/tests/js/load-visualizer.js @@ -0,0 +1,67 @@ +"use strict"; +// Loads vis-plugins/node-table-visualizer.js's internal pure functions +// (layeredPositions, createSimulation, boxEdgePoint, subtreeSize, rootKind) +// for direct unit testing, WITHOUT modifying the real file on disk: the +// module keeps every function private inside `module.exports = function +// (register, lib) {...}`, so we read its source as text, splice in one +// harmless capture statement in an in-memory copy only, and run that copy +// in a fresh vm context. The file the extension host actually loads is +// never touched. +const fs = require("node:fs"); +const path = require("node:path"); +const vm = require("node:vm"); + +const SOURCE_PATH = path.join(__dirname, "..", "..", "vis-plugins", "node-table-visualizer.js"); + +function loadInternals() { + const original = fs.readFileSync(SOURCE_PATH, "utf8"); + const anchor = "register({"; + const idx = original.indexOf(anchor); + if (idx === -1) { + throw new Error( + "load-visualizer.js: could not find the `register({` anchor to inject test hooks before -- " + + "has node-table-visualizer.js's shape changed?" + ); + } + const hookCapture = [ + "__TEST_HOOKS__.layeredPositions = layeredPositions;", + "__TEST_HOOKS__.createSimulation = createSimulation;", + "__TEST_HOOKS__.boxEdgePoint = boxEdgePoint;", + "__TEST_HOOKS__.subtreeSize = subtreeSize;", + "__TEST_HOOKS__.rootKind = rootKind;", + "", + ].join("\n"); + const patched = original.slice(0, idx) + hookCapture + original.slice(idx); + + const hooks = {}; + const sandbox = { module: {}, __TEST_HOOKS__: hooks, console }; + vm.createContext(sandbox); + vm.runInContext(patched, sandbox, { filename: SOURCE_PATH }); + + // The file only *assigns* module.exports = function(register, lib) {...} + // at the top level; the closure body (and our injected capture inside + // it) doesn't run until that function is actually called. `register` + // and `lib` are the real extension host's objects in production -- here + // just enough of a stub to get past the schema setup and toolbar/DOM + // code without ever calling into `document`/`window` (register itself + // is a no-op, so the DOM-heavy render() is defined but never invoked). + const noop = function () {}; + const passthroughSchema = function () { + return {}; + }; + const fakeLib = { + semanticJson: { + sOpenObject: passthroughSchema, + sString: passthroughSchema, + sOptionalProp: passthroughSchema, + sBoolean: passthroughSchema, + sArrayOf: passthroughSchema, + sLiteral: passthroughSchema, + }, + }; + sandbox.module.exports(noop, fakeLib); + + return hooks; +} + +module.exports = { loadInternals }; diff --git a/tests/js/node-table-visualizer.test.js b/tests/js/node-table-visualizer.test.js new file mode 100644 index 0000000..0a42f0f --- /dev/null +++ b/tests/js/node-table-visualizer.test.js @@ -0,0 +1,210 @@ +"use strict"; +const test = require("node:test"); +const assert = require("node:assert/strict"); +const { loadInternals } = require("./load-visualizer.js"); + +const h = loadInternals(); + +const NODE_WIDTH = 220; +const H_GAP = 70; + +// ---- layeredPositions ------------------------------------------------ + +test("layeredPositions: empty graph yields no positions", () => { + // Objects returned from the vm sandbox aren't reference-equal to + // same-shaped objects built in this realm (different Object.prototype), + // so deepEqual against a plain {} would spuriously fail -- compare via + // Object.keys instead of object identity/structure. + assert.deepEqual(Object.keys(h.layeredPositions([], [], {})), []); +}); + +test("layeredPositions: single node with no edges sits at level 0", () => { + const pos = h.layeredPositions(["n1"], [], { n1: 40 }); + assert.equal(pos.n1.x, NODE_WIDTH / 2); + assert.equal(pos.n1.y, 20); +}); + +test("layeredPositions: a->b->c chain places each node one level further right", () => { + const boxHeights = { a: 40, b: 40, c: 40 }; + const pos = h.layeredPositions( + ["a", "b", "c"], + [{ from: "a", to: "b" }, { from: "b", to: "c" }], + boxHeights + ); + assert.equal(pos.a.x, NODE_WIDTH / 2); + assert.equal(pos.b.x, NODE_WIDTH + H_GAP + NODE_WIDTH / 2); + assert.equal(pos.c.x, 2 * (NODE_WIDTH + H_GAP) + NODE_WIDTH / 2); + // all alone in their column -> same y + assert.equal(pos.a.y, 20); + assert.equal(pos.b.y, 20); + assert.equal(pos.c.y, 20); +}); + +test("layeredPositions: two nodes at the same level stack into separate columns", () => { + // root -> x, root -> y (siblings at level 1) + const boxHeights = { root: 40, x: 40, y: 40 }; + const pos = h.layeredPositions( + ["root", "x", "y"], + [{ from: "root", to: "x" }, { from: "root", to: "y" }], + boxHeights + ); + assert.equal(pos.x.x, pos.y.x); // same level -> same column x + assert.notEqual(pos.x.y, pos.y.y); // different rows within that level +}); + +test("layeredPositions: a pure cycle (no zero-incoming node) still terminates and levels from an arbitrary root", () => { + const boxHeights = { a: 40, b: 40 }; + const pos = h.layeredPositions(["a", "b"], [{ from: "a", to: "b" }, { from: "b", to: "a" }], boxHeights); + assert.equal(pos.a.x, NODE_WIDTH / 2); // a picked as the arbitrary BFS root -> level 0 + assert.equal(pos.b.x, NODE_WIDTH + H_GAP + NODE_WIDTH / 2); // reached via a->b -> level 1 +}); + +test("layeredPositions: nodes never reached by the BFS still get a position (the lvl===undefined fallback)", () => { + // A node with zero incoming edges is always seeded as a BFS root, so an + // isolated *cycle* is the only way to get a node the main walk never + // reaches: "a" and "b" each have incoming=1 (from each other), so + // neither is seeded, and neither is reachable from "root" (which has no + // outgoing edges at all). Both should still land somewhere via the + // `if (lvl === undefined) { maxLevel++; lvl = maxLevel; }` fallback, + // one level further right each time, in nodeIds iteration order. + const boxHeights = { root: 40, a: 40, b: 40 }; + const pos = h.layeredPositions( + ["root", "a", "b"], + [{ from: "a", to: "b" }, { from: "b", to: "a" }], + boxHeights + ); + assert.equal(pos.root.x, NODE_WIDTH / 2); // the only seeded root -> level 0 + assert.ok(pos.a.x > pos.root.x); + assert.ok(pos.b.x > pos.a.x); // "a" then "b": each bumps maxLevel further right +}); + +// ---- boxEdgePoint ------------------------------------------------------ + +test("boxEdgePoint: coincident points return the center", () => { + // Objects returned from the vm sandbox aren't reference-equal to + // same-shaped objects built in this realm, so compare fields directly + // rather than via deepEqual against a plain-object literal. + const p = h.boxEdgePoint(5, 5, 100, 50, 5, 5); + assert.equal(p.x, 5); + assert.equal(p.y, 5); +}); + +test("boxEdgePoint: approaching straight from the right hits the right edge midpoint", () => { + const p = h.boxEdgePoint(0, 0, 100, 50, 1000, 0); + assert.equal(p.x, 100); + assert.equal(p.y, 0); +}); + +test("boxEdgePoint: approaching straight from below hits the bottom edge midpoint", () => { + const p = h.boxEdgePoint(0, 0, 100, 50, 0, 1000); + assert.equal(p.x, 0); + assert.equal(p.y, 50); +}); + +test("boxEdgePoint: diagonal approach picks whichever axis's boundary is closer (min t)", () => { + // halfW=100, halfH=50: for a 45-degree line, ty (50/1=50) < tx (100/1=100), + // so it should clip on the vertical (height) boundary first. + const p = h.boxEdgePoint(0, 0, 100, 50, 1000, 1000); + assert.equal(p.y, 50); + assert.equal(p.x, 50); // t=0.05 applied to both dx and dy equally here since dx===dy +}); + +// ---- subtreeSize -------------------------------------------------------- + +test("subtreeSize: counts all reachable descendants across multiple children", () => { + const childrenOf = { a: ["b", "c"], b: ["d"], c: [], d: [] }; + assert.equal(h.subtreeSize(["b", "c"], childrenOf), 3); // b, c, d +}); + +test("subtreeSize: a childless start list counts as zero", () => { + assert.equal(h.subtreeSize([], { a: ["b"] }), 0); +}); + +test("subtreeSize: cycles terminate instead of infinite-looping", () => { + const childrenOf = { a: ["b"], b: ["a"] }; + assert.equal(h.subtreeSize(["a"], childrenOf), 2); // a, b -- visited once each +}); + +// ---- rootKind ------------------------------------------------------------ + +test("rootKind: recognizes local/watched/global, defaults unknowns to watched", () => { + assert.equal(h.rootKind({ kind: "local" }), "local"); + assert.equal(h.rootKind({ kind: "watched" }), "watched"); + assert.equal(h.rootKind({ kind: "global" }), "global"); + assert.equal(h.rootKind({ kind: "bogus" }), "watched"); + assert.equal(h.rootKind({}), "watched"); +}); + +// ---- createSimulation ---------------------------------------------------- + +function makeNode(id, x, y, height) { + return { id, x, y, vx: 0, vy: 0, fixed: false, height: height || 40 }; +} + +test("createSimulation: unlinked nodes repel -- their separation grows after one step", () => { + const a = makeNode("a", 0, 0); + const b = makeNode("b", 50, 0); + const sim = h.createSimulation([a, b], [], () => true); + const before = Math.abs(a.x - b.x); + sim.step(); + const after = Math.abs(a.x - b.x); + assert.ok(after > before, "repulsion should push unlinked nodes further apart"); +}); + +test("createSimulation: a linked pair far beyond spring length is pulled closer", () => { + const a = makeNode("a", 0, 0); + const b = makeNode("b", 1000, 0); + const sim = h.createSimulation([a, b], [{ from: "a", to: "b" }], () => true); + const before = Math.abs(a.x - b.x); + sim.step(); + const after = Math.abs(a.x - b.x); + assert.ok(after < before, "the spring should dominate repulsion at this distance and pull them together"); +}); + +test("createSimulation: an invisible node is frozen (zeroed velocity, doesn't move)", () => { + const a = makeNode("a", 0, 0); + const b = makeNode("b", 50, 0); + const sim = h.createSimulation([a, b], [], (id) => id !== "b"); // b is hidden + sim.step(); + assert.equal(b.x, 50); + assert.equal(b.y, 0); + assert.equal(b.vx, 0); + assert.equal(b.vy, 0); +}); + +test("createSimulation: a fixed node doesn't move even if visible", () => { + const a = makeNode("a", 0, 0); + const b = makeNode("b", 50, 0); + b.fixed = true; + const sim = h.createSimulation([a, b], [], () => true); + sim.step(); + assert.equal(b.x, 50); + assert.equal(b.y, 0); +}); + +test("createSimulation: overlapping tall boxes get pushed apart along the smaller-overlap axis", () => { + // Tall boxes (height 300) make minDY (300+16=316) larger than minDX + // (NODE_WIDTH+16=236), so with a small equal dx/dy offset, overlapX + // (~231) < overlapY (~311) and the X-axis collision branch should fire. + const a = makeNode("a", 5, 5, 300); + const b = makeNode("b", 0, 0, 300); + const sim = h.createSimulation([a, b], [], () => true); + const beforeGapX = Math.abs(a.x - b.x); + sim.step(); + const afterGapX = Math.abs(a.x - b.x); + assert.ok(afterGapX > beforeGapX, "collision resolution should widen the x gap between overlapping tall boxes"); +}); + +test("createSimulation: step() returns the total kinetic energy, ~0 once at rest", () => { + // Two nodes already sitting exactly at spring length apart, unlinked (no + // spring), but repulsion/gravity are still nonzero forces in general -- + // use a single node with no peers so there's truly nothing to move it. + const a = makeNode("a", 0, 0); + const sim = h.createSimulation([a], [], () => true); + const kinetic = sim.step(); + // Gravity alone (-x*GRAVITY at x=0) contributes nothing either, so a + // lone node at the origin has zero net force and should stay put. + assert.equal(kinetic, 0); + assert.equal(a.x, 0); + assert.equal(a.y, 0); +}); diff --git a/tests/test_dump_debug_graph.py b/tests/test_dump_debug_graph.py new file mode 100644 index 0000000..21f0854 --- /dev/null +++ b/tests/test_dump_debug_graph.py @@ -0,0 +1,566 @@ +"""Unit tests for scripts/dump_debug_graph.py. + +Run with: + ~/py314/bin/python -m pytest tests/ --cov=dump_debug_graph --cov-report=term-missing + +The gdb/debugpy-facing functions (run_gdb, run_debugpy) are exercised here +against fake subprocesses/sockets rather than real gdb or debugpy, so the +suite runs fast and needs no toolchain installed. The real toolchain path +is still covered end-to-end by running scripts/dump_debug_graph.py itself +(see its own module docstring) -- these tests are for the parsing/ +orchestration logic, not a replacement for that integration check. +""" +import json +import subprocess + +import pytest + +import dump_debug_graph as ddg + +# Real raw MI `value="..."` payloads, captured verbatim from an actual +# `gdb --interpreter=mi` / rust-gdb run against the built cpp/rust binaries +# (see git history of this file for how). Both go through +# parse_like_extension's fallback ("whole thing is itself a JSON string +# literal") branch, since gdb reports a `char*`/`&str` return value the +# same way regardless of the source language. +CPP_RAW_MI_VALUE = '\\"{\\\\\\"kind\\\\\\": {\\\\\\"nodeTable\\\\\\": true}, \\\\\\"nodes\\\\\\": [{\\\\\\"id\\\\\\": \\\\\\"0x55555556b020\\\\\\", \\\\\\"fields\\\\\\": [{\\\\\\"name\\\\\\": \\\\\\"data\\\\\\", \\\\\\"value\\\\\\": \\\\\\"10\\\\\\", \\\\\\"isPointer\\\\\\": false, \\\\\\"typeHint\\\\\\": \\\\\\"int\\\\\\"}, {\\\\\\"name\\\\\\": \\\\\\"next\\\\\\", \\\\\\"value\\\\\\": \\\\\\"0x0\\\\\\", \\\\\\"isPointer\\\\\\": true, \\\\\\"typeHint\\\\\\": \\\\\\"Node *\\\\\\"}], \\\\\\"type\\\\\\": \\\\\\"Node\\\\\\"}, {\\\\\\"id\\\\\\": \\\\\\"0x55555556b040\\\\\\", \\\\\\"fields\\\\\\": [{\\\\\\"name\\\\\\": \\\\\\"data\\\\\\", \\\\\\"value\\\\\\": \\\\\\"20\\\\\\", \\\\\\"isPointer\\\\\\": false, \\\\\\"typeHint\\\\\\": \\\\\\"int\\\\\\"}, {\\\\\\"name\\\\\\": \\\\\\"next\\\\\\", \\\\\\"value\\\\\\": \\\\\\"0x55555556b020\\\\\\", \\\\\\"isPointer\\\\\\": true, \\\\\\"typeHint\\\\\\": \\\\\\"Node *\\\\\\"}], \\\\\\"type\\\\\\": \\\\\\"Node\\\\\\"}, {\\\\\\"id\\\\\\": \\\\\\"0x55555556b060\\\\\\", \\\\\\"fields\\\\\\": [{\\\\\\"name\\\\\\": \\\\\\"data\\\\\\", \\\\\\"value\\\\\\": \\\\\\"30\\\\\\", \\\\\\"isPointer\\\\\\": false, \\\\\\"typeHint\\\\\\": \\\\\\"int\\\\\\"}, {\\\\\\"name\\\\\\": \\\\\\"next\\\\\\", \\\\\\"value\\\\\\": \\\\\\"0x55555556b040\\\\\\", \\\\\\"isPointer\\\\\\": true, \\\\\\"typeHint\\\\\\": \\\\\\"Node *\\\\\\"}], \\\\\\"type\\\\\\": \\\\\\"Node\\\\\\"}, {\\\\\\"id\\\\\\": \\\\\\"0x7fffffffd478\\\\\\", \\\\\\"fields\\\\\\": [{\\\\\\"name\\\\\\": \\\\\\"head_\\\\\\", \\\\\\"value\\\\\\": \\\\\\"0x55555556b060\\\\\\", \\\\\\"isPointer\\\\\\": true, \\\\\\"typeHint\\\\\\": \\\\\\"Node *\\\\\\"}], \\\\\\"type\\\\\\": \\\\\\"LinkedList\\\\\\"}, {\\\\\\"id\\\\\\": \\\\\\"0x555555556008\\\\\\", \\\\\\"fields\\\\\\": [{\\\\\\"name\\\\\\": \\\\\\"value\\\\\\", \\\\\\"value\\\\\\": \\\\\\"hello world\\\\\\", \\\\\\"isPointer\\\\\\": false, \\\\\\"typeHint\\\\\\": \\\\\\"char\\\\\\"}], \\\\\\"type\\\\\\": \\\\\\"char *\\\\\\"}], \\\\\\"edges\\\\\\": [{\\\\\\"from\\\\\\": \\\\\\"0x7fffffffd478\\\\\\", \\\\\\"to\\\\\\": \\\\\\"0x55555556b060\\\\\\", \\\\\\"label\\\\\\": \\\\\\"head_\\\\\\"}, {\\\\\\"from\\\\\\": \\\\\\"0x55555556b060\\\\\\", \\\\\\"to\\\\\\": \\\\\\"0x55555556b040\\\\\\", \\\\\\"label\\\\\\": \\\\\\"next\\\\\\"}, {\\\\\\"from\\\\\\": \\\\\\"0x55555556b040\\\\\\", \\\\\\"to\\\\\\": \\\\\\"0x55555556b020\\\\\\", \\\\\\"label\\\\\\": \\\\\\"next\\\\\\"}], \\\\\\"roots\\\\\\": [{\\\\\\"name\\\\\\": \\\\\\"top\\\\\\", \\\\\\"value\\\\\\": \\\\\\"0x55555556b060\\\\\\", \\\\\\"kind\\\\\\": \\\\\\"local\\\\\\", \\\\\\"type\\\\\\": \\\\\\"const int *\\\\\\"}, {\\\\\\"name\\\\\\": \\\\\\"count\\\\\\", \\\\\\"value\\\\\\": \\\\\\"3\\\\\\", \\\\\\"kind\\\\\\": \\\\\\"local\\\\\\", \\\\\\"type\\\\\\": \\\\\\"int\\\\\\"}, {\\\\\\"name\\\\\\": \\\\\\"message1\\\\\\", \\\\\\"value\\\\\\": \\\\\\"0x555555556008\\\\\\", \\\\\\"kind\\\\\\": \\\\\\"local\\\\\\", \\\\\\"type\\\\\\": \\\\\\"char *\\\\\\"}]}\\"' + +RUST_RAW_MI_VALUE = '\\"{\\\\\\"kind\\\\\\": {\\\\\\"nodeTable\\\\\\": true}, \\\\\\"nodes\\\\\\": [{\\\\\\"id\\\\\\": \\\\\\"0x5555555add50\\\\\\", \\\\\\"fields\\\\\\": [{\\\\\\"name\\\\\\": \\\\\\"data\\\\\\", \\\\\\"value\\\\\\": \\\\\\"10\\\\\\", \\\\\\"isPointer\\\\\\": false, \\\\\\"typeHint\\\\\\": \\\\\\"i32\\\\\\"}, {\\\\\\"name\\\\\\": \\\\\\"next\\\\\\", \\\\\\"value\\\\\\": \\\\\\"core::option::Option, alloc::alloc::Global>>::None\\\\\\", \\\\\\"isPointer\\\\\\": false, \\\\\\"typeHint\\\\\\": \\\\\\"core::option::Option, alloc::alloc::Global>>\\\\\\"}], \\\\\\"type\\\\\\": \\\\\\"list_example::Node\\\\\\"}, {\\\\\\"id\\\\\\": \\\\\\"0x5555555add70\\\\\\", \\\\\\"fields\\\\\\": [{\\\\\\"name\\\\\\": \\\\\\"data\\\\\\", \\\\\\"value\\\\\\": \\\\\\"20\\\\\\", \\\\\\"isPointer\\\\\\": false, \\\\\\"typeHint\\\\\\": \\\\\\"i32\\\\\\"}, {\\\\\\"name\\\\\\": \\\\\\"next\\\\\\", \\\\\\"value\\\\\\": \\\\\\"0x5555555add50\\\\\\", \\\\\\"isPointer\\\\\\": true, \\\\\\"typeHint\\\\\\": \\\\\\"core::option::Option, alloc::alloc::Global>>\\\\\\"}], \\\\\\"type\\\\\\": \\\\\\"list_example::Node\\\\\\"}, {\\\\\\"id\\\\\\": \\\\\\"0x5555555add90\\\\\\", \\\\\\"fields\\\\\\": [{\\\\\\"name\\\\\\": \\\\\\"data\\\\\\", \\\\\\"value\\\\\\": \\\\\\"30\\\\\\", \\\\\\"isPointer\\\\\\": false, \\\\\\"typeHint\\\\\\": \\\\\\"i32\\\\\\"}, {\\\\\\"name\\\\\\": \\\\\\"next\\\\\\", \\\\\\"value\\\\\\": \\\\\\"0x5555555add70\\\\\\", \\\\\\"isPointer\\\\\\": true, \\\\\\"typeHint\\\\\\": \\\\\\"core::option::Option, alloc::alloc::Global>>\\\\\\"}], \\\\\\"type\\\\\\": \\\\\\"list_example::Node\\\\\\"}, {\\\\\\"id\\\\\\": \\\\\\"0x7fffffffd0a0\\\\\\", \\\\\\"fields\\\\\\": [{\\\\\\"name\\\\\\": \\\\\\"head\\\\\\", \\\\\\"value\\\\\\": \\\\\\"0x5555555add90\\\\\\", \\\\\\"isPointer\\\\\\": true, \\\\\\"typeHint\\\\\\": \\\\\\"core::option::Option, alloc::alloc::Global>>\\\\\\"}], \\\\\\"type\\\\\\": \\\\\\"list_example::LinkedList\\\\\\"}, {\\\\\\"id\\\\\\": \\\\\\"0x5555555add98\\\\\\", \\\\\\"fields\\\\\\": [{\\\\\\"name\\\\\\": \\\\\\"value\\\\\\", \\\\\\"value\\\\\\": \\\\\\"30\\\\\\", \\\\\\"isPointer\\\\\\": false, \\\\\\"typeHint\\\\\\": \\\\\\"i32\\\\\\"}], \\\\\\"type\\\\\\": \\\\\\"*mut i32\\\\\\"}, {\\\\\\"id\\\\\\": \\\\\\"0x7fffffffd0b0\\\\\\", \\\\\\"fields\\\\\\": [{\\\\\\"name\\\\\\": \\\\\\"value\\\\\\", \\\\\\"value\\\\\\": \\\\\\"\\\\\\\\\\\\\\"hello world\\\\\\\\\\\\\\"\\\\\\", \\\\\\"isPointer\\\\\\": false, \\\\\\"typeHint\\\\\\": \\\\\\"&str\\\\\\"}], \\\\\\"type\\\\\\": \\\\\\"&str\\\\\\"}], \\\\\\"edges\\\\\\": [{\\\\\\"from\\\\\\": \\\\\\"0x7fffffffd0a0\\\\\\", \\\\\\"to\\\\\\": \\\\\\"0x5555555add90\\\\\\", \\\\\\"label\\\\\\": \\\\\\"head\\\\\\"}, {\\\\\\"from\\\\\\": \\\\\\"0x5555555add90\\\\\\", \\\\\\"to\\\\\\": \\\\\\"0x5555555add70\\\\\\", \\\\\\"label\\\\\\": \\\\\\"next\\\\\\"}, {\\\\\\"from\\\\\\": \\\\\\"0x5555555add70\\\\\\", \\\\\\"to\\\\\\": \\\\\\"0x5555555add50\\\\\\", \\\\\\"label\\\\\\": \\\\\\"next\\\\\\"}], \\\\\\"roots\\\\\\": [{\\\\\\"name\\\\\\": \\\\\\"top\\\\\\", \\\\\\"value\\\\\\": \\\\\\"0x5555555add98\\\\\\", \\\\\\"kind\\\\\\": \\\\\\"local\\\\\\", \\\\\\"type\\\\\\": \\\\\\"*mut i32\\\\\\"}, {\\\\\\"name\\\\\\": \\\\\\"message1\\\\\\", \\\\\\"value\\\\\\": \\\\\\"0x7fffffffd0b0\\\\\\", \\\\\\"kind\\\\\\": \\\\\\"local\\\\\\", \\\\\\"type\\\\\\": \\\\\\"&str\\\\\\"}, {\\\\\\"name\\\\\\": \\\\\\"count\\\\\\", \\\\\\"value\\\\\\": \\\\\\"3\\\\\\", \\\\\\"kind\\\\\\": \\\\\\"local\\\\\\", \\\\\\"type\\\\\\": \\\\\\"i32\\\\\\"}]}\\"' + +# Real raw `body.result` string, captured verbatim from an actual debugpy +# `evaluate` response: debugpy's repr() of the JSON string debug_graph() +# returned. +PY_RAW_EVALUATE_RESULT = '\'{"kind": {"nodeTable": true}, "nodes": [{"id": "0x7c61539b30e0", "fields": [{"name": "data", "value": "10", "isPointer": false, "typeHint": "int"}, {"name": "next", "value": "None", "isPointer": false, "typeHint": "NoneType"}], "type": "Node"}, {"id": "0x7c61539c87d0", "fields": [{"name": "data", "value": "20", "isPointer": false, "typeHint": "int"}, {"name": "next", "value": "0x7c61539b30e0", "isPointer": true, "typeHint": "Node"}], "type": "Node"}, {"id": "0x7c61539c9450", "fields": [{"name": "data", "value": "30", "isPointer": false, "typeHint": "int"}, {"name": "next", "value": "0x7c61539c87d0", "isPointer": true, "typeHint": "Node"}], "type": "Node"}, {"id": "0x7c61539b2f90", "fields": [{"name": "_head", "value": "0x7c61539c9450", "isPointer": true, "typeHint": "Node"}], "type": "LinkedList"}], "edges": [{"from": "0x7c61539b2f90", "to": "0x7c61539c9450", "label": "_head"}, {"from": "0x7c61539c9450", "to": "0x7c61539c87d0", "label": "next"}, {"from": "0x7c61539c87d0", "to": "0x7c61539b30e0", "label": "next"}], "roots": [{"name": "count", "value": "3", "kind": "local", "type": "int"}, {"name": "message1", "value": "hello world", "kind": "local", "type": "str"}, {"name": "top", "value": "30", "kind": "local", "type": "int"}, {"name": "Optional", "value": "typing.Optional", "kind": "global", "type": "_SpecialForm"}, {"name": "T", "value": "~T", "kind": "global", "type": "TypeVar"}]}\'' + + +# --- is_enclosed_with ------------------------------------------------- + +@pytest.mark.parametrize("s,ch,expected", [ + ('"hello"', '"', True), + ("'hello'", "'", True), + ('"hello"', "'", False), + ('"', '"', False), # single char: startswith and endswith the same char, but len < 2 + ("", '"', False), + ('"a', '"', False), +]) +def test_is_enclosed_with(s, ch, expected): + assert ddg.is_enclosed_with(s, ch) is expected + + +# --- parse_like_extension ---------------------------------------------- + +def test_parse_like_extension_cpp_real_capture(): + parsed = ddg.parse_like_extension(CPP_RAW_MI_VALUE) + assert parsed["ok"] is True + data = parsed["data"] + assert data["kind"] == {"nodeTable": True} + node_shaped = [n for n in data["nodes"] if [f["name"] for f in n["fields"]] == ["data", "next"]] + assert len(node_shaped) == 3 + assert {r["name"] for r in data["roots"]} == {"top", "count", "message1"} + + +def test_parse_like_extension_rust_real_capture(): + parsed = ddg.parse_like_extension(RUST_RAW_MI_VALUE) + assert parsed["ok"] is True + data = parsed["data"] + roots_by_name = {r["name"]: r for r in data["roots"]} + assert roots_by_name["message1"]["type"] == "&str" + # the &str node itself resolved the fat pointer to the real text + str_node = next(n for n in data["nodes"] if n["type"] == "&str") + assert str_node["fields"][0]["value"] == '"hello world"' + + +def test_parse_like_extension_simple_quoted_json(): + """The `is_enclosed_with` fast path: a plain string value that, once + MI-unescaped, is itself already valid JSON wrapped in one layer of + quotes (no double string-literal nesting).""" + payload = json.dumps({"kind": {"nodeTable": True}, "nodes": [], "edges": [], "roots": []}) + mi_value = '\\"%s\\"' % payload.replace('"', '\\\\\\"') + parsed = ddg.parse_like_extension(mi_value) + assert parsed["ok"] is True + assert parsed["data"]["nodes"] == [] + + +def test_parse_like_extension_malformed_reports_error_and_raw(): + parsed = ddg.parse_like_extension("not valid json at all") + assert parsed["ok"] is False + assert "error" in parsed + assert parsed["raw"] == "not valid json at all" + + +# --- parse_python_result ------------------------------------------------- + +def test_parse_python_result_real_capture(): + parsed = ddg.parse_python_result(PY_RAW_EVALUATE_RESULT) + assert parsed["ok"] is True + data = parsed["data"] + roots_by_name = {r["name"]: r for r in data["roots"]} + assert roots_by_name["count"]["value"] == "3" + assert roots_by_name["top"]["kind"] == "local" + assert roots_by_name["Optional"]["kind"] == "global" + + +def test_parse_python_result_malformed_reports_error_and_raw(): + parsed = ddg.parse_python_result("this is not a repr'd string") + assert parsed["ok"] is False + assert "error" in parsed + assert parsed["raw"] == "this is not a repr'd string" + + +def test_parse_python_result_valid_repr_but_not_json(): + parsed = ddg.parse_python_result(repr("not json")) + assert parsed["ok"] is False + assert parsed["raw"] == repr("not json") + + +# --- verify_top_delves_into_list / _py ---------------------------------- + +def _node(id_, *field_names): + return {"id": id_, "fields": [{"name": n} for n in field_names]} + + +def test_verify_top_delves_into_list_success(): + data = { + "nodes": [_node("0x1", "data", "next"), _node("0x2", "data", "next")], + "edges": [{"label": "next"}, {"label": "next"}], + "roots": [{"name": "list", "kind": "local"}], + } + assert ddg.verify_top_delves_into_list(data) is True + + +def test_verify_top_delves_into_list_fails_without_node_shaped_node(): + data = {"nodes": [_node("0x1", "value")], "edges": [], "roots": [{"name": "list", "kind": "local"}]} + assert ddg.verify_top_delves_into_list(data) is False + + +def test_verify_top_delves_into_list_fails_with_too_few_next_edges(): + data = { + "nodes": [_node("0x1", "data", "next")], + "edges": [{"label": "next"}], + "roots": [{"name": "list", "kind": "local"}], + } + assert ddg.verify_top_delves_into_list(data) is False + + +def test_verify_top_delves_into_list_fails_without_list_root(): + data = { + "nodes": [_node("0x1", "data", "next")], + "edges": [{"label": "next"}, {"label": "next"}], + "roots": [{"name": "something_else", "kind": "local"}], + } + assert ddg.verify_top_delves_into_list(data) is False + + +def test_verify_top_delves_into_list_py_success(): + data = { + "nodes": [_node("0x1", "data", "next"), _node("0x2", "data", "next")], + "edges": [{"label": "next"}, {"label": "next"}], + "roots": [{"name": "lst", "kind": "local"}], + } + assert ddg.verify_top_delves_into_list_py(data) is True + + +def test_verify_top_delves_into_list_py_fails_without_lst_root(): + data = { + "nodes": [_node("0x1", "data", "next")], + "edges": [{"label": "next"}, {"label": "next"}], + "roots": [{"name": "list", "kind": "local"}], # note: cpp/rust name, not python's + } + assert ddg.verify_top_delves_into_list_py(data) is False + + +def test_verify_top_delves_into_list_py_fails_without_node_shaped_node(): + data = {"nodes": [_node("0x1", "value")], "edges": [], "roots": [{"name": "lst", "kind": "local"}]} + assert ddg.verify_top_delves_into_list_py(data) is False + + +def test_verify_top_delves_into_list_py_fails_with_too_few_next_edges(): + data = { + "nodes": [_node("0x1", "data", "next")], + "edges": [{"label": "next"}], + "roots": [{"name": "lst", "kind": "local"}], + } + assert ddg.verify_top_delves_into_list_py(data) is False + + +# --- _write_dump ---------------------------------------------------------- + +def test_write_dump_writes_indented_json(tmp_path): + ddg._write_dump("mylabel", tmp_path, {"nodes": [], "roots": []}) + out_path = tmp_path / "debug_mylabel.json" + assert out_path.exists() + assert json.loads(out_path.read_text()) == {"nodes": [], "roots": []} + + +def test_write_dump_reports_parse_error_in_output(tmp_path, capsys): + ddg._write_dump("bad", tmp_path, {"PARSE_ERROR": "boom"}) + captured = capsys.readouterr() + assert "PARSE_ERROR" in captured.out + + +# --- find_debugpy_python -------------------------------------------------- + +def test_find_debugpy_python_prefers_env_override(monkeypatch): + monkeypatch.setenv("DUMP_DEBUG_GRAPH_PYTHON", "/fake/python") + seen = [] + + def fake_run(cmd, **kwargs): + seen.append(cmd[0]) + return subprocess.CompletedProcess(cmd, 0) + + monkeypatch.setattr(subprocess, "run", fake_run) + result = ddg.find_debugpy_python() + assert result == "/fake/python" + assert seen[0] == "/fake/python" + + +def test_find_debugpy_python_falls_through_candidates(monkeypatch): + monkeypatch.delenv("DUMP_DEBUG_GRAPH_PYTHON", raising=False) + + def fake_run(cmd, **kwargs): + if cmd[0] != "/only/this/one/works": + raise subprocess.CalledProcessError(1, cmd) + return subprocess.CompletedProcess(cmd, 0) + + monkeypatch.setattr(subprocess, "run", fake_run) + monkeypatch.setattr(ddg.shutil, "which", lambda name: "/only/this/one/works") + result = ddg.find_debugpy_python() + assert result == "/only/this/one/works" + + +def test_find_debugpy_python_raises_when_none_found(monkeypatch): + monkeypatch.delenv("DUMP_DEBUG_GRAPH_PYTHON", raising=False) + monkeypatch.setattr(subprocess, "run", lambda cmd, **kw: (_ for _ in ()).throw(FileNotFoundError())) + monkeypatch.setattr(ddg.shutil, "which", lambda name: None) + with pytest.raises(RuntimeError): + ddg.find_debugpy_python() + + +def test_find_debugpy_python_skips_falsy_candidates(monkeypatch): + """sys.executable can (rarely) be empty; the candidate loop must skip + it rather than passing "" to subprocess.run.""" + monkeypatch.delenv("DUMP_DEBUG_GRAPH_PYTHON", raising=False) + monkeypatch.setattr(ddg.sys, "executable", "") + monkeypatch.setattr(ddg.shutil, "which", lambda name: "/found/python3") + seen = [] + + def fake_run(cmd, **kwargs): + seen.append(cmd[0]) + if cmd[0] != "/found/python3": + raise subprocess.CalledProcessError(1, cmd) + return subprocess.CompletedProcess(cmd, 0) + + monkeypatch.setattr(subprocess, "run", fake_run) + result = ddg.find_debugpy_python() + assert result == "/found/python3" + assert "" not in seen + + +# --- _DapClient wire protocol ---------------------------------------------- + +class _FakeSocket: + """Feeds back pre-queued bytes to _DapClient.recv(), split arbitrarily + to prove _read_message() reassembles a frame from partial reads.""" + + def __init__(self, chunks): + self._chunks = list(chunks) + self.sent = b"" + + def sendall(self, data): + self.sent += data + + def recv(self, bufsize): + if not self._chunks: + return b"" + return self._chunks.pop(0) + + +def _frame(msg): + body = json.dumps(msg).encode("utf-8") + return ("Content-Length: %d\r\n\r\n" % len(body)).encode("ascii") + body + + +def test_dap_client_send_frames_content_length_correctly(): + sock = _FakeSocket([]) + client = ddg._DapClient(sock) + seq = client.send("initialize", {"foo": "bar"}) + assert seq == 1 + header, _, rest = sock.sent.partition(b"\r\n\r\n") + length = int(header.split(b":")[1].strip()) + assert len(rest) == length + msg = json.loads(rest.decode()) + assert msg == {"seq": 1, "type": "request", "command": "initialize", "arguments": {"foo": "bar"}} + + +def test_dap_client_reassembles_message_split_across_recv_calls(): + whole = _frame({"seq": 1, "type": "response", "command": "initialize", "success": True}) + # split the single frame into three arbitrary chunks to exercise the + # partial-read loop in both the header and body phases + chunks = [whole[:10], whole[10:40], whole[40:]] + sock = _FakeSocket(chunks) + client = ddg._DapClient(sock) + msg = client.wait_for(lambda m: m.get("command") == "initialize") + assert msg["success"] is True + + +def test_dap_client_wait_for_skips_non_matching_messages(): + chunks = [ + _frame({"seq": 1, "type": "event", "event": "output"}), + _frame({"seq": 2, "type": "response", "command": "evaluate", "request_seq": 5}), + ] + sock = _FakeSocket(chunks) + client = ddg._DapClient(sock) + msg = client.wait_for(lambda m: m.get("command") == "evaluate") + assert msg["request_seq"] == 5 + + +def test_dap_client_read_message_raises_eof_on_closed_socket(): + sock = _FakeSocket([]) # recv() immediately returns b"" + client = ddg._DapClient(sock) + with pytest.raises(EOFError): + client._read_message() + + +def test_dap_client_read_message_raises_eof_when_body_truncated(): + """Header parses fine (so length is known), but the socket closes + before the full body arrives -- the second (body) read loop's EOF.""" + whole = _frame({"seq": 1, "type": "response", "command": "initialize"}) + header_end = whole.index(b"\r\n\r\n") + 4 + truncated_body = whole[header_end:header_end + 3] # a few body bytes, not all + sock = _FakeSocket([whole[:header_end] + truncated_body]) + client = ddg._DapClient(sock) + with pytest.raises(EOFError): + client._read_message() + + +def test_dap_client_wait_for_times_out(monkeypatch): + sock = _FakeSocket([_frame({"seq": 1, "type": "event", "event": "output"})] * 5) + client = ddg._DapClient(sock) + with pytest.raises(TimeoutError): + client.wait_for(lambda m: False, timeout=0) + + +# --- run_gdb --------------------------------------------------------------- + +def test_run_gdb_extracts_last_done_value(monkeypatch): + mi_output = ( + '(gdb)\n' + '^done\n' + '^done,value="first"\n' + '^done,value="second"\n' + ).encode() + + def fake_run(cmd, **kwargs): + assert cmd[0] == "gdb" + return subprocess.CompletedProcess(cmd, 0, stdout=mi_output) + + monkeypatch.setattr(subprocess, "run", fake_run) + result = ddg.run_gdb("gdb", "/bin/true", "main.cpp", 10) + assert result == "second" + + +def test_run_gdb_raises_when_no_done_value(monkeypatch): + mi_output = b'^error,msg="No symbol table is loaded"\n' + + def fake_run(cmd, **kwargs): + return subprocess.CompletedProcess(cmd, 0, stdout=mi_output) + + monkeypatch.setattr(subprocess, "run", fake_run) + with pytest.raises(RuntimeError, match="No symbol table"): + ddg.run_gdb("gdb", "/bin/true", "main.cpp", 10) + + +# --- run_debugpy (fully mocked DAP session) -------------------------------- + +class _FakeDapServerSocket: + """Plays the debugpy side of the handshake in-memory: parses each + framed request dump_debug_graph.run_debugpy sends and queues back + exactly the response/event sequence a real debugpy adapter sends for + a --wait-for-client launch that immediately hits one breakpoint.""" + + def __init__(self, evaluate_result): + self._evaluate_result = evaluate_result + self._out = b"" + self._server_seq = 100 + self._pending_attach_seq = None + + def __enter__(self): + return self + + def __exit__(self, *exc): + return False + + def _emit(self, msg): + self._server_seq += 1 + msg = dict(msg, seq=self._server_seq) + self._out += _frame(msg) + + def sendall(self, data): + header, _, rest = data.partition(b"\r\n\r\n") + length = int(header.split(b":")[1].strip()) + req = json.loads(rest[:length].decode()) + cmd, seq = req["command"], req["seq"] + + if cmd == "initialize": + self._emit({"type": "response", "command": cmd, "request_seq": seq, "success": True}) + self._emit({"type": "event", "event": "initialized"}) + elif cmd == "attach": + self._pending_attach_seq = seq # debugpy defers this until after configurationDone + elif cmd == "setBreakpoints": + self._emit({"type": "response", "command": cmd, "request_seq": seq, "success": True}) + elif cmd == "configurationDone": + self._emit({"type": "response", "command": cmd, "request_seq": seq, "success": True}) + self._emit({"type": "response", "command": "attach", "request_seq": self._pending_attach_seq, "success": True}) + self._emit({"type": "event", "event": "stopped", "body": {"threadId": 1, "reason": "breakpoint"}}) + elif cmd == "stackTrace": + self._emit({ + "type": "response", "command": cmd, "request_seq": seq, "success": True, + "body": {"stackFrames": [{"id": 7, "name": "main"}]}, + }) + elif cmd == "evaluate": + self._emit({ + "type": "response", "command": cmd, "request_seq": seq, "success": True, + "body": {"result": self._evaluate_result}, + }) + elif cmd == "continue": + self._emit({"type": "response", "command": cmd, "request_seq": seq, "success": True}) + else: + raise AssertionError("unexpected DAP command in test: %r" % cmd) + + def recv(self, bufsize): + chunk, self._out = self._out[:bufsize], self._out[bufsize:] + return chunk + + +class _FakeProcess: + def __init__(self): + self.killed = False + + def wait(self, timeout=None): + return 0 + + def kill(self): + self.killed = True + + +def test_run_debugpy_drives_full_handshake_and_returns_evaluate_result(monkeypatch): + fake_socket = _FakeDapServerSocket(evaluate_result="'{\"ok\": true}'") + fake_proc = _FakeProcess() + + monkeypatch.setattr(ddg.subprocess, "Popen", lambda *a, **kw: fake_proc) + monkeypatch.setattr(ddg.socket, "create_connection", lambda addr, timeout=None: fake_socket) + + result = ddg.run_debugpy("python3", "/tmp/main.py", 54, "debug_graph(lst)") + assert result == "'{\"ok\": true}'" + + +def test_run_debugpy_raises_when_listen_socket_never_opens(monkeypatch): + monkeypatch.setattr(ddg.subprocess, "Popen", lambda *a, **kw: _FakeProcess()) + monkeypatch.setattr(ddg.time, "sleep", lambda s: None) # don't actually wait out the 50 retries + + def never_connects(addr, timeout=None): + raise OSError("connection refused") + + monkeypatch.setattr(ddg.socket, "create_connection", never_connects) + with pytest.raises(RuntimeError, match="never opened its listen socket"): + ddg.run_debugpy("python3", "/tmp/main.py", 54, "debug_graph(lst)") + + +def test_run_debugpy_kills_process_if_wait_times_out_on_cleanup(monkeypatch): + fake_socket = _FakeDapServerSocket(evaluate_result="'{}'") + + class _SlowProcess(_FakeProcess): + def __init__(self): + super().__init__() + self.wait_calls = 0 + + def wait(self, timeout=None): + self.wait_calls += 1 + if self.wait_calls == 1: + raise subprocess.TimeoutExpired(cmd="debugpy", timeout=timeout) + return 0 + + fake_proc = _SlowProcess() + monkeypatch.setattr(ddg.subprocess, "Popen", lambda *a, **kw: fake_proc) + monkeypatch.setattr(ddg.socket, "create_connection", lambda addr, timeout=None: fake_socket) + + result = ddg.run_debugpy("python3", "/tmp/main.py", 54, "debug_graph(lst)") + assert result == "'{}'" + assert fake_proc.killed is True + assert fake_proc.wait_calls == 2 + + +def test_run_debugpy_raises_when_evaluate_fails(monkeypatch): + class _FailingEvalSocket(_FakeDapServerSocket): + def sendall(self, data): + header, _, rest = data.partition(b"\r\n\r\n") + length = int(header.split(b":")[1].strip()) + req = json.loads(rest[:length].decode()) + if req["command"] == "evaluate": + self._emit({ + "type": "response", "command": "evaluate", "request_seq": req["seq"], + "success": False, "message": "bad expression", + }) + else: + super().sendall(data) + + fake_socket = _FailingEvalSocket(evaluate_result="unused") + monkeypatch.setattr(ddg.subprocess, "Popen", lambda *a, **kw: _FakeProcess()) + monkeypatch.setattr(ddg.socket, "create_connection", lambda addr, timeout=None: fake_socket) + + with pytest.raises(RuntimeError, match="bad expression"): + ddg.run_debugpy("python3", "/tmp/main.py", 54, "bad expr(") + + +# --- dump / dump_python orchestration --------------------------------------- + +def test_dump_success_path_writes_verified_result(tmp_path, monkeypatch): + monkeypatch.setattr(ddg, "run_gdb", lambda *a, **kw: CPP_RAW_MI_VALUE) + ok = ddg.dump("cpp", "gdb", "/bin/true", "main.cpp", 70, tmp_path, verify=lambda data: True) + assert ok is True + assert json.loads((tmp_path / "debug_cpp.json").read_text())["kind"] == {"nodeTable": True} + + +def test_dump_reports_failure_when_verify_rejects(tmp_path, monkeypatch): + monkeypatch.setattr(ddg, "run_gdb", lambda *a, **kw: CPP_RAW_MI_VALUE) + ok = ddg.dump("cpp", "gdb", "/bin/true", "main.cpp", 70, tmp_path, verify=lambda data: False) + assert ok is False + + +def test_dump_reports_failure_when_gdb_invocation_raises(tmp_path, monkeypatch): + def fake_run_gdb(*a, **kw): + raise RuntimeError("gdb exploded") + + monkeypatch.setattr(ddg, "run_gdb", fake_run_gdb) + ok = ddg.dump("cpp", "gdb", "/bin/true", "main.cpp", 70, tmp_path) + assert ok is False + written = json.loads((tmp_path / "debug_cpp.json").read_text()) + assert written["PARSE_ERROR"] == "gdb invocation failed" + assert "gdb exploded" in written["detail"] + + +def test_dump_reports_failure_when_result_unparseable(tmp_path, monkeypatch): + monkeypatch.setattr(ddg, "run_gdb", lambda *a, **kw: "not json at all") + ok = ddg.dump("cpp", "gdb", "/bin/true", "main.cpp", 70, tmp_path) + assert ok is False + written = json.loads((tmp_path / "debug_cpp.json").read_text()) + assert "PARSE_ERROR" in written + + +def test_dump_python_success_path_writes_verified_result(tmp_path, monkeypatch): + monkeypatch.setattr(ddg, "run_debugpy", lambda *a, **kw: PY_RAW_EVALUATE_RESULT) + ok = ddg.dump_python("python", "python3", "/tmp/main.py", 54, tmp_path, verify=lambda data: True) + assert ok is True + assert json.loads((tmp_path / "debug_python.json").read_text())["kind"] == {"nodeTable": True} + + +def test_dump_python_reports_failure_when_debugpy_invocation_raises(tmp_path, monkeypatch): + def fake_run_debugpy(*a, **kw): + raise RuntimeError("debugpy exploded") + + monkeypatch.setattr(ddg, "run_debugpy", fake_run_debugpy) + ok = ddg.dump_python("python", "python3", "/tmp/main.py", 54, tmp_path) + assert ok is False + written = json.loads((tmp_path / "debug_python.json").read_text()) + assert written["PARSE_ERROR"] == "debugpy invocation failed" + assert "debugpy exploded" in written["detail"] diff --git a/tests/test_gdb_debug_graph.py b/tests/test_gdb_debug_graph.py new file mode 100644 index 0000000..0ad59d8 --- /dev/null +++ b/tests/test_gdb_debug_graph.py @@ -0,0 +1,833 @@ +"""Unit tests for debuggers/gdb_debug_graph.py, driven against a fake +`gdb` module (tests/fake_gdb.py) instead of a real gdb process -- see that +module's docstring for why addresses are plain test-supplied ints rather +than derived from id(). +""" +import sys + +import fake_gdb as fg + +sys.modules["gdb"] = fg +import gdb_debug_graph as gg # noqa: E402 + +INT_T = fg.Type(fg.TYPE_CODE_INT, sizeof=4, display="int") +CHAR_T = fg.Type(fg.TYPE_CODE_CHAR, sizeof=1, display="char") + + +def make_node_types(): + node_type = fg.Type(fg.TYPE_CODE_STRUCT, tag="Node") + node_ptr_type = fg.Type(fg.TYPE_CODE_PTR, target=node_type, display="Node *") + node_type._fields = [fg.Field("data"), fg.Field("next")] + return node_type, node_ptr_type + + +def make_node(node_type, addr, data, next_ptr_value): + return fg.Value(node_type, address=addr, fields={ + "data": fg.Value(INT_T, py_value=data), + "next": next_ptr_value, + }) + + +def ptr_to(node_ptr_type, addr, pointee): + return fg.Value(node_ptr_type, ptr_int=addr, pointee=pointee) + + +def null_ptr(ptr_type): + return fg.Value(ptr_type, ptr_int=0, pointee=None) + + +def build_chain(): + """30 -> 20 -> 10 -> None, matching the real cpp/rust demo list.""" + node_type, node_ptr_type = make_node_types() + n10 = make_node(node_type, 0x10, "10", null_ptr(node_ptr_type)) + n20 = make_node(node_type, 0x20, "20", ptr_to(node_ptr_type, 0x10, n10)) + n30 = make_node(node_type, 0x30, "30", ptr_to(node_ptr_type, 0x20, n20)) + return node_type, node_ptr_type, n30 + + +def _fields(node_json): + return {f["name"]: f for f in node_json["fields"]} + + +# --- basic struct/pointer walk -------------------------------------------- + +def test_simple_chain_walk(monkeypatch): + monkeypatch.setattr(fg, "selected_frame", lambda: (_ for _ in ()).throw(fg.error("no frame"))) + node_type, node_ptr_type, n30 = build_chain() + result_json = gg.build_graph_json(n30, []) + import json + data = json.loads(result_json) + + assert data["kind"] == {"nodeTable": True} + node_shaped = [n for n in data["nodes"] if set(_fields(n)) == {"data", "next"}] + assert len(node_shaped) == 3 + next_edges = [e for e in data["edges"] if e["label"] == "next"] + assert len(next_edges) == 2 + assert node_shaped[0]["type"] == "Node" + + +def test_null_pointer_produces_no_edge(): + node_type, node_ptr_type, n30 = build_chain() + result_json = gg.build_graph_json(n30, []) + import json + data = json.loads(result_json) + n10 = next(n for n in data["nodes"] if _fields(n)["data"]["value"] == "10") + assert _fields(n10)["next"]["value"] == "0x0" + assert _fields(n10)["next"]["isPointer"] is True + # a null pointer target ("0x0") must never appear as an edge endpoint + assert not any(e["to"] == "0x0" for e in data["edges"]) + + +def test_cycle_is_handled_without_infinite_recursion(): + node_type, node_ptr_type = make_node_types() + # placeholders so we can wire a->b->a + a = fg.Value(node_type, address=0x1) + b = fg.Value(node_type, address=0x2) + a._fields = {"data": fg.Value(INT_T, py_value="1"), "next": ptr_to(node_ptr_type, 0x2, b)} + b._fields = {"data": fg.Value(INT_T, py_value="2"), "next": ptr_to(node_ptr_type, 0x1, a)} + + result_json = gg.build_graph_json(a, []) + import json + data = json.loads(result_json) + assert len(data["nodes"]) == 2 + assert len(data["edges"]) == 2 + + +# --- char* handling --------------------------------------------------------- + +def test_char_star_field_shows_full_string_not_one_byte(): + node_type = fg.Type(fg.TYPE_CODE_STRUCT, tag="Holder") + char_ptr_t = fg.Type(fg.TYPE_CODE_PTR, target=CHAR_T, display="char *") + msg_ptr = fg.Value(char_ptr_t, ptr_int=0x900, py_value="hello world") + holder = fg.Value(node_type, address=0x800, fields={"message": msg_ptr}) + node_type._fields = [fg.Field("message")] + + result_json = gg.build_graph_json(holder, []) + import json + data = json.loads(result_json) + str_node = next(n for n in data["nodes"] if n["id"] == "0x900") + assert _fields(str_node)["value"]["value"] == "hello world" + + +def test_char_star_long_string_is_truncated_with_ellipsis(): + node_type = fg.Type(fg.TYPE_CODE_STRUCT, tag="Holder") + char_ptr_t = fg.Type(fg.TYPE_CODE_PTR, target=CHAR_T, display="char *") + long_text = "x" * 600 + msg_ptr = fg.Value(char_ptr_t, ptr_int=0x900, py_value=long_text) + holder = fg.Value(node_type, address=0x800, fields={"message": msg_ptr}) + node_type._fields = [fg.Field("message")] + + result_json = gg.build_graph_json(holder, []) + import json + data = json.loads(result_json) + str_node = next(n for n in data["nodes"] if n["id"] == "0x900") + value = _fields(str_node)["value"]["value"] + assert len(value) == 501 # 500 chars + ellipsis marker + assert value.endswith("…") + + +# --- pretty-printer summary (e.g. std::optional) ------------------------- + +class _FakePrinter: + def __init__(self, children=None, text=None): + self._children = children or [] + self._text = text + + def children(self): + return self._children + + def to_string(self): + return self._text + + +def test_pretty_printer_single_leaf_is_unwrapped(monkeypatch): + opt_type = fg.Type(fg.TYPE_CODE_STRUCT, tag="std::optional") + inner = fg.Value(INT_T, py_value="42") + engaged_val = fg.Value(opt_type, address=0x100, fields={}) + + monkeypatch.setattr( + fg, "default_visualizer", + lambda val: _FakePrinter(children=[("[contained value]", inner)]) if val is engaged_val else None, + ) + holder_type = fg.Type(fg.TYPE_CODE_STRUCT, tag="Holder") + holder = fg.Value(holder_type, address=0x200, fields={"maybe": engaged_val}) + holder_type._fields = [fg.Field("maybe")] + + import json + data = json.loads(gg.build_graph_json(holder, [])) + root = next(n for n in data["nodes"] if n["id"] == "0x200") + assert _fields(root)["maybe"]["value"] == "42" + assert _fields(root)["maybe"]["typeHint"] == "std::optional" + + +def test_pretty_printer_disengaged_shows_empty_text(monkeypatch): + opt_type = fg.Type(fg.TYPE_CODE_STRUCT, tag="std::optional") + disengaged_val = fg.Value(opt_type, address=0x100, fields={}) + + monkeypatch.setattr( + fg, "default_visualizer", + lambda val: _FakePrinter(children=[], text="std::nullopt") if val is disengaged_val else None, + ) + holder_type = fg.Type(fg.TYPE_CODE_STRUCT, tag="Holder") + holder = fg.Value(holder_type, address=0x200, fields={"maybe": disengaged_val}) + holder_type._fields = [fg.Field("maybe")] + + import json + data = json.loads(gg.build_graph_json(holder, [])) + root = next(n for n in data["nodes"] if n["id"] == "0x200") + assert _fields(root)["maybe"]["value"] == "std::nullopt" + + +def test_pretty_printer_multi_leaf_expands_into_dotted_fields(monkeypatch): + pair_type = fg.Type(fg.TYPE_CODE_STRUCT, tag="std::pair") + pair_val = fg.Value(pair_type, address=0x100, fields={}) + first = fg.Value(INT_T, py_value="1") + second = fg.Value(INT_T, py_value="2") + + monkeypatch.setattr( + fg, "default_visualizer", + lambda val: _FakePrinter(children=[("first", first), ("second", second)]) if val is pair_val else None, + ) + holder_type = fg.Type(fg.TYPE_CODE_STRUCT, tag="Holder") + holder = fg.Value(holder_type, address=0x200, fields={"p": pair_val}) + holder_type._fields = [fg.Field("p")] + + import json + data = json.loads(gg.build_graph_json(holder, [])) + root = next(n for n in data["nodes"] if n["id"] == "0x200") + fields = _fields(root) + assert fields["p.first"]["value"] == "1" + assert fields["p.second"]["value"] == "2" + + +# --- Rust enum/variant flattening ------------------------------------------- + +def test_variant_like_some_flattens_boxed_pointer_payload(): + """_flatten_variant only unwraps a variant's payload field when that + field's *own value* is itself struct-typed (real DWARF wraps a boxed + pointer this way) -- a bare pointer/int payload wouldn't have anything + further to recurse into and is handled by the caller's fallback + instead (see test_variant_like_none_falls_back_to_scalar_rendering). + The single-field-named-"__0" case aliases straight through with no + dotted suffix, so `next` reads as a plain pointer field.""" + node_type, node_ptr_type = make_node_types() + n10 = make_node(node_type, 0x10, "10", null_ptr(node_ptr_type)) + ptr_val = ptr_to(node_ptr_type, 0x10, n10) + + box_type = fg.Type(fg.TYPE_CODE_STRUCT, tag="Box>", fields=[fg.Field("__0")]) + box_val = fg.Value(box_type, fields={"__0": ptr_val}) + + option_type = fg.Type( + fg.TYPE_CODE_STRUCT, tag="Option>>", + fields=[fg.Field("discriminant", artificial=True), fg.Field("__0")], + ) + some_val = fg.Value(option_type, fields={"__0": box_val}) + + holder_type = fg.Type(fg.TYPE_CODE_STRUCT, tag="Holder") + holder = fg.Value(holder_type, address=0x200, fields={"next": some_val}) + holder_type._fields = [fg.Field("next")] + + import json + data = json.loads(gg.build_graph_json(holder, [])) + root = next(n for n in data["nodes"] if n["id"] == "0x200") + next_field = _fields(root)["next"] + assert next_field["isPointer"] is True + assert next_field["value"] == "0x10" + assert any(e["from"] == "0x200" and e["to"] == "0x10" and e["label"] == "next" for e in data["edges"]) + + +def test_variant_like_none_falls_back_to_scalar_rendering(): + # Same shape, but the "__0" field is inaccessible for the live (None) + # variant -- val["__0"] raises gdb.error, matching real gdb's behavior + # for an inactive Rust enum variant. + option_type = fg.Type( + fg.TYPE_CODE_STRUCT, tag="Option>>", + fields=[fg.Field("discriminant", artificial=True), fg.Field("__0")], + ) + none_val = fg.Value(option_type, fields={}, py_value="None") + + holder_type = fg.Type(fg.TYPE_CODE_STRUCT, tag="Holder") + holder = fg.Value(holder_type, address=0x200, fields={"next": none_val}) + holder_type._fields = [fg.Field("next")] + + import json + data = json.loads(gg.build_graph_json(holder, [])) + root = next(n for n in data["nodes"] if n["id"] == "0x200") + next_field = _fields(root)["next"] + assert next_field["value"] == "None" + assert next_field.get("isPointer", False) is False + + +def test_variant_like_multi_field_payload_gets_dotted_labels(): + """When the payload field's value is itself a *multi*-field struct + (e.g. a Rust tuple-variant carrying more than one value), each of that + inner struct's fields becomes its own "outer.inner" leaf instead of + aliasing straight through (aliasing is only for the single-field, + name=="__0" case).""" + rect_type = fg.Type(fg.TYPE_CODE_STRUCT, tag="Rect", fields=[fg.Field("width"), fg.Field("height")]) + rect_val = fg.Value(rect_type, fields={ + "width": fg.Value(INT_T, py_value="3"), + "height": fg.Value(INT_T, py_value="4"), + }) + + variant_type = fg.Type( + fg.TYPE_CODE_STRUCT, tag="Shape::Rectangle", + fields=[fg.Field("discriminant", artificial=True), fg.Field("__0")], + ) + shape_val = fg.Value(variant_type, fields={"__0": rect_val}) + + holder_type = fg.Type(fg.TYPE_CODE_STRUCT, tag="Holder") + holder = fg.Value(holder_type, address=0x200, fields={"shape": shape_val}) + holder_type._fields = [fg.Field("shape")] + + import json + data = json.loads(gg.build_graph_json(holder, [])) + root = next(n for n in data["nodes"] if n["id"] == "0x200") + fields = _fields(root) + assert fields["shape.width"]["value"] == "3" + assert fields["shape.height"]["value"] == "4" + + +# --- the "upgrade a scalar leaf to the full struct" address-sharing case ---- + +def test_top_scalar_pointer_gets_upgraded_by_richer_struct_walk(monkeypatch): + """The gdb analog of $debug_graph(top): `top` is a bare `const int*` + whose address coincides with a Node's first member (so it shares the + Node's own address), auto-discovered alongside `list` (a sibling + local). Whichever order they're walked in, the richer struct + description must win -- this is the exact mechanism verify_top_delves_ + into_list checks end-to-end; here it's isolated as a pure unit test + against _visit_pointer's node_id-vs-visited bookkeeping.""" + node_type, node_ptr_type, n30 = build_chain() # n30 lives at 0x30 + + list_type = fg.Type(fg.TYPE_CODE_STRUCT, tag="LinkedList") + list_val = fg.Value(list_type, address=0x999, fields={"head_": ptr_to(node_ptr_type, 0x30, n30)}) + list_type._fields = [fg.Field("head_")] + + const_int_ptr_t = fg.Type(fg.TYPE_CODE_PTR, target=INT_T, display="const int *") + # `top` points at 0x30 but its *static* type is a bare int*, sharing + # n30's address since `data` is the node's first member. + top = fg.Value(const_int_ptr_t, ptr_int=0x30, pointee=n30["data"]) + + frame = fg.Frame(fg.Block([fg.Symbol("list", list_val)], is_global=False, is_static=False)) + monkeypatch.setattr(fg, "selected_frame", lambda: frame) + + import json + data = json.loads(gg.build_graph_json(top, [])) + node_0x30 = next(n for n in data["nodes"] if n["id"] == "0x30") + # must be the rich Node (data+next), not the scalar leaf `top`'s + # walk would have produced on its own + assert set(_fields(node_0x30)) == {"data", "next"} + next_edges = [e for e in data["edges"] if e["label"] == "next"] + assert len(next_edges) == 2 + roots_by_name = {r["name"]: r for r in data["roots"]} + assert roots_by_name["list"]["kind"] == "local" + + +# --- frame-based local/global auto-discovery -------------------------------- + +def test_frame_locals_walk_nested_blocks_and_dedup_by_name(monkeypatch): + outer_sym = fg.Symbol("y", fg.Value(INT_T, py_value="2")) + # "x" shadowed in both blocks -- only the innermost should be reported + shadow_inner = fg.Symbol("x", fg.Value(INT_T, py_value="inner")) + outer_block = fg.Block([outer_sym, fg.Symbol("x", fg.Value(INT_T, py_value="outer-shadowed"))], is_global=False) + inner_block = fg.Block([shadow_inner], superblock=outer_block, is_global=False) + + frame = fg.Frame(inner_block) + monkeypatch.setattr(fg, "selected_frame", lambda: frame) + + import json + data = json.loads(gg.build_graph_json(fg.Value(INT_T, py_value="99"), [])) + roots_by_name = {r["name"]: r for r in data["roots"]} + assert roots_by_name["x"]["value"] == "inner" + assert roots_by_name["y"]["value"] == "2" + + +def test_frame_globals_from_symtab_are_discovered(monkeypatch): + global_sym = fg.Symbol("g_count", fg.Value(INT_T, py_value="7")) + static_sym = fg.Symbol("s_flag", fg.Value(INT_T, py_value="1")) + global_block = fg.Block([global_sym], is_global=True) + static_block = fg.Block([static_sym], is_static=True) + symtab = fg.Symtab(global_block=global_block, static_block=static_block) + frame = fg.Frame(fg.Block([]), symtab=symtab) + monkeypatch.setattr(fg, "selected_frame", lambda: frame) + + import json + data = json.loads(gg.build_graph_json(fg.Value(INT_T, py_value="0"), [])) + roots_by_name = {r["name"]: r for r in data["roots"]} + assert roots_by_name["g_count"]["kind"] == "global" + assert roots_by_name["s_flag"]["kind"] == "global" + + +def test_no_selected_frame_still_returns_primary_root_only(monkeypatch): + monkeypatch.setattr(fg, "selected_frame", lambda: (_ for _ in ()).throw(fg.error("no frame"))) + import json + data = json.loads(gg.build_graph_json(fg.Value(INT_T, py_value="5"), [])) + assert data["roots"] == [] + + +# --- watched roots + DebugGraphFunction.invoke() ---------------------------- + +def test_watched_roots_are_labeled_and_walked(monkeypatch): + monkeypatch.setattr(fg, "selected_frame", lambda: (_ for _ in ()).throw(fg.error("no frame"))) + node_type, node_ptr_type, n30 = build_chain() + watched_ptr = ptr_to(node_ptr_type, 0x30, n30) + + import json + data = json.loads(gg.build_graph_json(fg.Value(INT_T, py_value="0"), [("top", watched_ptr)])) + roots_by_name = {r["name"]: r for r in data["roots"]} + assert roots_by_name["top"]["kind"] == "watched" + assert roots_by_name["top"]["value"] == "0x30" + + +def test_debug_graph_function_requires_at_least_one_arg(): + fn = gg.DebugGraphFunction() + try: + fn.invoke() + assert False, "expected GdbError" + except fg.GdbError: + pass + + +def test_debug_graph_function_returns_a_string_value_via_parse_and_eval(monkeypatch): + monkeypatch.setattr(fg, "selected_frame", lambda: (_ for _ in ()).throw(fg.error("no frame"))) + fn = gg.DebugGraphFunction() + result_value = fn.invoke(fg.Value(INT_T, py_value="42")) + assert result_value.string().startswith(gg._RESULT_MARKER) + + +# --- _JsonResultPrettyPrinter / _pretty_print_lookup ------------------------ + +def test_pretty_print_lookup_recognizes_our_own_result_marker(): + text = gg._RESULT_MARKER + '..."}' + char_ptr_t = fg.Type(fg.TYPE_CODE_PTR, target=CHAR_T) + val = fg.Value(char_ptr_t, ptr_int=0x1, py_value=text) + printer = gg._pretty_print_lookup(val) + assert printer is not None + assert printer.to_string() == text + assert printer.display_hint() == "string" + + +def test_pretty_print_lookup_ignores_unrelated_strings(): + char_ptr_t = fg.Type(fg.TYPE_CODE_PTR, target=CHAR_T) + val = fg.Value(char_ptr_t, ptr_int=0x1, py_value="just a normal char* the user's program made") + assert gg._pretty_print_lookup(val) is None + + +def test_pretty_print_lookup_ignores_non_char_pointee(): + int_ptr_t = fg.Type(fg.TYPE_CODE_PTR, target=INT_T) + val = fg.Value(int_ptr_t, ptr_int=0x1, py_value="30") + assert gg._pretty_print_lookup(val) is None + + +# --- direct-call coverage of the smaller error-handling branches ----------- + +class _RaisesOnStr: + def __str__(self): + raise fg.error("gdb couldn't format this value") + + +def test_format_scalar_falls_back_on_gdb_error(): + assert gg._format_scalar(_RaisesOnStr()) == "" + + +def test_is_variant_like_false_when_fields_raises_type_error(): + class BrokenFieldsType(fg.Type): + def fields(self): + raise TypeError("fields unavailable") + + t = BrokenFieldsType(fg.TYPE_CODE_STRUCT, tag="Weird") + assert gg._is_variant_like(t) is False + + +def test_is_variant_like_false_for_non_struct_type(): + assert gg._is_variant_like(INT_T) is False + + +def test_flatten_variant_yields_nothing_when_payload_is_not_struct_like(): + """Payload field access succeeds but its value isn't struct/union + typed (e.g. gdb hands back the raw scalar directly) -- nothing further + to recurse into, so the caller falls back to whole-value rendering.""" + variant_type = fg.Type( + fg.TYPE_CODE_STRUCT, tag="SomeVariant", + fields=[fg.Field("discriminant", artificial=True), fg.Field("__0")], + ) + val = fg.Value(variant_type, fields={"__0": fg.Value(INT_T, py_value="5")}) + assert list(gg._flatten_variant(val, variant_type, "label")) == [] + + +def test_pretty_printer_summary_children_raising_gdb_error_falls_back_to_to_string(monkeypatch): + class ExplodingChildrenPrinter: + def children(self): + raise fg.error("children unavailable") + + def to_string(self): + return "fallback text" + + dummy = object() + monkeypatch.setattr(fg, "default_visualizer", lambda val: ExplodingChildrenPrinter() if val is dummy else None) + leaves, empty_text = gg._pretty_printer_summary(dummy) + assert leaves == [] + assert empty_text == "fallback text" + + +def test_pretty_printer_summary_to_string_raising_gdb_error(): + class ExplodingToStringPrinter: + def to_string(self): + raise fg.error("to_string unavailable") + + import gdb_debug_graph + + old = gdb_debug_graph.gdb.default_visualizer + gdb_debug_graph.gdb.default_visualizer = lambda val: ExplodingToStringPrinter() + try: + leaves, empty_text = gg._pretty_printer_summary(object()) + finally: + gdb_debug_graph.gdb.default_visualizer = old + assert leaves == [] + assert empty_text == "" + + +def test_type_name_returns_none_when_tag_access_raises_gdb_error(): + class ExplodingTagType: + code = fg.TYPE_CODE_INT + + @property + def tag(self): + raise fg.error("tag inaccessible") + + assert gg._type_name(ExplodingTagType()) is None + + +def test_process_member_pointer_int_conversion_error_treated_as_null(monkeypatch): + monkeypatch.setattr(fg, "selected_frame", lambda: (_ for _ in ()).throw(fg.error("no frame"))) + node_type, node_ptr_type = make_node_types() + # a pointer-typed field whose Value has no ptr_int wired up at all -- + # int(val) raises inside our fake, exactly like a gdb.error would for + # an unreadable/optimized-out pointer. + broken_ptr = fg.Value(node_ptr_type) + holder_type = fg.Type(fg.TYPE_CODE_STRUCT, tag="Holder", fields=[fg.Field("next")]) + holder = fg.Value(holder_type, address=0x1, fields={"next": broken_ptr}) + + import json + data = json.loads(gg.build_graph_json(holder, [])) + root = next(n for n in data["nodes"] if n["id"] == "0x1") + assert _fields(root)["next"]["value"] == "0x0" + assert data["edges"] == [] + + +def test_describe_struct_skips_a_field_whose_access_raises_gdb_error(): + class PartiallyReadableValue(fg.Value): + def __getitem__(self, name): + if name == "broken": + raise fg.error("optimized out") + return super().__getitem__(name) + + holder_type = fg.Type( + fg.TYPE_CODE_STRUCT, tag="Holder", + fields=[fg.Field("broken"), fg.Field("ok")], + ) + holder = PartiallyReadableValue(holder_type, address=0x1, fields={"ok": fg.Value(INT_T, py_value="1")}) + + nodes, edges, visited = {}, [], set() + gg._describe_struct(holder, "0x1", nodes, edges, visited) + assert set(_fields({"fields": nodes["0x1"]["fields"]})) == {"ok"} + + +def test_visit_pointer_char_star_skips_when_node_already_described(): + char_ptr_t = fg.Type(fg.TYPE_CODE_PTR, target=CHAR_T) + ptr_val = fg.Value(char_ptr_t, ptr_int=0x50, py_value="should not be seen") + nodes = {"0x50": {"fields": [{"name": "value", "value": "already here", "isPointer": False}], "type": "char *"}} + gg._visit_pointer(ptr_val, 0x50, nodes, [], set()) + assert _fields(nodes["0x50"])["value"]["value"] == "already here" + + +def test_visit_pointer_char_star_falls_back_on_unicode_decode_error(): + class BadStringValue(fg.Value): + def string(self, length=None): + raise UnicodeDecodeError("utf-8", b"\xff", 0, 1, "invalid start byte") + + def dereference(self): + return fg.Value(CHAR_T, py_value="0") + + char_ptr_t = fg.Type(fg.TYPE_CODE_PTR, target=CHAR_T) + ptr_val = BadStringValue(char_ptr_t, ptr_int=0x60) + nodes = {} + gg._visit_pointer(ptr_val, 0x60, nodes, [], set()) + assert _fields(nodes["0x60"])["value"]["value"] == "0" + + +def test_visit_pointer_generic_scalar_skips_when_node_already_described(): + int_ptr_t = fg.Type(fg.TYPE_CODE_PTR, target=INT_T) + ptr_val = fg.Value(int_ptr_t, ptr_int=0x70, pointee=fg.Value(INT_T, py_value="99")) + nodes = {"0x70": {"fields": [{"name": "value", "value": "already here", "isPointer": False}], "type": "int *"}} + gg._visit_pointer(ptr_val, 0x70, nodes, [], set()) + assert _fields(nodes["0x70"])["value"]["value"] == "already here" + + +def test_walk_primary_root_null_pointer_produces_no_node(): + node_type, node_ptr_type = make_node_types() + null_root = null_ptr(node_ptr_type) + nodes, edges, visited = {}, [], set() + addr = gg._walk_primary_root(null_root, nodes, edges, visited) + assert addr is None + assert nodes == {} + + +def test_add_named_root_pointer_int_error_is_skipped_entirely(): + node_type, node_ptr_type = make_node_types() + broken_ptr = fg.Value(node_ptr_type) # int(val) raises + roots_out = [] + gg._add_named_root("bad", broken_ptr, "local", {}, [], set(), roots_out, set()) + assert roots_out == [] + + +def test_add_named_root_struct_address_error_is_skipped_entirely(): + class NoAddressValue(fg.Value): + @property + def address(self): + raise fg.error("no address") + + struct_type = fg.Type(fg.TYPE_CODE_STRUCT, tag="Weird", fields=[]) + val = NoAddressValue(struct_type, fields={}) + roots_out = [] + gg._add_named_root("weird", val, "local", {}, [], set(), roots_out, set()) + assert roots_out == [] + + +def test_add_named_root_struct_already_visited_still_adds_root_without_redescribing(): + struct_type = fg.Type(fg.TYPE_CODE_STRUCT, tag="Thing", fields=[fg.Field("x")]) + val = fg.Value(struct_type, address=0x5, fields={"x": fg.Value(INT_T, py_value="1")}) + nodes = {"0x5": {"fields": [{"name": "x", "value": "STALE"}], "type": "Thing"}} + roots_out = [] + gg._add_named_root("thing", val, "local", nodes, [], {"0x5"}, roots_out, set()) + assert roots_out == [("thing", "0x5", "local", "Thing")] + assert nodes["0x5"]["fields"][0]["value"] == "STALE" # not re-described + + +def test_frame_local_symbols_skips_non_variable_non_argument_symbols(): + label_sym = fg.Symbol("a_label", fg.Value(INT_T, py_value="0"), is_variable=False, is_argument=False) + real_sym = fg.Symbol("real", fg.Value(INT_T, py_value="1"), is_variable=True) + frame = fg.Frame(fg.Block([label_sym, real_sym])) + names = [s.name for s in gg._frame_local_symbols(frame)] + assert names == ["real"] + + +def test_file_global_symbols_returns_early_when_find_sal_raises(): + class BrokenSalFrame(fg.Frame): + def find_sal(self): + raise fg.error("no line info") + + frame = BrokenSalFrame(fg.Block([])) + assert list(gg._file_global_symbols(frame)) == [] + + +def test_file_global_symbols_returns_early_when_symtab_is_none(): + frame = fg.Frame(fg.Block([]), symtab=None) + assert list(gg._file_global_symbols(frame)) == [] + + +def test_build_graph_json_watched_root_int_error_treated_as_null(monkeypatch): + monkeypatch.setattr(fg, "selected_frame", lambda: (_ for _ in ()).throw(fg.error("no frame"))) + node_type, node_ptr_type = make_node_types() + broken_ptr = fg.Value(node_ptr_type) + import json + data = json.loads(gg.build_graph_json(fg.Value(INT_T, py_value="0"), [("bad", broken_ptr)])) + roots_by_name = {r["name"]: r for r in data["roots"]} + assert roots_by_name["bad"]["value"] == "0x0" + + +def test_describe_struct_root_with_pretty_printer_summary_single_leaf(monkeypatch): + opt_type = fg.Type(fg.TYPE_CODE_STRUCT, tag="std::optional") + root_val = fg.Value(opt_type, address=0x1, fields={}) + inner = fg.Value(INT_T, py_value="7") + monkeypatch.setattr(fg, "default_visualizer", lambda val: _FakePrinter(children=[("[contained value]", inner)])) + + nodes, edges, visited = {}, [], set() + gg._describe_struct(root_val, "0x1", nodes, edges, visited) + assert _fields({"fields": nodes["0x1"]["fields"]})["value"]["value"] == "7" + + +def test_describe_struct_root_with_pretty_printer_summary_disengaged(monkeypatch): + opt_type = fg.Type(fg.TYPE_CODE_STRUCT, tag="std::optional") + root_val = fg.Value(opt_type, address=0x1, fields={}) + monkeypatch.setattr(fg, "default_visualizer", lambda val: _FakePrinter(children=[], text="std::nullopt")) + + nodes, edges, visited = {}, [], set() + gg._describe_struct(root_val, "0x1", nodes, edges, visited) + assert _fields({"fields": nodes["0x1"]["fields"]})["value"]["value"] == "std::nullopt" + + +def test_describe_struct_root_with_pretty_printer_summary_multi_leaf(monkeypatch): + pair_type = fg.Type(fg.TYPE_CODE_STRUCT, tag="std::pair") + root_val = fg.Value(pair_type, address=0x1, fields={}) + first = fg.Value(INT_T, py_value="1") + second = fg.Value(INT_T, py_value="2") + monkeypatch.setattr(fg, "default_visualizer", lambda val: _FakePrinter(children=[("first", first), ("second", second)])) + + nodes, edges, visited = {}, [], set() + gg._describe_struct(root_val, "0x1", nodes, edges, visited) + fields = _fields({"fields": nodes["0x1"]["fields"]}) + assert fields["first"]["value"] == "1" + assert fields["second"]["value"] == "2" + + +def test_add_named_root_struct_already_in_skip_addrs_is_not_added(): + struct_type = fg.Type(fg.TYPE_CODE_STRUCT, tag="Thing", fields=[]) + val = fg.Value(struct_type, address=0x5, fields={}) + roots_out = [] + gg._add_named_root("thing", val, "local", {}, [], set(), roots_out, {"0x5"}) + assert roots_out == [] + + +def test_file_global_symbols_skips_duplicate_names_and_non_variables(): + dup1 = fg.Symbol("dup", fg.Value(INT_T, py_value="1")) + dup2 = fg.Symbol("dup", fg.Value(INT_T, py_value="2")) + not_a_var = fg.Symbol("fn", fg.Value(INT_T, py_value="?"), is_variable=False) + block = fg.Block([dup1, dup2, not_a_var], is_global=True) + symtab = fg.Symtab(global_block=block) + frame = fg.Frame(fg.Block([]), symtab=symtab) + names = [s.name for s in gg._file_global_symbols(frame)] + assert names == ["dup"] + + +def test_json_result_pretty_printer_falls_back_when_string_raises(): + class BadStringValue(fg.Value): + def string(self, length=None): + raise UnicodeDecodeError("utf-8", b"\xff", 0, 1, "invalid start byte") + + def __str__(self): + return "fallback repr" + + val = BadStringValue(fg.Type(fg.TYPE_CODE_PTR, target=CHAR_T), ptr_int=0x1) + printer = gg._JsonResultPrettyPrinter(val) + assert printer.to_string() == "fallback repr" + + +def test_pretty_print_lookup_handles_array_typed_values(): + array_type = fg.Type(fg.TYPE_CODE_ARRAY, target=CHAR_T) + val = fg.Value(array_type, py_value=gg._RESULT_MARKER + '..."}') + printer = gg._pretty_print_lookup(val) + assert printer is not None + + +def test_pretty_print_lookup_returns_none_when_string_probe_raises(): + class BadStringValue(fg.Value): + def string(self, length=None): + raise fg.error("cannot read memory") + + char_ptr_t = fg.Type(fg.TYPE_CODE_PTR, target=CHAR_T) + val = BadStringValue(char_ptr_t, ptr_int=0x1) + assert gg._pretty_print_lookup(val) is None + + +def test_debug_graph_function_invoke_with_watched_name_ptr_pairs(monkeypatch): + monkeypatch.setattr(fg, "selected_frame", lambda: (_ for _ in ()).throw(fg.error("no frame"))) + node_type, node_ptr_type, n30 = build_chain() + name_val = fg.Value(CHAR_T, py_value="top") + fn = gg.DebugGraphFunction() + result_value = fn.invoke(fg.Value(INT_T, py_value="0"), name_val, ptr_to(node_ptr_type, 0x30, n30)) + import json + data = json.loads(result_value.string()) + roots_by_name = {r["name"]: r for r in data["roots"]} + assert roots_by_name["top"]["kind"] == "watched" + + +def test_debug_graph_function_invoke_name_string_extraction_falls_back(monkeypatch): + """If the extra "name" argument doesn't support .string() (TypeError), + str(name_val) is used instead.""" + monkeypatch.setattr(fg, "selected_frame", lambda: (_ for _ in ()).throw(fg.error("no frame"))) + node_type, node_ptr_type, n30 = build_chain() + + class NoStringName: + def string(self): + raise TypeError("not a string-like value") + + def __str__(self): + return "fallback_name" + + fn = gg.DebugGraphFunction() + result_value = fn.invoke(fg.Value(INT_T, py_value="0"), NoStringName(), ptr_to(node_ptr_type, 0x30, n30)) + import json + data = json.loads(result_value.string()) + roots_by_name = {r["name"]: r for r in data["roots"]} + assert "fallback_name" in roots_by_name + + +def test_describe_struct_skips_anonymous_and_base_class_fields(): + holder_type = fg.Type( + fg.TYPE_CODE_STRUCT, tag="Derived", + fields=[fg.Field(None), fg.Field("Base", is_base_class=True), fg.Field("own")], + ) + holder = fg.Value(holder_type, address=0x1, fields={"own": fg.Value(INT_T, py_value="1")}) + nodes, edges, visited = {}, [], set() + gg._describe_struct(holder, "0x1", nodes, edges, visited) + assert set(_fields({"fields": nodes["0x1"]["fields"]})) == {"own"} + + +def test_add_named_root_pointer_success_path_visits_and_appends(): + node_type, node_ptr_type, n30 = build_chain() + ptr_val = ptr_to(node_ptr_type, 0x30, n30) + nodes, edges, visited, roots_out = {}, [], set(), [] + gg._add_named_root("top", ptr_val, "local", nodes, edges, visited, roots_out, set()) + assert roots_out == [("top", "0x30", "local", "Node *")] + assert "0x30" in nodes + + +def test_build_graph_json_skips_frame_locals_and_globals_already_used_by_name(monkeypatch): + """A name already claimed by an explicit watched root must not be + re-added as a local/global, even if the frame also has a symbol with + that same name.""" + same_name_local = fg.Symbol("dup", fg.Value(INT_T, py_value="local-version")) + same_name_global = fg.Symbol("dup", fg.Value(INT_T, py_value="global-version")) + global_block = fg.Block([same_name_global], is_global=True) + symtab = fg.Symtab(global_block=global_block) + frame = fg.Frame(fg.Block([same_name_local]), symtab=symtab) + monkeypatch.setattr(fg, "selected_frame", lambda: frame) + + watched_ptr = fg.Value(INT_T, py_value="watched-version") + import json + data = json.loads(gg.build_graph_json(fg.Value(INT_T, py_value="0"), [("dup", watched_ptr)])) + dup_roots = [r for r in data["roots"] if r["name"] == "dup"] + assert len(dup_roots) == 1 + assert dup_roots[0]["kind"] == "watched" + + +def test_add_named_root_null_pointer_is_skipped_entirely(): + node_type, node_ptr_type = make_node_types() + roots_out = [] + gg._add_named_root("nil", null_ptr(node_ptr_type), "local", {}, [], set(), roots_out, set()) + assert roots_out == [] + + +def test_add_named_root_pointer_already_in_skip_addrs_is_not_added(): + node_type, node_ptr_type, n30 = build_chain() + ptr_val = ptr_to(node_ptr_type, 0x30, n30) + roots_out = [] + gg._add_named_root("top", ptr_val, "local", {}, [], set(), roots_out, {"0x30"}) + assert roots_out == [] + + +def test_pretty_print_lookup_returns_none_for_non_pointer_non_array_type(): + assert gg._pretty_print_lookup(fg.Value(INT_T, py_value="5")) is None + + +def test_build_graph_json_skips_locals_and_globals_whose_value_raises(): + class BrokenValueSymbol(fg.Symbol): + def value(self, frame): + raise fg.error("optimized out") + + local_sym = BrokenValueSymbol("bad_local", None) + global_sym = BrokenValueSymbol("bad_global", None) + global_block = fg.Block([global_sym], is_global=True) + symtab = fg.Symtab(global_block=global_block) + frame = fg.Frame(fg.Block([local_sym]), symtab=symtab) + + import gdb_debug_graph as gg_mod + gg_mod.gdb.selected_frame = lambda: frame + try: + import json + data = json.loads(gg.build_graph_json(fg.Value(INT_T, py_value="0"), [])) + finally: + gg_mod.gdb.selected_frame = fg.selected_frame + assert data["roots"] == [] diff --git a/tests/test_py_debug_graph.py b/tests/test_py_debug_graph.py new file mode 100644 index 0000000..3a181e9 --- /dev/null +++ b/tests/test_py_debug_graph.py @@ -0,0 +1,271 @@ +"""Unit tests for debuggers/py_debug_graph.py -- the debugpy-side walker. + +Unlike gdb_debug_graph.py, this module has no external (gdb) dependency, +so it's called directly with plain Python objects; no fake/mocked debugger +is needed. +""" +import sys + +import py_debug_graph as pdg + + +class Node: + def __init__(self, data, next=None): + self.data = data + self.next = next + + +class LinkedList: + def __init__(self, head=None): + self._head = head + + +def _fields(node_json): + return {f["name"]: f for f in node_json["fields"]} + + +def test_simple_chain_produces_node_shaped_nodes_and_next_edges(): + lst = LinkedList(Node(30, Node(20, Node(10, None)))) + result = pdg.build_graph_json(lst, {}, None) + assert result["kind"] == {"nodeTable": True} + + node_shaped = [n for n in result["nodes"] if set(_fields(n)) == {"data", "next"}] + assert len(node_shaped) == 3 + next_edges = [e for e in result["edges"] if e["label"] == "next"] + assert len(next_edges) == 2 + + head_node = next(n for n in result["nodes"] if n["type"] == "LinkedList") + assert _fields(head_node)["_head"]["isPointer"] is True + + +def test_none_becomes_a_plain_scalar_field_not_an_edge(): + lst = LinkedList(Node(10, None)) + result = pdg.build_graph_json(lst, {}, None) + tail = next(n for n in result["nodes"] if _fields(n)["data"]["value"] == "10") + next_field = _fields(tail)["next"] + assert next_field["value"] == "None" + assert next_field["isPointer"] is False + assert next_field["typeHint"] == "NoneType" + + +def test_cycle_is_handled_without_infinite_recursion(): + a = Node(1) + b = Node(2) + a.next = b + b.next = a # cycle + result = pdg.build_graph_json(a, {}, None) + assert len(result["nodes"]) == 2 + assert len(result["edges"]) == 2 # a->b, b->a; no infinite unrolling + + +def test_scalar_root_produces_a_bare_root_node(): + result = pdg.build_graph_json(42, {}, None) + assert result["nodes"] == [{"id": "root", "fields": [ + {"name": "value", "value": "42", "isPointer": False, "typeHint": "int"}, + ], "type": "int"}] + assert result["roots"] == [] + + +def test_none_root_produces_no_primary_node(): + result = pdg.build_graph_json(None, {}, None) + assert result["nodes"] == [] + + +def test_module_or_function_valued_field_is_treated_as_a_plain_scalar_not_describable(): + class HasModuleField: + def __init__(self): + self.mod = sys + self.fn = len + + result = pdg.build_graph_json(HasModuleField(), {}, None) + fields = _fields(result["nodes"][0]) + assert fields["mod"]["isPointer"] is False + assert fields["fn"]["isPointer"] is False + + +def test_format_scalar_falls_back_to_error_text_when_str_raises(): + class Explodes: + # __slots__ with no __dict__ makes this non-"describable" (vars() + # raises TypeError), so it's walked as a scalar via _format_scalar + # rather than recursed into as a struct. + __slots__ = () + + def __str__(self): + raise ValueError("boom") + + class Holder: + def __init__(self): + self.bad = Explodes() + + result = pdg.build_graph_json(Holder(), {}, None) + fields = _fields(result["nodes"][0]) + assert "