Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,12 @@ Use when you need the source file and line for a component at a tap coordinate.
if (resolved) {
source = resolved;
code = await api.sourceResolver.readSourceFragment(resolved, params.contextLines);
} else {
source = {
file: item.frame.file,
line: item.frame.line,
column: item.frame.col,
};
}
} else {
source = {
Expand Down
6 changes: 5 additions & 1 deletion packages/tool-server/src/utils/debugger/source-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,11 @@ export function createSourceResolver(port: number, projectRoot: string): SourceR
const frame = data.stack?.[0];
if (!frame?.file) return null;

if (frame.file.includes("node_modules")) return null;
// A failed symbolication echoes the bundle URL back unchanged (an
// http(s) URL); reject those. A successful one yields a real file path,
// including legitimate node_modules sources (e.g. expo-router /
// react-navigation route components), which we keep.
if (/^https?:\/\//.test(frame.file)) return null;

const relFile = frame.file.replace(projectRoot + "/", "").replace(/^\/+/, "");

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, it, expect } from "vitest";
import { describe, it, expect, vi } from "vitest";
import {
filterInspectItems,
debuggerInspectElementTool,
type InspectItem,
} from "../../src/tools/debugger/debugger-inspect-element";

Expand Down Expand Up @@ -265,3 +266,90 @@ describe("filterInspectItems — includeSkipped=true", () => {
expect(skippedCount).toBeGreaterThan(0);
});
});

type RawFrame = {
fn: string;
file: string;
line: number;
col: number;
original?: boolean;
};

/**
* Builds a minimal fake JsRuntimeDebuggerApi that exercises the real
* `execute` resolution path without a live CDP connection. `symbolicate`
* returns whatever `symbolicateImpl` yields so we can simulate a failed
* symbolication (null) and assert the raw-frame fallback.
*/
function fakeServices(
rawItems: Array<{ name: string; frame: RawFrame | null }>,
symbolicateImpl: () => Promise<InspectItem["source"]>
) {
const symbolicate = vi.fn(symbolicateImpl);
const readSourceFragment = vi.fn(async () => " > 1 | code");
return {
services: {
debugger: {
deviceName: "iPhone 16",
appName: "MyApp",
logicalDeviceId: "device-1",
cdp: {
evaluateWithBinding: vi.fn(async () => ({ items: rawItems })),
},
sourceResolver: { symbolicate, readSourceFragment },
},
} as unknown as Record<string, unknown>,
symbolicate,
readSourceFragment,
};
}

const params = {
port: 8081,
device_id: "device-1",
x: 100,
y: 200,
contextLines: 3,
resolveSourceMaps: true,
maxItems: 35,
includeSkipped: false,
};

describe("debuggerInspectElementTool — raw fallback when symbolication fails", () => {
it("retains the raw bundled frame location when symbolicate returns null", async () => {
const { services, readSourceFragment } = fakeServices(
[{ name: "Screen", frame: { fn: "Screen", file: "http://localhost:8081/index.bundle", line: 4321, col: 17, original: false } }],
async () => null // symbolication failed / echoed bundle URL
);

const result = await debuggerInspectElementTool.execute(services, params);
if ("error" in result) throw new Error(`unexpected error: ${result.error}`);

expect(result.items).toHaveLength(1);
expect(result.items[0].name).toBe("Screen");
// Raw fallback mirrors the resolveSourceMaps:false shape: column <- frame.col
expect(result.items[0].source).toEqual({
file: "http://localhost:8081/index.bundle",
line: 4321,
column: 17,
});
// No source file was read because symbolication produced no mapped location.
expect(result.items[0].code).toBeNull();
expect(readSourceFragment).not.toHaveBeenCalled();
});

it("uses the mapped location when symbolicate succeeds", async () => {
const mapped = { file: "app/screen.tsx", line: 12, column: 4 };
const { services, readSourceFragment } = fakeServices(
[{ name: "Screen", frame: { fn: "Screen", file: "http://localhost:8081/index.bundle", line: 4321, col: 17, original: false } }],
async () => mapped
);

const result = await debuggerInspectElementTool.execute(services, params);
if ("error" in result) throw new Error(`unexpected error: ${result.error}`);

expect(result.items[0].source).toEqual(mapped);
expect(result.items[0].code).toBe(" > 1 | code");
expect(readSourceFragment).toHaveBeenCalledOnce();
});
});
77 changes: 76 additions & 1 deletion packages/tool-server/test/metro/source-resolver.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { describe, it, expect, vi, beforeEach, beforeAll, afterAll } from "vitest";
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
Expand All @@ -8,6 +8,15 @@ import {
createSourceResolver,
} from "../../src/utils/debugger/source-resolver";

const mockFetch = vi.fn();
vi.stubGlobal("fetch", mockFetch);

function symbolicateResponse(frame: { file?: string; lineNumber?: number; column?: number }) {
return new Response(JSON.stringify({ stack: [frame] }), {
headers: { "Content-Type": "application/json" },
});
}

describe("parseDebugStack", () => {
it("parses stack frames correctly", () => {
const stack = `Error: react-stack-top-frame
Expand Down Expand Up @@ -113,3 +122,69 @@ describe("readSourceFragment containment + extension allowlist", () => {
expect(out).toBeNull();
});
});

describe("createSourceResolver — symbolicate", () => {
const projectRoot = "/Users/dev/myapp";
const bundleUrl = "http://localhost:8081/index.bundle?platform=ios&dev=true";

beforeEach(() => {
mockFetch.mockReset();
});

it("keeps genuinely-mapped node_modules sources", async () => {
// A route component (expo-router / react-navigation) legitimately resolves
// into node_modules. Metro returns a real file path — it must be kept.
mockFetch.mockResolvedValueOnce(
symbolicateResponse({
file: `${projectRoot}/node_modules/expo-router/build/views/Navigator.js`,
lineNumber: 42,
column: 7,
})
);

const resolver = createSourceResolver(8081, projectRoot);
const result = await resolver.symbolicate(bundleUrl, 100, 20, "Navigator");

expect(result).toEqual({
file: "node_modules/expo-router/build/views/Navigator.js",
line: 42,
column: 7,
});
});

it("rejects unmapped bundle URLs echoed back by Metro", async () => {
// A failed symbolication echoes the bundle URL back unchanged.
mockFetch.mockResolvedValueOnce(
symbolicateResponse({ file: bundleUrl, lineNumber: 100, column: 20 })
);

const resolver = createSourceResolver(8081, projectRoot);
const result = await resolver.symbolicate(bundleUrl, 100, 20, "App");

expect(result).toBeNull();
});

it("maps real app source paths relative to the project root", async () => {
mockFetch.mockResolvedValueOnce(
symbolicateResponse({
file: `${projectRoot}/app/index.tsx`,
lineNumber: 12,
column: 4,
})
);

const resolver = createSourceResolver(8081, projectRoot);
const result = await resolver.symbolicate(bundleUrl, 100, 20, "Index");

expect(result).toEqual({ file: "app/index.tsx", line: 12, column: 4 });
});

it("returns null when the symbolicate request throws", async () => {
mockFetch.mockRejectedValueOnce(new Error("network down"));

const resolver = createSourceResolver(8081, projectRoot);
const result = await resolver.symbolicate(bundleUrl, 100, 20, "App");

expect(result).toBeNull();
});
});
Loading