Files
software-mansion__argent/packages/tool-server/test/debugger/component-tree.test.ts
Filip131311 2c68679f28 fix(component-tree): make maxNodes honest, and stop offering untappable coordinates
maxNodes promised 'Maximum total nodes to include' but only ever collapsed
single-child wrapper chains of length >= 2 — it never dropped a node, so
maxNodes:5 returned a 24-node tree. It also fired against a count the renderer
does not produce, and its accounting could exceed the tree.

Three defects, all measured:

Chain discovery pushed every suffix of a chain as its own chain, because the
recursion guard read a map that is not populated until the greedy pass below
it. The greedy pass could then collapse an outer chain and one nested inside
it, counting the shared nodes twice: an 8-node tree reported '11 wrapper nodes
collapsed'. Members are now consumed as chains are taken, and the total is
measured as the difference between rendered counts rather than accumulated, so
it cannot disagree with the tree above it.

The inline marker counted the chain start, which is printed directly above it,
so a 7-wrapper chain said 'via 7 wrappers' while hiding 6.

The budget was computed from a plain subtree count, but the renderer also skips
same-name overlapping siblings. Budgeting against the larger number would have
shrunk trees that were already within budget.

What maxNodes will not do is drop a node carrying a name, text, testID, or a
branch. Truncating those is how a caller concludes an element does not exist
when it was merely trimmed — a wrong answer, which is worse than an oversized
response. When the budget cannot be reached the response says so and by how
much, and points at debugger-inspect-element for narrowing. The schema now
describes that contract instead of promising a hard cap.

Separately: an element whose centre lies outside the viewport was rendered as
'(tap: 0.22,-0.11)'. The on-screen filter keeps such nodes deliberately (a 10%
margin, so a partially-visible row still appears), which means the DEFAULT
response offered coordinates that cannot land, formatted exactly like real
ones. Those now read '(off-screen: x,y)' — the numbers stay, since their sign
says which way to scroll, but the tap affordance is gone.

Finally, the off-screen count is reported even at zero. Suppressing it is why
onScreenOnly:true and false look identical on a screen with nothing off-view,
which reads as a broken parameter rather than an inactive filter.
2026-08-01 02:52:05 +02:00

411 lines
16 KiB
TypeScript

