From 9556aa08a7b37548782e86586d9db275088ac1b2 Mon Sep 17 00:00:00 2001 From: mynameisdeleted Date: Thu, 9 Jul 2026 19:31:14 -0400 Subject: [PATCH] Add unit tests for py_debug_graph module This commit introduces a comprehensive suite of unit tests for the py_debug_graph module, which is responsible for building debug graphs in Python. The tests cover various scenarios including linked lists, cycles, scalar values, and the handling of local and global variables in the debugging context. Additionally, it verifies the behavior of the debug_graph function and ensures that the builtins.root patch works as intended. These tests aim to enhance the reliability and robustness of the debugging functionality. --- .gitignore | 2 + requirements-dev.txt | 8 + scripts/dump_debug_graph.py | 9 +- tests/conftest.py | 6 + tests/fake_gdb.py | 188 ++++++ tests/js/load-visualizer.js | 67 ++ tests/js/node-table-visualizer.test.js | 210 +++++++ tests/test_dump_debug_graph.py | 566 +++++++++++++++++ tests/test_gdb_debug_graph.py | 833 +++++++++++++++++++++++++ tests/test_py_debug_graph.py | 271 ++++++++ 10 files changed, 2158 insertions(+), 2 deletions(-) create mode 100644 requirements-dev.txt create mode 100644 tests/conftest.py create mode 100644 tests/fake_gdb.py create mode 100644 tests/js/load-visualizer.js create mode 100644 tests/js/node-table-visualizer.test.js create mode 100644 tests/test_dump_debug_graph.py create mode 100644 tests/test_gdb_debug_graph.py create mode 100644 tests/test_py_debug_graph.py 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 "