From 660d53e237ce2817e1a7807203839ef408751aaa Mon Sep 17 00:00:00 2001 From: Gowtham Kumar Solleti Date: Tue, 20 Oct 2026 23:26:25 -0500 Subject: [PATCH] Source hint for production builds, through the page's source maps When a minified build has stripped React's _debugSource and Vue's __file, inspector.js hands the nearest component function's own source text and the page's script URLs to the worker, which finds the script that holds it, follows its sourceMappingURL (or SourceMap header; data: and indexed maps too), decodes the VLQ mappings and puts the original file:line and component name into the source hint with method "sourcemap". Same origin (or loopback from a loopback page) only, redirects refused, no cookies, 8 MB per file, 1.5 s total; any failure leaves no hint. No new permission or dependency. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 10 + PRIVACY.md | 18 +- README.md | 3 +- SECURITY.md | 1 + bridge/store.js | 2 +- extension/background.js | 10 +- extension/content.js | 44 +++- extension/inspector.js | 27 +++ extension/sourcemap.js | 200 ++++++++++++++++++ test/e2e-sourcemap.test.mjs | 130 ++++++++++++ test/fixtures/sourcemap/build.sh | 5 + .../sourcemap/src/components/PriceTag.js | 15 ++ test/fixtures/sourcemap/src/main.js | 6 + test/fixtures/sourcemap/src/runtime.js | 8 + test/ship-firefox.test.mjs | 2 +- test/site/sourcemap/app.min.js | 2 + test/site/sourcemap/app.min.js.map | 7 + test/site/sourcemap/index.html | 5 + test/ui-sourcemap.test.mjs | 139 ++++++++++++ tools/make-firefox-zip.sh | 2 +- 20 files changed, 624 insertions(+), 12 deletions(-) create mode 100644 extension/sourcemap.js create mode 100644 test/e2e-sourcemap.test.mjs create mode 100644 test/fixtures/sourcemap/build.sh create mode 100644 test/fixtures/sourcemap/src/components/PriceTag.js create mode 100644 test/fixtures/sourcemap/src/main.js create mode 100644 test/fixtures/sourcemap/src/runtime.js create mode 100644 test/site/sourcemap/app.min.js create mode 100644 test/site/sourcemap/app.min.js.map create mode 100644 test/site/sourcemap/index.html create mode 100644 test/ui-sourcemap.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 336aad9..26cbc8b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- **A source hint for production builds, from the page's source maps.** When a minified build has + stripped React's `_debugSource` and Vue's `__file`, Pinpoint takes the clicked component's own + minified code, finds it in the scripts the page loaded, follows that script's `sourceMappingURL` + (or `SourceMap` header; `data:` maps and indexed maps too) and maps it back to the original + `file:line` and component name. The note's source hint says `method: "sourcemap"`. The service + worker fetches only the page's own origin (or another loopback port from a loopback page) — never + a third-party host — within 1.5 seconds and 8 MB per file; on any failure the note simply has no + file, as before. The VLQ decoder is ~40 lines in `extension/sourcemap.js`, no new dependency. + ## [0.7.1] — 2026-10-20 ### Added diff --git a/PRIVACY.md b/PRIVACY.md index abef53f..35d9503 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -1,6 +1,6 @@ # Privacy -*Last updated: 18 October 2026. This describes Pinpoint 0.7 — the extension in `extension/` and +*Last updated: 20 October 2026. This describes Pinpoint 0.7 — the extension in `extension/` and the bridge in `bridge/`, as published in this repository.* Pinpoint is a developer tool that runs on your own machine. There is no Pinpoint account, no Pinpoint @@ -16,7 +16,8 @@ this is assembled and posted to your local bridge: - **the element** — tag, id, classes, a CSS selector, DOM path, rendered text, trimmed `outerHTML`, `role`/`aria-*`/`data-*` attributes, bounding box, about 25 computed style properties, and a fingerprint used to re-find the element after a re-render -- **the framework hint** where a dev build exposes it — React/Vue component chain and `file:line` +- **the framework hint** where a dev build exposes it — React/Vue component chain and `file:line` — + or, when a production build has stripped that, the `file:line` the page's own source map gives (below) - **your comment** — the sentence you typed - **a screenshot** — a PNG cropped to the element, captured by the extension's service worker - **page errors and failed requests**, only when something went wrong (below) @@ -42,6 +43,14 @@ recorded. Request and response bodies and headers are never read. The record is extension's memory only, per tab, and is dropped when the tab closes or loads a new page. Nothing is attached when nothing went wrong. +**Source maps.** When the framework hint finds no file (a production build), the extension's +service worker reads the page's own scripts and their source maps to find where the clicked component +came from: it downloads only scripts the page itself loaded, and only from the page's own origin (or, +on a loopback page, another loopback port), without cookies, at most 8 MB each, for at most 1.5 +seconds. Scripts and maps on any other host are never requested. What it keeps is a file path, a +line and a component name in the note's source hint; the scripts and maps stay in the worker's +memory for a minute and are not stored or sent anywhere. + Before page errors and failed requests leave the browser, they are cut to 2000 characters and then scrubbed. Query strings and fragments are dropped from URLs, but a stack frame's line:col is kept. Passwords in URLs (`scheme://user:pass@host`) are removed. Bearer and Basic credentials, JWTs, long @@ -75,7 +84,8 @@ for microphone permission the first time. Dictation is not offered on Firefox. To `http://127.0.0.1:` (7331 by default) and nowhere else. The extension contains no other network destination — no telemetry endpoint, no remote code, no update service beyond the browser's -own. The bridge refuses any request carrying a web page's `Origin`, accepts an extension only with the +own. (The one other request it makes is the source-map lookup above: to the local page's own dev +server, for files that page already loaded.) The bridge refuses any request carrying a web page's `Origin`, accepts an extension only with the token in `~/.pinpoint/token`, and only listens on loopback. **The one place data leaves Pinpoint is the one you asked for:** the coding agent you connect. Over @@ -116,7 +126,7 @@ leaves nothing behind. | Permission | Why | |---|---| -| `host_permissions: ` | Chrome requires a broad host permission for `tabs.captureVisibleTab`, which takes the screenshot. A narrower permission does not grant that API. It is not used to read or inject into pages beyond the local-development matches below. | +| `host_permissions: ` | Chrome requires a broad host permission for `tabs.captureVisibleTab`, which takes the screenshot. A narrower permission does not grant that API. It is not used to read or inject into pages beyond the local-development matches below; the only fetches it allows are the source-map lookup's, to a local page's own origin. | | `activeTab`, `scripting` | Show the bar and the picker on the page you are annotating. | | `tabs` | Know which tab is being captured, and keep pins in sync across tabs showing the same page. | | `storage` | The settings listed above. | diff --git a/README.md b/README.md index e2b491a..a9a3229 100644 --- a/README.md +++ b/README.md @@ -488,7 +488,8 @@ element tag, id, classes, a CSS selector built from stable attributes w attributes, bounding box, ~25 computed style properties, and a fingerprint (tag + text + key attributes) used to verify a pin is still on the right element source framework (react/vue/svelte/angular/astro), component chain, - file:line where the dev build exposes it + file:line where the dev build exposes it — or, in a production build, from the + page's own source maps (same origin only; method: "sourcemap") screenshot PNG of just the element (+8px), long edge ≤1200px context only when something went wrong: uncaught errors and failed requests from the minute before, redacted in the browser diff --git a/SECURITY.md b/SECURITY.md index b1f4883..b68ec57 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -18,6 +18,7 @@ Pinpoint moves text and screenshots from your browser to your coding agent, so t - **Page context is untrusted and redacted.** Error messages, stack lines and failed-request URLs come from the page, like the element's text and HTML. The service worker checks their shape, keeps only the last 60 seconds and redacts them (`extension/redact.js`) before they reach the bridge; if the redaction script is missing, nothing is attached. Before page errors and failed requests leave the browser, they are cut to 2000 characters and then scrubbed. Query strings and fragments are dropped from URLs, but a stack frame's line:col is kept. Passwords in URLs (`scheme://user:pass@host`) are removed. Bearer and Basic credentials, JWTs, long hex and base64 strings are replaced. So are the values of key=value and "key":"value" pairs whose name ends in a secret word (api key, token, secret, password/passwd/pwd, session id, sid, signature/sig, auth), including prefixed names such as DB_PASSWORD or csrf_token. Each string is capped at 300 characters. In the agent's markdown every entry is flattened to one line with emphasis and backticks removed, under a label saying it is page data, not instructions. - **Recorded steps are scraped page data.** They appear in the markdown below the "untrusted data" line, flattened (no newlines, emphasis or backticks), and the store caps them at 10. Only trusted `pointerdown` events in the top frame become steps, and only the top frame may start, add to or stop a recording. - **New routes keep the same guards.** `POST /annotations/:id/accept` and `POST /annotations/:id/after` sit behind the same Host, Origin and `x-pinpoint-token` checks as every other annotation route. `/after` only re-checks a resolved note's element in a tab already showing that page, and only while the extension has been heard from in the last two minutes. `/android` and `/android/annotations` accept only the bridge's own origin or a token-carrying extension, like the `/ios` routes. +- **The source-map lookup never leaves the page's origin.** When a production build has no framework debug info, the service worker (`extension/sourcemap.js`) fetches the scripts the page loaded and their source maps to resolve the source hint. It runs only for a local development page, takes the origin from the browser's own record of the asking frame (never from the message), and fetches only URLs on that origin — or another loopback port when the page is on loopback — with redirects refused, no cookies, an 8 MB cap per file and a 1.5 s budget. A map on a CDN or any third-party host is not requested. Running in the worker rather than the page means the page's CSP and any `fetch` it has wrapped play no part, and the page cannot see or answer the requests. Only the resulting file, line and component name go into the note. - **The extension only injects itself into local development pages** — localhost, the loopback range, private LAN addresses, `.local`/`.test`/`.localhost` hosts and `file://`. Anywhere else it is not present unless you turn it on for that tab from the toolbar. - **The `` host permission** is required by Chrome for `tabs.captureVisibleTab` (the element screenshot) and nothing else; a narrower permission does not grant that API. - **Screenshots never become loose image files.** They are stored base64-encoded inside `~/.pinpoint/annotations.json`, and a queued capture is abandoned rather than taken if the page changed first — you never get a screenshot of the wrong page. diff --git a/bridge/store.js b/bridge/store.js index dc925ce..ecb8e2d 100644 --- a/bridge/store.js +++ b/bridge/store.js @@ -205,7 +205,7 @@ export function toMarkdown(a, { heading = true, channel = "mcp" } = {}) { lines.push(`- Text as rendered: "${flat(e.text)}"` + (tt && tt !== "none" ? ` (CSS text-transform: ${tt} — the source string will differ, grep the HTML below instead)` : "")); } if (s.components?.length) lines.push(`- Component chain (${flat(s.framework)}): ${s.components.map(flat).join(" ← ")}`); - if (s.file) lines.push(`- Source file: ${flat(s.file)}${s.line ? ":" + flat(s.line) : ""}`); + if (s.file) lines.push(`- Source file: ${flat(s.file)}${s.line ? ":" + flat(s.line) : ""}` + (s.method === "sourcemap" ? " (found through the page's source map)" : "")); else if (!s.components?.length) lines.push(`- Source file: not detected (no framework metadata on this page) — find the element by grepping the HTML below`); const attrs = Object.entries(s.attributes || {}); if (attrs.length) lines.push(`- Markers: ${attrs.map(([k, v]) => `${flat(k)}="${flat(v)}"`).join(", ")}`); diff --git a/extension/background.js b/extension/background.js index a8ab8ff..48f71d4 100644 --- a/extension/background.js +++ b/extension/background.js @@ -10,7 +10,8 @@ const api = globalThis.browser ?? globalThis.chrome; // pinpointRedact, for page context. A Firefox build lists redact.js in background.scripts instead. -if (typeof importScripts === "function") { try { importScripts("redact.js"); } catch {} } +// pinpointSourceMap, for the source-hint fallback; Firefox lists it in background.scripts too. +if (typeof importScripts === "function") { try { importScripts("redact.js"); } catch {} try { importScripts("sourcemap.js"); } catch {} } const DEFAULT_PORT = 7331; const WATCH_ALARM = "pinpoint-watch"; @@ -710,6 +711,13 @@ api.runtime.onMessage.addListener((msg, sender, sendResponse) => { if (!tab?.id) return { ok: false, error: "No active tab." }; return togglePicker(tab, true); } + // The source-hint fallback (sourcemap.js). Here, not in the page: the page's CSP and any fetch + // it has wrapped do not apply, and the page never sees the requests. The origin checked is the + // asking frame's own, from the browser — never one the message names. + case "sourcemap": { + if (!sender.url || !isLocalDev(sender.url) || !globalThis.pinpointSourceMap) return null; + return globalThis.pinpointSourceMap.resolve({ snippet: msg.snippet, scripts: msg.scripts }, sender.url); + } case "canRun": { const [tab] = await api.tabs.query({ active: true, currentWindow: true }); const reason = await whyNot(tab?.url); diff --git a/extension/content.js b/extension/content.js index 2f43489..2e247ef 100644 --- a/extension/content.js +++ b/extension/content.js @@ -670,6 +670,39 @@ return { framework: null, components: [], file: null, line: null, column: null, attributes: {} }; } + // A production build has stripped the framework's debug info, so the hint has no file: ask the + // worker to trace the component through the page's own source maps (sourcemap.js). Bounded, and + // any failure just leaves the hint as it was. + // Started when the popover opens (paintMeta) and reused at Send, so the lookup has usually + // finished — or at least filled the worker's cache — by the time the comment is written. + const mapLookups = new WeakMap(); + function mapLookup(el) { + if (!mapLookups.has(el)) mapLookups.set(el, mapSource(el).catch(() => null)); + return mapLookups.get(el); + } + async function mapSource(el) { + let raw = null; + const on = (e) => { raw = e.detail; }; + el.addEventListener("pinpoint:probe-result", on, { once: true }); + el.dispatchEvent(new CustomEvent("pinpoint:probe", { bubbles: true })); + el.removeEventListener("pinpoint:probe-result", on); + let probe = null; + try { probe = JSON.parse(raw); } catch {} + if (typeof probe?.snippet !== "string") return null; + const r = await chrome.runtime.sendMessage({ type: "sourcemap", snippet: probe.snippet, scripts: probe.scripts }).catch(() => null); + if (typeof r?.file !== "string") return null; + return { ...r, fnName: probe.name }; + } + async function applyMapSource(el, hint) { + const r = await Promise.race([mapLookup(el), new Promise((res) => setTimeout(res, 1800))]); // the worker stops at 1.5 s + if (!r) return; + Object.assign(hint, { file: r.file, line: r.line ?? null, column: r.column ?? null, method: "sourcemap" }); + if (r.name) { + const i = hint.components.indexOf(r.fnName); + if (i >= 0) hint.components[i] = r.name; else hint.components.unshift(r.name); + } + } + // The page's recent uncaught errors, from inspector.js in the MAIN world. Raw and untrusted: the // worker validates and redacts them before they reach the bridge. function pageErrors() { @@ -1625,6 +1658,7 @@ // repaint it without wiping whatever you have already typed. function paintMeta(el, region) { const hint = sourceHint(el); + if (!hint.file && /^(react|vue)$/.test(hint.framework || "")) mapLookup(el); const comp = hint.components[0] ? [elt("b", null, `<${hint.components[0]}>`), " "] : []; const file = hint.file ? ` · ${hint.file}${hint.line ? ":" + hint.line : ""}` : ""; if (flowSteps) { @@ -1876,6 +1910,8 @@ // A region annotation is an element annotation on its anchor, plus the box. Everything // downstream — selector, fingerprint, pin re-finding, styles — keeps working unchanged. if (opts.region) a.region = opts.region; + // Only React and Vue leave a component function to look up; anything else adds no await here. + if (!a.source.file && /^(react|vue)$/.test(a.source.framework || "")) { try { await applyMapSource(el, a.source); } catch {} } const errors = pageErrors(); if (errors.length) a.context = { errors }; return a; @@ -1894,7 +1930,7 @@ ]; if (a.element.text) lines.push(`**Text:** "${a.element.text}"`); if (s.components?.length) lines.push(`**Component chain (${s.framework}):** ${s.components.join(" ← ")}`); - if (s.file) lines.push(`**Source:** ${s.file}${s.line ? ":" + s.line : ""}`); + if (s.file) lines.push(`**Source:** ${s.file}${s.line ? ":" + s.line : ""}${s.method === "sourcemap" ? " (via source map)" : ""}`); lines.push(`**Size:** ${a.element.rect.width}×${a.element.rect.height}px at viewport ${a.page.viewport.width}×${a.page.viewport.height}`); const st = Object.entries(a.element.styles).map(([k, v]) => `${k}: ${v}`).join("; "); if (st) lines.push(``, `**Computed styles:** ${st}`); @@ -1919,12 +1955,14 @@ try { const region = selectedRegion; const page = selectedPage, steps = flowSteps; + // A page note is cropped to the window: the whole visible page, not the box. Measured + // before the note is built: a source-map lookup in there can take a moment, and the crop is of + // what was on screen when Send was pressed (the worker skips it if the page scrolled since). + const capture = await captureRect(el, page ? { x: window.scrollX, y: window.scrollY, width: window.innerWidth, height: window.innerHeight } : region); const a = await buildAnnotation(el, comment, region ? { region } : {}); if (draft) a.status = "draft"; if (page) a.kind = "page"; if (steps) { a.kind = "flow"; a.steps = steps; } - // A page note is cropped to the window: the whole visible page, not the box. - const capture = await captureRect(el, page ? { x: window.scrollX, y: window.scrollY, width: window.innerWidth, height: window.innerHeight } : region); if (capture) hideOverlayForCapture(); // The worker stores the comment first and answers immediately, then takes and attaches the // crop on its own. Nothing here is on the page's lifetime. diff --git a/extension/inspector.js b/extension/inspector.js index 3c9acf9..3f9b959 100644 --- a/extension/inspector.js +++ b/extension/inspector.js @@ -87,6 +87,33 @@ return hint; } + // For the source-map fallback (sourcemap.js, in the worker): the nearest component's own source + // text and the page's scripts, so the worker can find which bundle holds it. Only asked when the + // hint above found no file — a production build. The function's text is the page's own code. + function mapProbe(el) { + let fn = null; + for (let node = el; node && !fn; node = node.parentElement) { + const key = Object.keys(node).find((k) => k.startsWith("__reactFiber$") || k.startsWith("__reactInternalInstance$")); + for (let f = key && node[key], i = 0; f && i < 80 && !fn; f = f.return, i++) { + const t = f.type; + fn = typeof t === "function" ? t : [t?.render, t?.type, t?.type?.render].find((x) => typeof x === "function") || null; + } + const v = node.__vueParentComponent?.type || node.__vue__?.$options; + if (!fn && v) fn = [v.setup, v.render].find((x) => typeof x === "function") || null; + } + if (!fn) return null; + const src = Function.prototype.toString.call(fn); + if (src.length < 16 || /\{\s*\[native code\]\s*\}/.test(src)) return null; + const scripts = new Set([...document.scripts].map((s) => s.src).filter(Boolean)); + try { for (const r of performance.getEntriesByType("resource")) if (r.initiatorType === "script") scripts.add(r.name); } catch {} + return { snippet: src.slice(0, 120), name: fn.name || null, scripts: [...scripts].slice(0, 30) }; + } + document.addEventListener("pinpoint:probe", (e) => { + let r = null; + try { r = mapProbe(e.target); } catch {} + e.target.dispatchEvent(new CustomEvent("pinpoint:probe-result", { detail: JSON.stringify(r) })); + }, true); + // ---------- page context ---------- // What went wrong on the page lately: uncaught errors and unhandled rejections, newest last. Only // listening — console is never wrapped, so DevTools keeps showing the page's own call sites. This diff --git a/extension/sourcemap.js b/extension/sourcemap.js new file mode 100644 index 0000000..b541ce8 --- /dev/null +++ b/extension/sourcemap.js @@ -0,0 +1,200 @@ +// The source hint's last resort: a production build strips React's _debugSource and Vue's __file, +// but its source map still knows where every function came from. inspector.js (MAIN world) hands +// us the clicked component's minified source text and the page's script URLs; here, in the worker, +// we find which script holds that text, read the script's source map and map the position back. +// Loaded as a classic script by the worker (Firefox lists it in background.scripts); the tests load +// it the same way. Nothing fetched here leaves the browser: the result is a file:line in the note. +(() => { + const B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + const DIGIT = new Int8Array(128).fill(-1); + for (let i = 0; i < 64; i++) DIGIT[B64.charCodeAt(i)] = i; + + // "mappings" → one array per generated line of [genCol, src, srcLine, srcCol, name?], all + // absolute (the field stores deltas; genCol resets each line, the rest run on across lines). + function decodeMappings(str) { + const lines = [[]]; + const v = [0, 0, 0, 0, 0]; + let seg = [], n = 0, shift = 0; + for (let i = 0; i <= str.length; i++) { + const c = i < str.length ? str.charCodeAt(i) : 59; // 59 ";" closes the last segment + if (c === 44 || c === 59) { // "," or ";" + if (seg.length) { + if (seg.length !== 1 && seg.length !== 4 && seg.length !== 5) throw new Error("bad segment"); + const out = []; + for (let k = 0; k < seg.length; k++) out.push(v[k] += seg[k]); + lines[lines.length - 1].push(out); + seg = []; + } + if (c === 59 && i < str.length) { lines.push([]); v[0] = 0; } + continue; + } + const d = c < 128 ? DIGIT[c] : -1; + if (d < 0) throw new Error("bad base64 in mappings"); + n += (d & 31) << shift; // ponytail: plain shift; values past 2^31 do not occur in real maps + if (d & 32) { shift += 5; if (shift > 30) throw new Error("VLQ too long"); continue; } + seg.push(n & 1 ? -(n >>> 1) : n >>> 1); + n = 0; shift = 0; + } + for (const l of lines) l.sort((a, b) => a[0] - b[0]); + return lines; + } + + // The segment at or before (line, column), both 0-based, on that generated line only — the + // same rule as source-map's originalPositionFor (GREATEST_LOWER_BOUND). + function segmentAt(lines, line, column) { + const segs = lines[line]; + if (!segs) return null; + let lo = 0, hi = segs.length - 1, best = -1; + while (lo <= hi) { + const mid = (lo + hi) >> 1; + if (segs[mid][0] <= column) { best = mid; lo = mid + 1; } else hi = mid - 1; + } + return best < 0 ? null : { segs, i: best }; + } + + // A map's sources as webpack, Vite and esbuild write them, made project-relative where possible. + function cleanSource(source, sourceRoot) { + let s = String(source || ""); + if (sourceRoot && !/^[a-z][\w+.-]*:/i.test(s) && !s.startsWith("/")) s = sourceRoot.replace(/\/?$/, "/") + s; + s = s.replace(/^webpack:\/\/[^/]*\//, "").replace(/^(file|vite|turbopack|rollup):\/\//, "").replace(/^\/@fs\//, "/") + .replace(/[?#].*$/, "").replace(/^(\.\.?\/)+/, "").replace(/^\.\//, ""); + const m = ("/" + s).match(/^.*?\/((?:src|app|pages|components|lib)\/.*)$/); // the first such folder + return m ? m[1] : s; + } + + // (map, 0-based line, 0-based column) → { file, line, column (1-based), name } or null. + // `map._lines` caches the decoded mappings on the parsed map object. + function originalPosition(map, line, column) { + if (Array.isArray(map.sections)) { + let sec = null; + for (const s of map.sections) { + const o = s.offset || {}; + if (o.line < line || (o.line === line && o.column <= column)) sec = s; else break; + } + if (!sec?.map) return null; // a section by "url" is not followed + const o = sec.offset; + return originalPosition(sec.map, line - o.line, line === o.line ? column - o.column : column); + } + if (typeof map.mappings !== "string" || !Array.isArray(map.sources)) return null; + map._lines ||= decodeMappings(map.mappings); + const hit = segmentAt(map._lines, line, column); + if (!hit) return null; + const seg = hit.segs[hit.i]; + if (seg.length < 4) return null; + const file = cleanSource(map.sources[seg[1]], map.sourceRoot); + if (!file || /(^|\/)node_modules\//.test(file)) return null; + // A component's own name: names[] on this segment or on the next few (esbuild puts it on the + // identifier after `function`). PascalCase only, so a destructured prop is never taken for one. + let name = null; + for (let k = hit.i; k < hit.segs.length && hit.segs[k][0] <= column + 40 && !name; k++) { + const s = hit.segs[k], cand = s.length === 5 ? map.names?.[s[4]] : null; + if (typeof cand === "string" && /^[A-Z][\w$]*$/.test(cand)) name = cand; + } + return { file, line: seg[2] + 1, column: seg[3] + 1, name }; + } + + // Where `snippet` starts in `text`, 0-based — null when absent or when it appears twice. + function locate(text, snippet) { + const at = text.indexOf(snippet); + if (at < 0 || text.indexOf(snippet, at + 1) >= 0) return null; + let line = 0, last = -1; + for (let i = text.indexOf("\n"); i >= 0 && i < at; i = text.indexOf("\n", i + 1)) { line++; last = i; } + return { line, column: at - last - 1 }; + } + + // The last `//# sourceMappingURL=` comment (or the older `//@`). + function mapComment(text) { + const tail = text.slice(-4096); + let m, url = null; + const re = /\/[/*][#@][ \t]*sourceMappingURL=([^\s'"*]+)/g; + while ((m = re.exec(tail))) url = m[1]; + return url; + } + + const LOOPBACK = (h) => h === "localhost" || h.endsWith(".localhost") || /^127\./.test(h) || h === "[::1]"; + // Same origin as the page, or loopback when the page itself is on loopback (a dev server on + // another port). Never a third party: a CDN's scripts and maps are not ours to download. + function allowed(url, pageUrl) { + try { + const u = new URL(url), p = new URL(pageUrl); + if (!/^https?:$/.test(u.protocol)) return false; + return u.origin === p.origin || (LOOPBACK(p.hostname) && LOOPBACK(u.hostname)); + } catch { return false; } + } + + const CAP = 8 * 1024 * 1024; + // fetch → text, refused past CAP bytes. Redirects fail: a same-origin URL must not bounce us to + // another host. No cookies: dev servers serve their bundles without them. + async function fetchText(url, signal) { + const res = await fetch(url, { signal, credentials: "omit", redirect: "error", cache: "force-cache" }); + if (!res.ok) throw new Error("HTTP " + res.status); + if (Number(res.headers.get("content-length")) > CAP) throw new Error("too big"); + const reader = res.body.getReader(), parts = []; + let size = 0; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + size += value.byteLength; + if (size > CAP) { reader.cancel().catch(() => {}); throw new Error("too big"); } + parts.push(value); + } + const text = new TextDecoder().decode(await new Blob(parts).arrayBuffer()); + return { text, header: res.headers.get("sourcemap") || res.headers.get("x-sourcemap") }; + } + + // ponytail: 8 scripts, 60 s, oldest first out; a cache keyed by more than URL when a dev server + // serves new code under an unchanged URL faster than that. + const cache = new Map(); + function cached(key, make) { + const hit = cache.get(key); + if (hit && Date.now() - hit.at < 60000) return hit.p; + const p = make(); + p.catch(() => cache.delete(key)); + cache.set(key, { at: Date.now(), p }); + if (cache.size > 8) cache.delete(cache.keys().next().value); + return p; + } + + async function loadMap(ref, scriptUrl, pageUrl, signal, get) { + if (ref.startsWith("data:")) { + const comma = ref.indexOf(","); + const meta = ref.slice(5, comma), body = ref.slice(comma + 1); + const raw = /;base64$/i.test(meta) ? new TextDecoder().decode(Uint8Array.from(atob(body), (c) => c.charCodeAt(0))) : decodeURIComponent(body); + return JSON.parse(raw); + } + const url = new URL(ref, scriptUrl).href; + if (!allowed(url, pageUrl)) return null; + return JSON.parse((await get(url, signal)).text.replace(/^\)\]\}'[^\n]*\n/, "")); + } + + // { snippet, scripts } from the page, pageUrl from the sender → { file, line, column, name } | null. + // `get` is fetchText; tests pass their own. + async function resolve({ snippet, scripts }, pageUrl, { timeout = 1500, get = fetchText } = {}) { + if (typeof snippet !== "string" || snippet.length < 16 || snippet.length > 500 || !Array.isArray(scripts)) return null; + const ctl = new AbortController(); + const timer = setTimeout(() => ctl.abort(), timeout); + const urls = [...new Set(scripts.filter((s) => typeof s === "string" && allowed(s, pageUrl)))].slice(0, 30); + try { + const found = await Promise.race([ + new Promise((_, rej) => ctl.signal.addEventListener("abort", () => rej(new Error("timeout")))), + (async () => { + const hits = await Promise.all(urls.map(async (url) => { + const entry = await cached(url, () => get(url, ctl.signal)).catch(() => null); + const at = entry && locate(entry.text, snippet); + return at && { url, entry, at }; + })); + const hit = hits.find(Boolean); + if (!hit) return null; + const ref = mapComment(hit.entry.text) || hit.entry.header; + if (!ref) return null; + hit.entry.map ||= loadMap(ref, hit.url, pageUrl, ctl.signal, get); + const map = await hit.entry.map.catch(() => { hit.entry.map = null; return null; }); + return map ? originalPosition(map, hit.at.line, hit.at.column) : null; + })(), + ]); + return found; + } catch { return null; } finally { clearTimeout(timer); } + } + + globalThis.pinpointSourceMap = { decodeMappings, segmentAt, cleanSource, originalPosition, locate, mapComment, allowed, resolve }; +})(); diff --git a/test/e2e-sourcemap.test.mjs b/test/e2e-sourcemap.test.mjs new file mode 100644 index 0000000..797c6be --- /dev/null +++ b/test/e2e-sourcemap.test.mjs @@ -0,0 +1,130 @@ +// A production build: a minified component behind a React-shaped fiber with no _debugSource, and +// an esbuild source map next to the bundle. The note's source hint must come from the map. +// Run: node --test e2e-sourcemap.test.mjs (its own bridge and site, PORT+12 / SITE+12) +import { test, before, after } from "node:test"; +import assert from "node:assert/strict"; +import { chromium } from "playwright"; +import { spawn } from "node:child_process"; +import http from "node:http"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { toMarkdown } from "../bridge/store.js"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const EXT = path.resolve(here, "../extension"); +const CLI = path.resolve(here, "../bridge/cli.js"); +const CHROME = process.env.PINPOINT_CHROME || undefined; +const PORT = (Number(process.env.PINPOINT_TEST_PORT) || 7399) + 12, SITE = (Number(process.env.PINPOINT_TEST_SITE) || 8081) + 12; +const BASE = `http://127.0.0.1:${PORT}`; +const HOME = fs.mkdtempSync(path.join(os.tmpdir(), "pp-sourcemap-")); +const DIR = path.join(here, "site/sourcemap"); + +// /prod.html loads the bundle whose map sits beside it; /cross.html loads the same bundle with its +// map on another origin (notlocal.example, which the browser resolves to this very server — so a +// fetch would succeed and show up in `requests`). +const requests = []; +const site = http.createServer((req, res) => { + requests.push(`${req.headers.host}${req.url}`); + const p = req.url.split("?")[0]; + const page = (script) => fs.readFileSync(path.join(DIR, "index.html"), "utf8").replace("SCRIPT", script); + const bundle = () => fs.readFileSync(path.join(DIR, "app.min.js"), "utf8"); + const send = (type, body) => { res.writeHead(200, { "content-type": type }); res.end(body); }; + if (p === "/prod.html") return send("text/html", page("/sourcemap/app.min.js")); + if (p === "/cross.html") return send("text/html", page("/sourcemap/cross.min.js")); + if (p === "/sourcemap/app.min.js") return send("text/javascript", bundle()); + if (p === "/sourcemap/cross.min.js") return send("text/javascript", bundle().replace(/sourceMappingURL=.*/, `sourceMappingURL=http://notlocal.example:${SITE}/sourcemap/app.min.js.map`)); + if (p === "/sourcemap/app.min.js.map") return send("application/json", fs.readFileSync(path.join(DIR, "app.min.js.map"))); + res.writeHead(404); res.end(); +}); + +let daemon, ctx; +before(async () => { + await new Promise((r) => site.listen(SITE, r)); + daemon = spawn("node", [CLI, "--port", String(PORT)], { env: { ...process.env, PINPOINT_HOME: HOME, PINPOINT_AFTER_DELAY: "0" }, stdio: "ignore" }); + for (let i = 0; i < 50; i++) { try { await fetch(BASE + "/health"); break; } catch { await new Promise((r) => setTimeout(r, 100)); } } + ctx = await chromium.launchPersistentContext("", { + ...(CHROME ? { executablePath: CHROME } : { channel: "chromium" }), headless: true, + args: ["--headless=new", `--disable-extensions-except=${EXT}`, `--load-extension=${EXT}`, + "--host-resolver-rules=MAP notlocal.example 127.0.0.1"], + viewport: { width: 1280, height: 800 }, + }); + let [sw] = ctx.serviceWorkers(); + if (!sw) sw = await ctx.waitForEvent("serviceworker"); + const token = fs.readFileSync(path.join(HOME, "token"), "utf8").trim(); + for (let i = 0; i < 40; i++) { + try { await sw.evaluate(async ({ port, token }) => { await chrome.storage.sync.set({ port }); await chrome.storage.local.set({ token }); }, { port: PORT, token }); break; } + catch { await new Promise((r) => setTimeout(r, 150)); } + } +}); +after(async () => { + await ctx?.close(); + daemon?.kill(); + site.close(); + fs.rmSync(HOME, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }); +}, { timeout: 60000 }); + +// Pinpoint's UI is in a closed shadow root, reachable only from the content script's isolated +// world — found over CDP, as in e2e.test.mjs. +async function inShadow(page, fn, arg) { + const cdp = await page.context().newCDPSession(page); + const ctxs = []; + cdp.on("Runtime.executionContextCreated", ({ context }) => { if (context.auxData?.type === "isolated" && /Pinpoint/.test(context.name) && context.auxData.isDefault === false) ctxs.push(context); }); + await cdp.send("Runtime.enable"); + const { frameTree } = await cdp.send("Page.getFrameTree"); + const c = ctxs.reverse().find((x) => x.auxData.frameId === frameTree.frame.id); + if (!c) throw new Error("Pinpoint's content script is not running"); + const r = await cdp.send("Runtime.callFunctionOn", { + functionDeclaration: `async function (a) { for (let i = 0; i < 60 && !globalThis.__pinpointShadow; i++) await new Promise((r) => setTimeout(r, 50)); return (${fn.toString()})(a); }`, + executionContextId: c.id, arguments: [{ value: arg ?? null }], returnByValue: true, awaitPromise: true, + }); + await cdp.detach(); + if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description || r.exceptionDetails.text); + return r.result.value; +} +async function waitShadow(page, fn, timeout = 15000) { + for (const t0 = Date.now(); Date.now() - t0 < timeout; await new Promise((r) => setTimeout(r, 50))) { + if (await inShadow(page, fn).catch(() => false)) return; + } + throw new Error("timed out: " + fn.toString().slice(0, 120)); +} + +async function noteOn(p, comment) { + await fetch(BASE + "/annotations", { method: "DELETE" }); + const page = await ctx.newPage(); + await page.goto(`http://localhost:${SITE}${p}`); + await page.waitForFunction(() => !!document.querySelector("pinpoint-root") && !!document.getElementById("price")); + await page.keyboard.press("Alt+Shift+A"); + await page.waitForFunction(() => document.documentElement.classList.contains("pinpoint-picking")); + await page.locator("#price").hover(); + await page.locator("#price").click(); + await waitShadow(page, () => __pinpointShadow.querySelector(".pop").style.display === "block"); + await inShadow(page, (c) => { const t = __pinpointShadow.querySelector("textarea"); t.value = c; t.focus(); __pinpointShadow.querySelector(".send").click(); }, comment); + await waitShadow(page, () => /Sent|offline|Failed/.test(__pinpointShadow.querySelector(".pop .status").textContent)); + assert.match(await inShadow(page, () => __pinpointShadow.querySelector(".pop .status").textContent), /Sent/); + const { annotations: [a] } = await (await fetch(BASE + "/annotations?status=pending")).json(); + await page.close(); + return a; +} + +test("a minified production component is traced through the source map to its original file:line", async () => { + const a = await noteOn("/prod.html", "bigger price"); + assert.equal(a.comment, "bigger price"); + assert.equal(a.source.framework, "react"); + assert.equal(a.source.file, "src/components/PriceTag.js"); + assert.equal(a.source.line, 9); + assert.equal(a.source.method, "sourcemap"); + assert.equal(a.source.components[0], "PriceTag", "the minified name is replaced by the original from names[]"); + assert.match(toMarkdown(a), /Source file: src\/components\/PriceTag\.js:9 \(found through the page's source map\)/); +}); + +test("a source map on another origin is never fetched, and the note has no source file", async () => { + requests.length = 0; + const a = await noteOn("/cross.html", "cross origin"); + assert.equal(a.source.framework, "react"); + assert.equal(a.source.file, null); + assert.equal(a.source.method, undefined); + assert.deepEqual(requests.filter((r) => r.startsWith("notlocal.example")), [], "the third-party map was requested"); + assert.ok(requests.some((r) => r.endsWith("/sourcemap/cross.min.js")), "the page itself did load the bundle"); +}); diff --git a/test/fixtures/sourcemap/build.sh b/test/fixtures/sourcemap/build.sh new file mode 100644 index 0000000..966fa55 --- /dev/null +++ b/test/fixtures/sourcemap/build.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +# Regenerates test/site/sourcemap/app.min.js(.map). Not run by the tests: the output is committed. +set -euo pipefail +cd "$(dirname "$0")" +npx --yes esbuild@0.24.2 src/main.js --bundle --minify --format=iife --sourcemap --outfile=../../site/sourcemap/app.min.js diff --git a/test/fixtures/sourcemap/src/components/PriceTag.js b/test/fixtures/sourcemap/src/components/PriceTag.js new file mode 100644 index 0000000..22d3999 --- /dev/null +++ b/test/fixtures/sourcemap/src/components/PriceTag.js @@ -0,0 +1,15 @@ +// A production-style component: once minified, nothing in the page says where it came from. +export function Badge({ text }) { + const el = document.createElement("span"); + el.className = "badge"; + el.textContent = text; + return el; +} + +export function PriceTag({ amount, currency = "USD" }) { + const el = document.createElement("button"); + el.id = "price"; + el.className = "price-tag"; + el.textContent = new Intl.NumberFormat("en-US", { style: "currency", currency }).format(amount); + return el; +} diff --git a/test/fixtures/sourcemap/src/main.js b/test/fixtures/sourcemap/src/main.js new file mode 100644 index 0000000..e466a43 --- /dev/null +++ b/test/fixtures/sourcemap/src/main.js @@ -0,0 +1,6 @@ +import { Badge, PriceTag } from "./components/PriceTag.js"; +import { mount } from "./runtime.js"; + +const root = document.getElementById("root"); +mount(root, Badge, { text: "new" }); +mount(root, PriceTag, { amount: 42 }); diff --git a/test/fixtures/sourcemap/src/runtime.js b/test/fixtures/sourcemap/src/runtime.js new file mode 100644 index 0000000..d9ddc6d --- /dev/null +++ b/test/fixtures/sourcemap/src/runtime.js @@ -0,0 +1,8 @@ +// Mirrors what React leaves on a DOM node: __reactFiber$ pointing at the host fiber, whose +// .return is the component's fiber with .type = the component function. No _debugSource: that is +// exactly what a production build strips. +export function mount(root, Component, props) { + const el = Component(props); + el["__reactFiber$pp" + Math.random().toString(36).slice(2)] = { type: el.tagName.toLowerCase(), return: { type: Component, return: null } }; + root.append(el); +} diff --git a/test/ship-firefox.test.mjs b/test/ship-firefox.test.mjs index 56e6eec..639def2 100644 --- a/test/ship-firefox.test.mjs +++ b/test/ship-firefox.test.mjs @@ -75,7 +75,7 @@ test("make-firefox-zip.sh builds an AMO package from the same extension/", zipOk assert.equal(m.version, src.version); assert.ok(m.name.length <= 45, "AMO refuses a longer name"); assert.equal(m.key, undefined, "AMO assigns ids from the gecko id, not a key"); - assert.deepEqual(m.background, { scripts: ["redact.js", "background.js"] }, "Firefox has no MV3 service workers; the redactor loads first or page context fails closed"); + assert.deepEqual(m.background, { scripts: ["redact.js", "sourcemap.js", "background.js"] }, "Firefox has no MV3 service workers; the redactor loads first or page context fails closed"); assert.deepEqual(m.browser_specific_settings.gecko.data_collection_permissions.required, ["websiteContent", "browsingActivity"]); assert.equal(m.browser_specific_settings.gecko_android, undefined, "no native messaging on Android, so it must not be listed there"); const g = m.browser_specific_settings.gecko; diff --git a/test/site/sourcemap/app.min.js b/test/site/sourcemap/app.min.js new file mode 100644 index 0000000..57fd37e --- /dev/null +++ b/test/site/sourcemap/app.min.js @@ -0,0 +1,2 @@ +(()=>{function a({text:n}){let t=document.createElement("span");return t.className="badge",t.textContent=n,t}function c({amount:n,currency:t="USD"}){let e=document.createElement("button");return e.id="price",e.className="price-tag",e.textContent=new Intl.NumberFormat("en-US",{style:"currency",currency:t}).format(n),e}function o(n,t,e){let r=t(e);r["__reactFiber$pp"+Math.random().toString(36).slice(2)]={type:r.tagName.toLowerCase(),return:{type:t,return:null}},n.append(r)}var m=document.getElementById("root");o(m,a,{text:"new"});o(m,c,{amount:42});})(); +//# sourceMappingURL=app.min.js.map diff --git a/test/site/sourcemap/app.min.js.map b/test/site/sourcemap/app.min.js.map new file mode 100644 index 0000000..9bd0923 --- /dev/null +++ b/test/site/sourcemap/app.min.js.map @@ -0,0 +1,7 @@ +{ + "version": 3, + "sources": ["../../fixtures/sourcemap/src/components/PriceTag.js", "../../fixtures/sourcemap/src/runtime.js", "../../fixtures/sourcemap/src/main.js"], + "sourcesContent": ["// A production-style component: once minified, nothing in the page says where it came from.\nexport function Badge({ text }) {\n const el = document.createElement(\"span\");\n el.className = \"badge\";\n el.textContent = text;\n return el;\n}\n\nexport function PriceTag({ amount, currency = \"USD\" }) {\n const el = document.createElement(\"button\");\n el.id = \"price\";\n el.className = \"price-tag\";\n el.textContent = new Intl.NumberFormat(\"en-US\", { style: \"currency\", currency }).format(amount);\n return el;\n}\n", "// Mirrors what React leaves on a DOM node: __reactFiber$ pointing at the host fiber, whose\n// .return is the component's fiber with .type = the component function. No _debugSource: that is\n// exactly what a production build strips.\nexport function mount(root, Component, props) {\n const el = Component(props);\n el[\"__reactFiber$pp\" + Math.random().toString(36).slice(2)] = { type: el.tagName.toLowerCase(), return: { type: Component, return: null } };\n root.append(el);\n}\n", "import { Badge, PriceTag } from \"./components/PriceTag.js\";\nimport { mount } from \"./runtime.js\";\n\nconst root = document.getElementById(\"root\");\nmount(root, Badge, { text: \"new\" });\nmount(root, PriceTag, { amount: 42 });\n"], + "mappings": "MACO,SAASA,EAAM,CAAE,KAAAC,CAAK,EAAG,CAC9B,IAAMC,EAAK,SAAS,cAAc,MAAM,EACxC,OAAAA,EAAG,UAAY,QACfA,EAAG,YAAcD,EACVC,CACT,CAEO,SAASC,EAAS,CAAE,OAAAC,EAAQ,SAAAC,EAAW,KAAM,EAAG,CACrD,IAAMH,EAAK,SAAS,cAAc,QAAQ,EAC1C,OAAAA,EAAG,GAAK,QACRA,EAAG,UAAY,YACfA,EAAG,YAAc,IAAI,KAAK,aAAa,QAAS,CAAE,MAAO,WAAY,SAAAG,CAAS,CAAC,EAAE,OAAOD,CAAM,EACvFF,CACT,CCXO,SAASI,EAAMC,EAAMC,EAAWC,EAAO,CAC5C,IAAMC,EAAKF,EAAUC,CAAK,EAC1BC,EAAG,kBAAoB,KAAK,OAAO,EAAE,SAAS,EAAE,EAAE,MAAM,CAAC,CAAC,EAAI,CAAE,KAAMA,EAAG,QAAQ,YAAY,EAAG,OAAQ,CAAE,KAAMF,EAAW,OAAQ,IAAK,CAAE,EAC1ID,EAAK,OAAOG,CAAE,CAChB,CCJA,IAAMC,EAAO,SAAS,eAAe,MAAM,EAC3CC,EAAMD,EAAME,EAAO,CAAE,KAAM,KAAM,CAAC,EAClCD,EAAMD,EAAMG,EAAU,CAAE,OAAQ,EAAG,CAAC", + "names": ["Badge", "text", "el", "PriceTag", "amount", "currency", "mount", "root", "Component", "props", "el", "root", "mount", "Badge", "PriceTag"] +} diff --git a/test/site/sourcemap/index.html b/test/site/sourcemap/index.html new file mode 100644 index 0000000..c851d96 --- /dev/null +++ b/test/site/sourcemap/index.html @@ -0,0 +1,5 @@ + + +Production build +
+ diff --git a/test/ui-sourcemap.test.mjs b/test/ui-sourcemap.test.mjs new file mode 100644 index 0000000..91b644f --- /dev/null +++ b/test/ui-sourcemap.test.mjs @@ -0,0 +1,139 @@ +// Unit tests for extension/sourcemap.js: the VLQ decoder, the mapping lookup and the fetch policy, +// against a real esbuild map (test/site/sourcemap, regenerated by test/fixtures/sourcemap/build.sh). +// Run: node --test ui-sourcemap.test.mjs. Loads the file the way the worker does: a classic script. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import vm from "node:vm"; +import { createRequire } from "node:module"; +import { fileURLToPath } from "node:url"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const sandbox = { URL, AbortController, TextDecoder, Blob, setTimeout, clearTimeout, atob, Uint8Array, Int8Array, Promise, Error, JSON, Date, Map, Set }; +vm.runInNewContext(fs.readFileSync(path.resolve(here, "../extension/sourcemap.js"), "utf8"), sandbox); +const SM = sandbox.pinpointSourceMap; +const BUNDLE = fs.readFileSync(path.join(here, "site/sourcemap/app.min.js"), "utf8"); +const MAP = fs.readFileSync(path.join(here, "site/sourcemap/app.min.js.map"), "utf8"); +const plain = (v) => JSON.parse(JSON.stringify(v)); // out of the vm's realm, for deepEqual + +test("VLQ: single values, signs, continuation bits, line breaks", () => { + // A=0 C=+1 D=-1; g/h set the continuation bit, so gB=+16 hB=-16 2H=+123 +/D=+2047 //D=-2047 + assert.deepEqual(plain(SM.decodeMappings("AAAA")), [[[0, 0, 0, 0]]]); + assert.deepEqual(plain(SM.decodeMappings("CACA,DADA")), [[[0, 0, 0, 0], [1, 0, 1, 0]]]); + assert.deepEqual(plain(SM.decodeMappings("gBAAA")), [[[16, 0, 0, 0]]]); + assert.deepEqual(plain(SM.decodeMappings("hBAAA")), [[[-16, 0, 0, 0]]]); + assert.deepEqual(plain(SM.decodeMappings("2HAAA")), [[[123, 0, 0, 0]]]); + assert.deepEqual(plain(SM.decodeMappings("+/D")), [[[2047]]]); + assert.deepEqual(plain(SM.decodeMappings("//D")), [[[-2047]]]); + // genCol resets per line; source, line, column and name accumulate across lines + assert.deepEqual(plain(SM.decodeMappings("ECAEC;;EAAE")), [[[2, 1, 0, 2, 1]], [], [[2, 1, 0, 4]]]); + assert.throws(() => SM.decodeMappings("AA"), /bad segment/); + assert.throws(() => SM.decodeMappings("A!AA"), /bad base64/); +}); + +test("decoder agrees with source-map-js on every segment of a real esbuild map", (t) => { + let SourceMapConsumer; + try { ({ SourceMapConsumer } = createRequire(import.meta.url)("source-map-js")); } catch { return t.skip("source-map-js not installed"); } + const theirs = []; + new SourceMapConsumer(JSON.parse(MAP)).eachMapping((m) => theirs.push([m.generatedLine - 1, m.generatedColumn, m.originalLine - 1, m.originalColumn, m.name || null])); + const map = JSON.parse(MAP), mine = []; + SM.decodeMappings(map.mappings).forEach((segs, line) => segs.forEach((s) => mine.push([line, s[0], s[2], s[3], s.length === 5 ? map.names[s[4]] : null]))); + assert.ok(mine.length > 50); + assert.deepEqual(mine, theirs); +}); + +test("a minified component's position maps back to its original file, line and name", () => { + const map = JSON.parse(MAP); + // Column 8: esbuild maps `function` itself, after the original's `export `. + // What inspector.js sends: the head of the function's own source text. + const fn = BUNDLE.match(/function c\(\{amount[^]{0,100}/)[0]; + const at = SM.locate(BUNDLE, fn.slice(0, 120)); + assert.deepEqual(plain(at), { line: 0, column: BUNDLE.indexOf("function c(") }); + assert.deepEqual(plain(SM.originalPosition(map, at.line, at.column)), { file: "src/components/PriceTag.js", line: 9, column: 8, name: "PriceTag" }); + const badge = SM.locate(BUNDLE, "function a({text:n}){let t=document"); + assert.deepEqual(plain(SM.originalPosition(map, badge.line, badge.column)), { file: "src/components/PriceTag.js", line: 2, column: 8, name: "Badge" }); + // Before the first segment of a line, and past the last line: nothing. + assert.equal(SM.originalPosition({ ...map, _lines: undefined, mappings: "EAAA" }, 0, 1), null); + assert.equal(SM.originalPosition(map, 5, 0), null); +}); + +test("locate: 0-based line and column; absent or ambiguous snippets find nothing", () => { + assert.deepEqual(plain(SM.locate("a\nbc\n function x(){}", "function x(){}")), { line: 2, column: 2 }); + assert.deepEqual(plain(SM.locate("function x(){}", "function x(){}")), { line: 0, column: 0 }); + assert.equal(SM.locate("abc", "zzz"), null); + assert.equal(SM.locate("f(){} f(){}", "f(){}"), null); +}); + +test("sources are cleaned into project-relative paths", () => { + assert.equal(SM.cleanSource("webpack://my-app/./src/App.jsx"), "src/App.jsx"); + assert.equal(SM.cleanSource("webpack:///src/components/Nav.tsx?abc"), "src/components/Nav.tsx"); + assert.equal(SM.cleanSource("/@fs/Users/me/proj/src/main.ts"), "src/main.ts"); + assert.equal(SM.cleanSource("../../src/pages/Home.vue"), "src/pages/Home.vue"); + assert.equal(SM.cleanSource("Card.tsx", "/project/app/ui"), "app/ui/Card.tsx"); + assert.equal(SM.cleanSource("file:///home/me/x/lib/util.js"), "lib/util.js"); +}); + +test("indexed maps: the section covering the position is used; url sections are skipped", () => { + const inner = JSON.parse(MAP); + const indexed = { version: 3, sections: [ + { offset: { line: 0, column: 0 }, map: { version: 3, sources: ["src/other.js"], names: [], mappings: "AAAA" } }, + { offset: { line: 3, column: 10 }, map: inner }, + { offset: { line: 9, column: 0 }, url: "elsewhere.map" }, + ] }; + const col = BUNDLE.indexOf("function c("); + assert.equal(SM.originalPosition(indexed, 3, 10 + col).file, "src/components/PriceTag.js"); + assert.equal(SM.originalPosition(indexed, 3, 10 + col).line, 9); + assert.equal(SM.originalPosition(indexed, 1, 0), null); // section 1 has no line 1 + assert.equal(SM.originalPosition(indexed, 0, 3).file, "src/other.js"); + assert.equal(SM.originalPosition(indexed, 9, 0), null); +}); + +test("sourceMappingURL: the last comment wins; none means null", () => { + assert.equal(SM.mapComment("x\n//# sourceMappingURL=a.map\n//# sourceMappingURL=b.map\n"), "b.map"); + assert.equal(SM.mapComment("x /*# sourceMappingURL=c.css.map */"), "c.css.map"); + assert.equal(SM.mapComment("var s = 1;"), null); +}); + +test("fetch policy: same origin, or loopback from a loopback page; never a third party", () => { + assert.equal(SM.allowed("http://localhost:3000/a.js", "http://localhost:3000/"), true); + assert.equal(SM.allowed("http://127.0.0.1:5173/a.js", "http://localhost:3000/"), true); + assert.equal(SM.allowed("https://cdn.example.com/a.js", "http://localhost:3000/"), false); + assert.equal(SM.allowed("http://myapp.test/a.js", "http://myapp.test/x"), true); + assert.equal(SM.allowed("http://localhost:3000/a.js", "http://myapp.test/"), false); + assert.equal(SM.allowed("data:text/javascript,1", "http://localhost/"), false); + assert.equal(SM.allowed("file:///a.js", "file:///index.html"), false); +}); + +const PAGE = "http://localhost:4000/"; +const files = (extra = {}) => ({ "http://localhost:4000/app.min.js": BUNDLE, "http://localhost:4000/app.min.js.map": MAP, ...extra }); +const getter = (table, seen) => async (url) => { seen.push(url); if (!(url in table)) throw new Error("404"); return { text: table[url], header: null }; }; +const SNIP = BUNDLE.slice(BUNDLE.indexOf("function c("), BUNDLE.indexOf("function c(") + 120); + +test("resolve: finds the script, follows its map, caches per URL", async () => { + const seen = []; + const get = getter(files(), seen); + const scripts = ["http://localhost:4000/app.min.js", "https://cdn.example.com/lib.js"]; + const r = await SM.resolve({ snippet: SNIP, scripts }, PAGE, { get }); + assert.deepEqual(plain(r), { file: "src/components/PriceTag.js", line: 9, column: 8, name: "PriceTag" }); + assert.deepEqual(seen, ["http://localhost:4000/app.min.js", "http://localhost:4000/app.min.js.map"], "the third-party script is never fetched"); + await SM.resolve({ snippet: SNIP, scripts }, PAGE, { get }); + assert.equal(seen.length, 2, "both the script and its map come from the cache the second time"); +}); + +test("resolve: a data: URL map, a cross-origin map, a missing map, a timeout", async () => { + const b64 = Buffer.from(MAP).toString("base64"); + const seen = []; + const inline = BUNDLE.replace(/sourceMappingURL=.*/, `sourceMappingURL=data:application/json;charset=utf-8;base64,${b64}`); + const r = await SM.resolve({ snippet: SNIP, scripts: ["http://localhost:4000/inline.js"] }, PAGE, { get: getter({ "http://localhost:4000/inline.js": inline }, seen) }); + assert.equal(r.file, "src/components/PriceTag.js"); + const cross = BUNDLE.replace(/sourceMappingURL=.*/, "sourceMappingURL=https://maps.example.com/app.min.js.map"); + assert.equal(await SM.resolve({ snippet: SNIP, scripts: ["http://localhost:4000/cross.js"] }, PAGE, { get: getter({ "http://localhost:4000/cross.js": cross }, seen) }), null); + const none = BUNDLE.replace(/\/\/# sourceMappingURL=.*/, ""); + assert.equal(await SM.resolve({ snippet: SNIP, scripts: ["http://localhost:4000/none.js"] }, PAGE, { get: getter({ "http://localhost:4000/none.js": none }, seen) }), null); + assert.ok(!seen.some((u) => /example\.com/.test(u))); + const t0 = Date.now(); + assert.equal(await SM.resolve({ snippet: SNIP, scripts: ["http://localhost:4000/slow.js"] }, PAGE, { timeout: 100, get: () => new Promise(() => {}) }), null); + assert.ok(Date.now() - t0 < 1000); + assert.equal(await SM.resolve({ snippet: "short", scripts: [] }, PAGE), null); +}); diff --git a/tools/make-firefox-zip.sh b/tools/make-firefox-zip.sh index 3c63052..dbc69dd 100755 --- a/tools/make-firefox-zip.sh +++ b/tools/make-firefox-zip.sh @@ -20,7 +20,7 @@ node -e ' const f = process.argv[1], m = require(f); delete m.key; m.name = "Pinpoint \u2014 annotate UI for your coding agent"; -m.background = { scripts: ["redact.js", "background.js"] }; // redact.js first: background.js needs it +m.background = { scripts: ["redact.js", "sourcemap.js", "background.js"] }; // helpers first: background.js needs them m.browser_specific_settings = { gecko: { id: "pinpoint@gowtham012.github.io", strict_min_version: "140.0",