import { describe, it, expect } from "vitest";
import {
buildTextTree,
type RawEntry,
type RawResult,
} from "../../src/tools/debugger/debugger-component-tree";
function entry(
id: number,
name: string,
parentIdx: number,
rect: RawEntry["rect"] = null,
extra: Partial<Pick<RawEntry, "testID" | "accLabel" | "text">> = {}
): RawEntry {
return { id, name, rect, parentIdx, ...extra };
}
const SCREEN = { screenW: 400, screenH: 800 };
describe("buildTextTree — same-name dedup (B fix)", () => {
it("collapses 2-deep same-name chain", () => {
const rect = { x: 0, y: 62, w: 400, h: 738 };
const data: RawResult = {
...SCREEN,
components: [
entry(0, "ScrollView", -1, rect),
entry(1, "ScrollView", 0, rect),
entry(2, "Text", 1, { x: 50, y: 100, w: 300, h: 30 }, { text: "Hello" }),
],
};
const result = buildTextTree(data, { onScreenOnly: true });
const scrollViewCount = (result.match(/ScrollView/g) || []).length;
expect(scrollViewCount).toBe(1);
expect(result).toContain('Text "Hello"');
});
it("collapses 3-deep same-name chain (the bug fix)", () => {
const rect = { x: 0, y: 62, w: 400, h: 738 };
const data: RawResult = {
...SCREEN,
components: [
entry(0, "ScrollView", -1, rect),
entry(1, "ScrollView", 0, rect),
entry(2, "ScrollView", 1, rect),
entry(3, "Text", 2, { x: 50, y: 100, w: 300, h: 30 }, { text: "Content" }),
],
};
const result = buildTextTree(data, { onScreenOnly: true });
const scrollViewCount = (result.match(/ScrollView/g) || []).length;
expect(scrollViewCount).toBe(1);
expect(result).toContain('Text "Content"');
});
it("collapses different-name content-free child with same rect", () => {
const rect = { x: 0, y: 62, w: 400, h: 738 };
const data: RawResult = {
...SCREEN,
components: [
entry(0, "Wrapper", -1, rect),
entry(1, "ScrollView", 0, rect),
entry(2, "Text", 1, { x: 50, y: 100, w: 300, h: 30 }, { text: "Hi" }),
],
};
const result = buildTextTree(data, { onScreenOnly: true });
// ScrollView is content-free (no text/testID/accLabel) and overlaps parent → collapsed
expect(result).not.toContain("ScrollView");
// Child text gets reparented to Wrapper
expect(result).toContain("Wrapper");
expect(result).toContain('Text "Hi"');
});
it("keeps different-name child with same rect when it has testID", () => {
const rect = { x: 0, y: 62, w: 400, h: 738 };
const data: RawResult = {
...SCREEN,
components: [
entry(0, "Wrapper", -1, rect),
entry(1, "ScrollView", 0, rect, { testID: "myScroll" }),
entry(2, "Text", 1, { x: 50, y: 100, w: 300, h: 30 }, { text: "Hi" }),
],
};
const result = buildTextTree(data, { onScreenOnly: true });
expect(result).toContain("ScrollView");
});
});
describe("buildTextTree — onScreenOnly (C)", () => {
it("removes off-screen components when onScreenOnly=true", () => {
const data: RawResult = {
...SCREEN,
components: [
entry(0, "Header", -1, { x: 0, y: 0, w: 400, h: 50 }, { text: "Title" }),
entry(1, "Footer", -1, { x: 0, y: 2000, w: 400, h: 50 }, { text: "Bottom" }),
],
};
const result = buildTextTree(data, { onScreenOnly: true });
expect(result).toContain("Header");
expect(result).not.toContain("Footer");
});
it("keeps off-screen components when onScreenOnly=false", () => {
const data: RawResult = {
...SCREEN,
components: [
entry(0, "Header", -1, { x: 0, y: 0, w: 400, h: 50 }, { text: "Title" }),
entry(1, "Footer", -1, { x: 0, y: 2000, w: 400, h: 50 }, { text: "Bottom" }),
],
};
const result = buildTextTree(data, { onScreenOnly: false });
expect(result).toContain("Header");
expect(result).toContain("Footer");
});
});
describe("buildTextTree — maxNodes with wrapper-chain collapsing (D)", () => {
it("renders full tree when maxNodes is not set", () => {
const data: RawResult = {
...SCREEN,
components: [
entry(0, "Root", -1, { x: 0, y: 0, w: 400, h: 800 }),
entry(1, "Wrapper1", 0, { x: 0, y: 0, w: 400, h: 700 }),
entry(2, "Wrapper2", 1, { x: 0, y: 0, w: 400, h: 600 }),
entry(3, "Wrapper3", 2, { x: 0, y: 0, w: 400, h: 500 }),
entry(4, "Leaf", 3, { x: 50, y: 100, w: 300, h: 30 }, { text: "Hello" }),
],
};
const result = buildTextTree(data, { onScreenOnly: false });
expect(result).toContain("Wrapper1");
expect(result).toContain("Wrapper2");
expect(result).toContain("Wrapper3");
expect(result).not.toContain("via");
});
it("collapses single-child wrapper chains when maxNodes is exceeded", () => {
const data: RawResult = {
...SCREEN,
components: [
entry(0, "Root", -1, { x: 0, y: 0, w: 400, h: 800 }),
entry(1, "Wrapper1", 0, { x: 0, y: 0, w: 400, h: 700 }),
entry(2, "Wrapper2", 1, { x: 0, y: 0, w: 400, h: 600 }),
entry(3, "Wrapper3", 2, { x: 0, y: 0, w: 400, h: 500 }),
entry(4, "Wrapper4", 3, { x: 0, y: 0, w: 400, h: 400 }),
entry(5, "Leaf", 4, { x: 50, y: 100, w: 300, h: 30 }, { text: "Hello" }),
],
};
const result = buildTextTree(data, { onScreenOnly: false, maxNodes: 3 });
expect(result).toContain("via");
expect(result).toContain('Leaf "Hello"');
expect(result).toContain("wrapper node");
expect(result).toContain("collapsed");
});
it("does NOT collapse branching nodes", () => {
const data: RawResult = {
...SCREEN,
components: [
entry(0, "Root", -1, { x: 0, y: 0, w: 400, h: 800 }),
entry(1, "Left", 0, { x: 0, y: 0, w: 200, h: 400 }, { text: "A" }),
entry(2, "Right", 0, { x: 200, y: 0, w: 200, h: 400 }, { text: "B" }),
],
};
const result = buildTextTree(data, { onScreenOnly: false, maxNodes: 2 });
// Root has 2 children — not a wrapper chain, so nothing to collapse
expect(result).not.toContain("via");
expect(result).toContain("Left");
expect(result).toContain("Right");
});
it("does NOT collapse nodes with text/testID/accLabel", () => {
const data: RawResult = {
...SCREEN,
components: [
entry(0, "Root", -1, { x: 0, y: 0, w: 400, h: 800 }),
entry(1, "LabeledWrapper", 0, { x: 0, y: 0, w: 400, h: 700 }, { text: "Important" }),
entry(2, "Leaf", 1, { x: 50, y: 100, w: 300, h: 30 }, { text: "Content" }),
],
};
const result = buildTextTree(data, { onScreenOnly: false, maxNodes: 1 });
// LabeledWrapper has text so it's not a wrapper — no collapsing
expect(result).not.toContain("via");
expect(result).toContain("LabeledWrapper");
});
});
describe("buildTextTree — full-screen wrapper collapse", () => {
it("removes full-screen wrappers without content", () => {
const data: RawResult = {
...SCREEN,
components: [
entry(0, "FullScreenWrap", -1, { x: 0, y: 0, w: 400, h: 800 }),
entry(1, "Button", 0, { x: 100, y: 300, w: 200, h: 50 }, { text: "Click" }),
],
};
const result = buildTextTree(data, { onScreenOnly: true });
expect(result).not.toContain("FullScreenWrap");
expect(result).toContain('Button "Click"');
});
it("keeps full-screen wrapper with testID", () => {
const data: RawResult = {
...SCREEN,
components: [
entry(0, "Screen", -1, { x: 0, y: 0, w: 400, h: 800 }, { testID: "main-screen" }),
entry(1, "Button", 0, { x: 100, y: 300, w: 200, h: 50 }, { text: "Click" }),
],
};
const result = buildTextTree(data, { onScreenOnly: true });
expect(result).toContain("Screen [testID=main-screen]");
});
});
describe("buildTextTree — same-testID chain collapse", () => {
it("keeps only topmost component with a given testID", () => {
const data: RawResult = {
...SCREEN,
components: [
entry(0, "Outer", -1, { x: 10, y: 10, w: 380, h: 100 }, { testID: "field" }),
entry(1, "Inner", 0, { x: 10, y: 10, w: 380, h: 100 }, { testID: "field" }),
entry(2, "Text", 1, { x: 20, y: 20, w: 200, h: 30 }, { text: "Hello" }),
],
};
const result = buildTextTree(data, { onScreenOnly: true });
expect(result).toContain("Outer [testID=field]");
expect(result).not.toContain("Inner");
});
});
describe("buildTextTree — includeSkipped summary", () => {
it("appends filtered summary section with TS-side stats", () => {
const rect = { x: 0, y: 62, w: 400, h: 738 };
const data: RawResult = {
...SCREEN,
components: [
entry(0, "ScrollView", -1, rect),
entry(1, "ScrollView", 0, rect),
entry(2, "Text", 1, { x: 50, y: 100, w: 300, h: 30 }, { text: "Hello" }),
],
};
const result = buildTextTree(data, { onScreenOnly: true, includeSkipped: true });
expect(result).toContain("--- Filtered ---");
expect(result).toContain("TS-side removed:");
expect(result).toContain("Same-name dedup:");
expect(result).toContain("ScrollView");
});
it("includes JS-side skipped counts when provided", () => {
const data: RawResult = {
...SCREEN,
totalFibers: 547,
skippedCounts: { View: 120, RCTView: 80 },
components: [
entry(0, "MainScreen", -1, { x: 0, y: 0, w: 400, h: 700 }),
entry(1, "Text", 0, { x: 50, y: 100, w: 300, h: 30 }, { text: "Hello" }),
],
};
const result = buildTextTree(data, { onScreenOnly: true, includeSkipped: true });
expect(result).toContain("--- Filtered ---");
expect(result).toContain("Total fibers walked: 547");
expect(result).toContain("JS-side skipped: 200");
expect(result).toContain("View: 120");
expect(result).toContain("RCTView: 80");
});
it("omits summary section when includeSkipped is false", () => {
const data: RawResult = {
...SCREEN,
components: [
entry(0, "Root", -1, { x: 0, y: 0, w: 400, h: 700 }),
entry(1, "Text", 0, { x: 50, y: 100, w: 300, h: 30 }, { text: "Hi" }),
],
};
const result = buildTextTree(data, { onScreenOnly: true });
expect(result).not.toContain("--- Filtered ---");
});
it("reports multiple filter passes in summary", () => {
const data: RawResult = {
...SCREEN,
totalFibers: 100,
skippedCounts: { View: 50 },
components: [
entry(0, "Outer", -1, { x: 10, y: 10, w: 380, h: 100 }, { testID: "field" }),
entry(1, "Inner", 0, { x: 10, y: 10, w: 380, h: 100 }, { testID: "field" }),
entry(2, "FullWrap", -1, { x: 0, y: 0, w: 400, h: 800 }),
entry(3, "OffScreen", -1, { x: 0, y: 5000, w: 100, h: 50 }, { text: "Far away" }),
entry(4, "Text", 0, { x: 20, y: 20, w: 200, h: 30 }, { text: "Hello" }),
],
};
const result = buildTextTree(data, { onScreenOnly: true, includeSkipped: true });
expect(result).toContain("Same-testID chain:");
expect(result).toContain("Full-screen wrapper:");
expect(result).toContain("Off-screen:");
});
});
describe("buildTextTree — maxNodes accounting and budget honesty (issue #631)", () => {
// Seven content-free single-child wrappers with distinct rects (so the
// content-free-wrapper pass does not eat them) above one text leaf.
function wrapperChain(): RawResult {
const components: RawEntry[] = [];
for (let i = 0; i < 7; i++) {
components.push(
entry(i, `W${i}`, i === 0 ? -1 : i - 1, {
x: i * 12,
y: i * 12,
w: 300 - i * 12,
h: 400 - i * 12,
})
);
}
components.push(entry(7, "Leaf", 6, { x: 100, y: 200, w: 80, h: 20 }, { text: "Hi" }));
return { ...SCREEN, components };
}
it("never reports collapsing more nodes than the tree contains", () => {
// Regression: chain discovery also pushed every suffix of a chain, so the
// greedy pass collapsed an outer chain and one nested inside it and counted
// the shared nodes twice — an 8-node tree reported 11 collapsed.
const result = buildTextTree(wrapperChain(), { onScreenOnly: false, maxNodes: 1 });
const collapsed = Number(/\.\.\. (\d+) wrapper node/.exec(result)![1]);
expect(collapsed).toBe(6);
expect(collapsed).toBeLessThan(8);
});
it("counts only the wrappers it actually hides in the inline marker", () => {
// The chain start is printed above the marker and the chain end below it,
// so a 7-wrapper chain hides 6.
const result = buildTextTree(wrapperChain(), { onScreenOnly: false, maxNodes: 1 });
expect(result).toContain("... via 6 wrappers");
expect(result).not.toContain("... via 7 wrappers");
});
it("keeps every named node and states the shortfall when the budget cannot be met", () => {
const components: RawEntry[] = [entry(0, "Root", -1, { x: 0, y: 0, w: 400, h: 780 })];
for (let i = 1; i <= 6; i++) {
components.push(
entry(i, `Item${i}`, 0, { x: 0, y: i * 60, w: 400, h: 50 }, { text: `Row ${i}` })
);
}
const result = buildTextTree({ ...SCREEN, components }, { onScreenOnly: false, maxNodes: 2 });
// Truncating here would let a caller conclude Row 6 does not exist.
for (let i = 1; i <= 6; i++) expect(result).toContain(`Row ${i}`);
expect(result).toContain("could not be met");
expect(result).toContain("5 nodes over budget");
});
it("says nothing extra when the budget is met", () => {
const result = buildTextTree(wrapperChain(), { onScreenOnly: false, maxNodes: 50 });
expect(result).not.toContain("could not be met");
expect(result).not.toContain("collapsed");
});
it("treats a zero budget as one rather than emitting an empty tree", () => {
const result = buildTextTree(wrapperChain(), { onScreenOnly: false, maxNodes: 0 });
expect(result).toContain('Leaf "Hi"');
});
});
describe("buildTextTree — off-screen coordinates are not offered as tap targets", () => {
function scrolledAboveViewport(): RawResult {
return {
...SCREEN,
components: [
entry(0, "Screen", -1, { x: 0, y: 0, w: 400, h: 800 }, { testID: "root" }),
// Centre at y = -20 → above the viewport, but inside the 10% margin the
// on-screen filter allows, so it survives onScreenOnly: true.
entry(1, "Heading", 0, { x: 10, y: -40, w: 200, h: 40 }, { text: "Explore" }),
entry(2, "Body", 0, { x: 10, y: 300, w: 200, h: 40 }, { text: "Visible" }),
],
};
}
it("marks a node whose centre is off the viewport, in the default mode too", () => {
const result = buildTextTree(scrolledAboveViewport(), { onScreenOnly: true });
expect(result).toContain('Heading "Explore" (off-screen:');
// The affordance token must be gone — an agent scanning for `tap:` would
// otherwise feed gesture-tap a coordinate that cannot land.
expect(result).not.toContain('Heading "Explore" (tap:');
// Sign is preserved so the caller knows which way to scroll.
expect(result).toContain("-0.03");
});
it("still lists on-screen nodes as tap targets", () => {
const result = buildTextTree(scrolledAboveViewport(), { onScreenOnly: true });
expect(result).toContain('Body "Visible" (tap:');
});
it("marks rather than removes when onScreenOnly is false", () => {
const result = buildTextTree(scrolledAboveViewport(), { onScreenOnly: false });
expect(result).toContain('Heading "Explore" (off-screen:');
});
});
describe("buildTextTree — off-screen accounting is visible when the filter is on", () => {
it("reports Off-screen: 0 so an inert filter is distinguishable from a broken one", () => {
// This is what the reporter of #631 could not tell apart: with everything on
// screen, onScreenOnly true and false agree, and suppressing the zero made
// that look like the parameter had no effect.
const data: RawResult = {
...SCREEN,
components: [
entry(0, "Screen", -1, { x: 0, y: 0, w: 400, h: 800 }, { testID: "root" }),
entry(1, "Body", 0, { x: 10, y: 300, w: 200, h: 40 }, { text: "Visible" }),
],
};
const result = buildTextTree(data, { onScreenOnly: true, includeSkipped: true });
expect(result).toContain("Off-screen: 0");
});
});