Section
Hello world, visit docs.
+Big inline art: tiny-jpeg.
+ + +- Alpha
- Beta diff --git a/automation/tests/test_prompt.py b/automation/tests/test_prompt.py index 376e64c..117cef9 100644 --- a/automation/tests/test_prompt.py +++ b/automation/tests/test_prompt.py @@ -14,7 +14,62 @@ def test_shared_js_files_exist_and_match_package(): assert (CORE / "prompt.js").is_file() # package-side copies/symlinks must resolve to the same source assert "function extractMarkdown" in _read_js("dom.js") + assert "function truncateDataUri" in _read_js("dom.js") + assert "function inventoryInterestingControls" in _read_js("dom.js") assert "function buildAIPrompt" in _read_js("prompt.js") + assert "function truncateDataUri" in _read_js("prompt.js") + + +def test_build_ai_prompt_truncates_data_uris_in_network(page): + page.goto("about:blank") + meta = { + "url": "https://example.test/page", + "title": "T", + "ts": 1_700_000_000_000, + "selector": "body", + } + long_data = "data:image/jpeg;base64," + ("A" * 500) + store = { + "console": [], + "errors": [], + "network": [{ + "type": "fetch", "method": "GET", "url": long_data, + "status": 200, "ts": 3, "duration": 12, "error": None, + }], + } + out = page.evaluate(_build_prompt_script(meta, "# Hello", store)) + assert "bytes truncated" in out + assert ("A" * 100) not in out + + +def test_build_ai_prompt_includes_interesting_controls(page): + page.goto("about:blank") + meta = {"url": "https://example.test/page", "title": "T", "ts": 1, "selector": "body"} + controls = [ + { + "tag": "button", + "role": "", + "ariaLabel": "Document options", + "text": "…", + "visible": True, + "selector": "#vis", + }, + { + "tag": "button", + "role": "", + "ariaLabel": "Document options", + "text": "…", + "visible": False, + "selector": "#hid", + }, + ] + out = page.evaluate( + _build_prompt_script(meta, "# Hello", {"console": [], "errors": [], "network": []}, None, controls) + ) + assert "## Interesting Controls" in out + assert "[visible]" in out + assert "### Hidden" in out + assert "Document options" in out def test_build_ai_prompt_script_formats_errors_and_failures(page): diff --git a/automation/tests/test_session.py b/automation/tests/test_session.py index f913881..f63c809 100644 --- a/automation/tests/test_session.py +++ b/automation/tests/test_session.py @@ -31,6 +31,8 @@ def test_extract_markdown_body_and_selector(page, fixture_url): assert "**world**" in full["markdown"] assert "[docs](/docs)" in full["markdown"] assert "- Alpha" in full["markdown"] + assert "bytes truncated" in full["markdown"] + assert "4AAQSkZJRgABAQAAAQABAAD" not in full["markdown"] scoped = session.extract_markdown("#main-content") assert scoped["selector"] == "#main-content" @@ -64,10 +66,23 @@ def test_build_ai_prompt_has_sections(page, fixture_url): assert prompt.startswith("# Page Context") assert "## Page Content" in prompt assert "Hello **world**" in prompt + assert "## Interesting Controls" in prompt + assert "[visible]" in prompt + assert "Document options" in prompt assert "JavaScript Errors" in prompt or "Console Errors" in prompt assert "_Extracted by Context Extractor_" in prompt +def test_inventory_controls_visible_and_hidden(page, fixture_url): + session = ExtractorSession(page) + page.goto(fixture_url, wait_until="domcontentloaded") + controls = session.inventory_controls("#main-content") + docs = [c for c in controls if c.get("ariaLabel") == "Document options"] + assert len(docs) == 2 + assert any(c.get("visible") for c in docs) + assert any(not c.get("visible") for c in docs) + + def test_clear_store(page, fixture_url): session = ExtractorSession(page) page.goto(fixture_url, wait_until="domcontentloaded") diff --git a/extension/core/dom.js b/extension/core/dom.js index ef906d0..ea69c08 100644 --- a/extension/core/dom.js +++ b/extension/core/dom.js @@ -62,6 +62,24 @@ function getSelector(el) { // --------------------------------------------------------------------------- const NEVER_RENDERED_TAGS = new Set(["script", "style", "noscript", "template", "svg", "iframe", "canvas"]); +/** Max length before a data: URL is collapsed in dumps (keeps prompts usable). */ +const DATA_URI_SOFT_MAX = 120; + +/** + * Collapse long data: URIs so markdown / network dumps don't blow up (e.g. Outline + * inlining base64 images). Non-data URLs are returned unchanged. + * Keep in sync with truncateDataUri in core/prompt.js. + */ +function truncateDataUri(url, softMax) { + if (typeof url !== "string" || !url.startsWith("data:")) return url || ""; + const limit = softMax || DATA_URI_SOFT_MAX; + if (url.length <= limit) return url; + const comma = url.indexOf(","); + const header = comma >= 0 ? url.slice(0, Math.min(comma, 64)) : url.slice(0, 64); + const payloadLen = comma >= 0 ? url.length - comma - 1 : url.length; + return header + ",…[" + payloadLen + " bytes truncated]"; +} + function isHidden(el) { if (!el || el.nodeType !== 1) return false; if (el.hidden) return true; @@ -95,7 +113,7 @@ function extractMarkdown(node) { const kids = walkInline(el); switch (tag) { case "a": { - const href = el.getAttribute("href") || ""; + const href = truncateDataUri(el.getAttribute("href") || ""); return "[" + kids + "](" + href + ")"; } case "strong": @@ -184,3 +202,47 @@ function extractMarkdown(node) { md = md.replace(/\n{3,}/g, "\n\n"); return md.trim(); } + +// --------------------------------------------------------------------------- +// Interesting controls inventory (agent / SPA debugging) +// --------------------------------------------------------------------------- +const CONTROLS_CAP = 80; +const CONTROL_SELECTOR = + 'button, [role="button"], [role="menuitem"], [role="option"], [aria-label]'; + +/** + * List interactive / labeled controls under root with visibility flags. + * Helps agents find hidden Radix twins ("Document options") without dumping + * the whole DOM. + */ +function inventoryInterestingControls(root, cap) { + const base = root && root.nodeType === 1 ? root : document.body; + if (!base || !base.querySelectorAll) return []; + const limit = cap || CONTROLS_CAP; + const nodes = base.querySelectorAll(CONTROL_SELECTOR); + const seen = new Set(); + const out = []; + for (let i = 0; i < nodes.length && out.length < limit; i++) { + const el = nodes[i]; + if (seen.has(el)) continue; + seen.add(el); + const tag = el.tagName ? el.tagName.toLowerCase() : ""; + const role = (el.getAttribute && el.getAttribute("role")) || ""; + const ariaLabel = (el.getAttribute && el.getAttribute("aria-label")) || ""; + let text = ""; + try { + text = (el.textContent || "").replace(/\s+/g, " ").trim().slice(0, 80); + } catch (_) { + text = ""; + } + out.push({ + tag: tag, + role: role, + ariaLabel: ariaLabel, + text: text, + visible: !isHidden(el), + selector: getSelector(el), + }); + } + return out; +} diff --git a/extension/core/prompt.js b/extension/core/prompt.js index 37b5d08..2d318b0 100644 --- a/extension/core/prompt.js +++ b/extension/core/prompt.js @@ -16,8 +16,33 @@ // Pass a tighter selector (or a larger maxChars) instead of relying on this // cap for real content; it exists as a safety net, not a summarizer. const DEFAULT_MAX_MARKDOWN_CHARS = 20000; +const DATA_URI_SOFT_MAX = 120; -function buildAIPrompt(meta, markdown, store, maxChars) { +/** + * Collapse long data: URIs in network/prompt lines. + * Keep in sync with truncateDataUri in core/dom.js. + */ +function truncateDataUri(url, softMax) { + if (typeof url !== "string" || !url.startsWith("data:")) return url || ""; + const limit = softMax || DATA_URI_SOFT_MAX; + if (url.length <= limit) return url; + const comma = url.indexOf(","); + const header = comma >= 0 ? url.slice(0, Math.min(comma, 64)) : url.slice(0, 64); + const payloadLen = comma >= 0 ? url.length - comma - 1 : url.length; + return header + ",…[" + payloadLen + " bytes truncated]"; +} + +function formatControlLine(c) { + const bits = []; + if (c.tag) bits.push(c.tag); + if (c.role) bits.push("role=" + c.role); + if (c.ariaLabel) bits.push('aria-label="' + c.ariaLabel + '"'); + if (c.text) bits.push('"' + c.text + '"'); + if (c.selector) bits.push("`" + c.selector + "`"); + return (c.visible ? "[visible] " : "[hidden] ") + bits.join(" "); +} + +function buildAIPrompt(meta, markdown, store, maxChars, controls) { const limit = maxChars || DEFAULT_MAX_MARKDOWN_CHARS; const parts = []; @@ -42,6 +67,28 @@ function buildAIPrompt(meta, markdown, store, maxChars) { parts.push(""); } + const controlList = Array.isArray(controls) ? controls : []; + if (controlList.length) { + const visible = controlList.filter((c) => c && c.visible); + const hiddenCount = controlList.length - visible.length; + parts.push("## Interesting Controls"); + parts.push(""); + parts.push( + `_Showing ${visible.length} visible` + + (hiddenCount ? `, ${hiddenCount} hidden` : "") + + ` (of ${controlList.length} inventoried)._`, + ); + parts.push(""); + visible.forEach((c) => parts.push("- " + formatControlLine(c))); + if (hiddenCount) { + parts.push(""); + parts.push("### Hidden"); + parts.push(""); + controlList.filter((c) => c && !c.visible).forEach((c) => parts.push("- " + formatControlLine(c))); + } + parts.push(""); + } + const errors = (store && store.errors) || []; if (errors.length) { parts.push("## JavaScript Errors"); @@ -80,14 +127,16 @@ function buildAIPrompt(meta, markdown, store, maxChars) { parts.push(""); failed.forEach((n) => { const status = n.error ? "ERR" : n.status; - parts.push(`- ${n.method || "GET"} ${status} ${n.url}${n.duration ? " (" + n.duration + "ms)" : ""}${n.error ? " — " + n.error : ""}`); + const url = truncateDataUri(n.url || ""); + parts.push(`- ${n.method || "GET"} ${status} ${url}${n.duration ? " (" + n.duration + "ms)" : ""}${n.error ? " — " + n.error : ""}`); }); parts.push(""); } else if (network.length) { parts.push("## Recent Requests"); parts.push(""); network.slice(-15).forEach((n) => { - parts.push(`- ${n.method || "GET"} ${n.status || "—"} ${n.url}${n.duration ? " (" + n.duration + "ms)" : ""}`); + const url = truncateDataUri(n.url || ""); + parts.push(`- ${n.method || "GET"} ${n.status || "—"} ${url}${n.duration ? " (" + n.duration + "ms)" : ""}`); }); parts.push(""); } diff --git a/extension/popup.js b/extension/popup.js index 62ae41c..6451799 100644 --- a/extension/popup.js +++ b/extension/popup.js @@ -412,7 +412,7 @@ function wire() { out.push(""); state.store.network.forEach((n) => { const status = n.error ? "ERR" : (n.status || "—"); - out.push(`- ${n.method || "GET"} ${status} ${n.url}${n.duration ? " (" + n.duration + "ms)" : ""}${n.error ? " — " + n.error : ""}`); + out.push(`- ${n.method || "GET"} ${status} ${truncateDataUri(n.url || "")}${n.duration ? " (" + n.duration + "ms)" : ""}${n.error ? " — " + n.error : ""}`); }); out.push(""); out.push("---");