Skip to content

Commit c26d6d6

Browse files
committed
Refine background screenshot compatibility
1 parent 7e23f5a commit c26d6d6

5 files changed

Lines changed: 89 additions & 9 deletions

File tree

src/chrome/src/agent/agent.js

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -793,7 +793,13 @@ export class Agent extends LoopDetector {
793793
return this._retryBlankScreenshotCapture(
794794
await captureOnce(),
795795
captureOnce,
796-
{ probe },
796+
{
797+
probe,
798+
// A full-page image is expected to reflect the whole scroll extent.
799+
// Document length alone therefore cannot distinguish a legitimate
800+
// uniform page from a blank background compositor frame.
801+
includeDocumentLengthSignal: false,
802+
},
797803
);
798804
}
799805

@@ -5023,7 +5029,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d
50235029
}
50245030
}
50255031

5026-
_pageSignalsContentBehindBlank(probe) {
5032+
_pageSignalsContentBehindBlank(probe, { includeDocumentLengthSignal = true } = {}) {
50275033
if (!probe) return true;
50285034
const textChars = Number(probe.documentTextChars || 0);
50295035
const visibleTextChars = Number(probe.visibleTextChars || 0);
@@ -5037,15 +5043,26 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d
50375043
visibleTextChars > 20 ||
50385044
imageCount > 0 ||
50395045
domNodes > 150 ||
5040-
(innerHeight > 0 && scrollHeight > innerHeight + 200)
5046+
(
5047+
includeDocumentLengthSignal
5048+
&& innerHeight > 0
5049+
&& scrollHeight > innerHeight + 200
5050+
)
50415051
);
50425052
}
50435053

