diff --git a/packages/tool-server/src/tools/debugger/debugger-inspect-element.ts b/packages/tool-server/src/tools/debugger/debugger-inspect-element.ts index 0ab93ef7..829afd00 100644 --- a/packages/tool-server/src/tools/debugger/debugger-inspect-element.ts +++ b/packages/tool-server/src/tools/debugger/debugger-inspect-element.ts @@ -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 = { diff --git a/packages/tool-server/src/utils/debugger/source-resolver.ts b/packages/tool-server/src/utils/debugger/source-resolver.ts index 8862a24d..04ff371e 100644 --- a/packages/tool-server/src/utils/debugger/source-resolver.ts +++ b/packages/tool-server/src/utils/debugger/source-resolver.ts @@ -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(/^\/+/, ""); diff --git a/packages/tool-server/test/debugger/inspect-element-filter.test.ts b/packages/tool-server/test/debugger/inspect-element-filter.test.ts index 4f1f3692..5ab6e1a5 100644 --- a/packages/tool-server/test/debugger/inspect-element-filter.test.ts +++ b/packages/tool-server/test/debugger/inspect-element-filter.test.ts @@ -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"; @@ -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 +) { + 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, + 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(); + }); +}); diff --git a/packages/tool-server/test/metro/source-resolver.test.ts b/packages/tool-server/test/metro/source-resolver.test.ts index 64cc1064..d6de0b03 100644 --- a/packages/tool-server/test/metro/source-resolver.test.ts +++ b/packages/tool-server/test/metro/source-resolver.test.ts @@ -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"; @@ -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 @@ -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(); + }); +});