5044-
async _retryBlankScreenshotCapture(firstShot, captureOnce, { probe = null } = {}) {
5054+
async _retryBlankScreenshotCapture(
5055+
firstShot,
5056+
captureOnce,
5057+
{ probe = null, includeDocumentLengthSignal = true } = {},
5058+
) {
50455059
if (!firstShot?.dataUrl || typeof captureOnce !== 'function') return firstShot;
50465060
let shot = firstShot;
50475061
let blankness = await this._analyzeScreenshotBlankness(shot.dataUrl);
5048-
if (!blankness?.blank || !this._pageSignalsContentBehindBlank(probe)) return shot;
5062+
if (
5063+
!blankness?.blank
5064+
|| !this._pageSignalsContentBehindBlank(probe, { includeDocumentLengthSignal })
5065+
) return shot;
50495066

50505067
const meta = {
50515068
detected: true,

src/firefox/ARCHITECTURE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
Firefox uses Manifest V2 (background page, not service worker) and has **no access to the Chrome DevTools Protocol (CDP)**. Starting with v3.6.x, the Firefox build has been brought to functional parity with Chrome for the accessibility-tree (AX) subsystem — the same tree builder, the same four AX tools (`get_accessibility_tree`, `click_ax`, `type_ax`, `set_field`), and the same ref_id registry. What Firefox still lacks:
88

99
- **No trusted events** — clicks and key presses are synthetic (`el.click()`, `new KeyboardEvent()`), and some sites reject `event.isTrusted === false`. All AX-tool click/type paths use synthetic dispatch in Firefox; the CDP-backed trusted-event path in Chrome has no Firefox equivalent.
10-
- **No pixel-perfect / full-page screenshots** — uses `browser.tabs.captureTab()` instead of CDP `Page.captureScreenshot`; it can capture the run tab while that tab is inactive.
10+
- **No pixel-perfect / full-page screenshots** — uses `browser.tabs.captureTab()` instead of CDP `Page.captureScreenshot`; it can capture the run tab while that tab is inactive. Firefox has exposed `tabs.captureTab()` since Firefox 59, before WebBrain's Firefox 109 minimum, and the manifest declares the required `<all_urls>` permission.
1111
- **No shadow DOM piercing** — content script can read open shadow roots via `element.shadowRoot`, but cannot pierce closed roots.
1212
- **No offscreen document** — no HTTP fetch proxy for localhost LLM servers with Private Network Access / CORS issues. User must ensure their local LLM server sends permissive CORS headers.
1313
- **No duplicate-submit guard** — the per-tab submit-throttle (Chrome v3.6.5+) is still Chrome-only. Firefox's agent loop does not block rapid duplicate Create/Submit clicks. `blockedDone` and the ambiguous-click candidate payload were ported to Firefox in v4.0.1 (see "Overlay defenses" below).

src/firefox/src/agent/agent.js

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4864,9 +4864,11 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d
48644864

48654865
/**
48664866
* Capture a viewport screenshot for the run's tab without activating it.
4867-
* Firefox supports `scale: 1` on captureTab to force a CSS-pixel-aligned
4868-
* image (otherwise it captures at devicePixelRatio, causing the same
4869-
* coordinate-mismatch loop Chrome had pre-1.5.1). Returns
4867+
* `tabs.captureTab()` has been available since Firefox 59; WebBrain's
4868+
* minimum is Firefox 109 and its `<all_urls>` permission authorizes capture.
4869+
* Firefox supports `scale: 1` here to force a CSS-pixel-aligned image
4870+
* (otherwise it captures at devicePixelRatio, causing the same coordinate-
4871+
* mismatch loop Chrome had pre-1.5.1). Returns
48704872
* { dataUrl, width, height } in (possibly budget-resized) pixels, or null.
48714873
* `opts` accepted for Chrome call-site parity; capture is always CSS-locked.
48724874
*/

src/firefox/src/run-capture.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@ export async function captureAndSaveRunScreenshot(api, tabId, filename) {
5454
if (!tab) {
5555
throw new Error('The run tab is no longer available.');
5656
}
57+
// Firefox has supported arbitrary-tab capture since Firefox 59. WebBrain's
58+
// declared minimum is 109 and the manifest includes the required <all_urls>.
5759
const dataUrl = await api.tabs.captureTab(tabId, { format: 'png' });
5860
filename = await filenameInConfiguredDownloadDirectory(api, filename);
5961
const downloadId = await api.downloads.download({

test/run.js

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25572,6 +25572,65 @@ test('Chrome full-page screenshot paths reject blank background captures after r
2557225572
}
2557325573
});
2557425574

25575+
test('Chrome full-page blank guard ignores document length as the lone content signal', async () => {
25576+
const originalChrome = globalThis.chrome;
25577+
const originalAttach = cdpClientCh.attach;
25578+
const originalCapture = cdpClientCh.captureFullPageScreenshot;
25579+
let captureCalls = 0;
25580+
try {
25581+
globalThis.chrome = {
25582+
...(originalChrome || {}),
25583+
tabs: {
25584+
...(originalChrome?.tabs || {}),
25585+
get: async (tabId) => ({
25586+
id: tabId,
25587+
active: false,
25588+
url: 'https://example.test/uniform-long-page',
25589+
}),
25590+
},
25591+
};
25592+
cdpClientCh.attach = async () => ({ attached: true });
25593+
cdpClientCh.captureFullPageScreenshot = async () => {
25594+
captureCalls += 1;
25595+
return { data: 'dW5pZm9ybQ==' };
25596+
};
25597+
25598+
const agent = new AgentCh({});
25599+
agent._preparePageForCapture = async () => {};
25600+
agent._withIndicatorsHidden = async (_tabId, capture) => capture();
25601+
agent._captureViewportProbe = async () => ({
25602+
readyState: 'complete',
25603+
documentTextChars: 0,
25604+
visibleTextChars: 0,
25605+
domNodes: 20,
25606+
imageCount: 0,
25607+
scrollHeight: 5000,
25608+
innerHeight: 800,
25609+
});
25610+
agent._analyzeScreenshotBlankness = async () => ({
25611+
blank: true,
25612+
reason: 'near-all-white frame',
25613+
meanLuma: 255,
25614+
lumaStdDev: 0,
25615+
whiteRatio: 1,
25616+
blackRatio: 0,
25617+
});
25618+
25619+
const result = await agent.captureFullPageScreenshotForUser(42);
25620+
assert.deepEqual(result, {
25621+
ok: true,
25622+
dataUrl: 'data:image/png;base64,dW5pZm9ybQ==',
25623+
warning: null,
25624+
});
25625+
assert.equal(captureCalls, 1, 'a uniform long page should not retry from scroll length alone');
25626+
} finally {
25627+
cdpClientCh.attach = originalAttach;
25628+
cdpClientCh.captureFullPageScreenshot = originalCapture;
25629+
if (originalChrome === undefined) delete globalThis.chrome;
25630+
else globalThis.chrome = originalChrome;
25631+
}
25632+
});
25633+
2557525634
test('user full-page screenshots apply adapter capture policy without LLM adapter injection', async () => {
2557625635
const originalChrome = globalThis.chrome;
2557725636
const originalAttach = cdpClientCh.attach;

0 commit comments

Comments
 (0)