Skip to content
Merged
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
27 changes: 20 additions & 7 deletions agents/autowebcompat-repro/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -28,19 +28,32 @@ ENV PATH="/opt/venv/bin:$PATH"

FROM base AS agent

# The Firefox DevTools MCP server is an npm package launched via `npx`, so the
# agent image needs Node.js + npm (the python base ships neither). It also
# needs the shared libraries Firefox requires to run headless; the Firefox
# binary itself is downloaded at agent startup (a fresh Nightly per run) via
# mozdownload/mozinstall, not baked in here.
# The agent needs Node.js + npm to run the DevTools MCP servers (npm packages;
# the python base ships neither) and the shared libraries the browsers require
# to run headless. The browser binaries themselves are downloaded at agent
# startup (a fresh build per run): Firefox via mozdownload/mozinstall and Chrome
# for Testing via the Chrome-for-Testing JSON API (see browser.py).
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
# Node.js, to run the Firefox and Chrome DevTools MCP servers.
nodejs npm \
# Shared by both browsers.
ca-certificates \
libgtk-3-0 libdbus-glib-1-2 libx11-xcb1 libxtst6 libxt6 \
libasound2 libpci3 \
# Firefox runtime deps.
libgtk-3-0 libdbus-glib-1-2 libx11-xcb1 libxtst6 libxt6 libpci3 \
# Chrome runtime deps.
libnss3 libnspr4 libatk1.0-0t64 libatk-bridge2.0-0t64 libatspi2.0-0t64 \
libcups2t64 libdbus-1-3 libgbm1 libxcomposite1 libxdamage1 libxfixes3 \
libxrandr2 libxkbcommon0 libasound2t64 libpango-1.0-0 libcairo2 \
fonts-liberation \
&& rm -rf /var/lib/apt/lists/*

# Install the DevTools MCP servers from the pinned package.json/lockfile
COPY agents/autowebcompat-repro/package.json \
agents/autowebcompat-repro/package-lock.json \
/app/node/
RUN cd /app/node && npm ci --omit=dev

# hackbot.toml lives at the agent root (not inside the package), so copy it into
# the working dir; the runtime discovers it there (cwd) at startup.
COPY agents/autowebcompat-repro/hackbot.toml /app/hackbot.toml
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,9 @@
from hackbot_runtime.claude import Reporter
from pydantic import BaseModel

from .browser import FirefoxBrowsers
from .config import BUGZILLA_READ_TOOLS, DEVTOOLS_TOOLS
from .devtools_mcp import build_devtools_server
from .browser import ChromeBrowsers, FirefoxBrowsers
from .config import BUGZILLA_READ_TOOLS, CHROME_DEVTOOLS_TOOLS, DEVTOOLS_TOOLS
from .mcp_servers import build_chrome_devtools_server, build_firefox_devtools_server
from .result import (
RESULT_SERVER_NAME,
SUBMIT_RESULT_TOOL,
Expand Down Expand Up @@ -82,6 +82,7 @@ class AutowebcompatReproResult(BaseModel):
plan_result: TestPlanResult
reproductions: list[tuple[str, BugReproductionResult | ReproductionResult]]
chrome_mask_fixed: bool | None
chrome_reproduced: bool | None


@dataclass
Expand Down Expand Up @@ -317,6 +318,7 @@ def __init__(
input_data: AutoWebcompatInput,
bugzilla_mcp_server: McpServerConfig,
screenshot_dir: Path,
chrome_path: Path,
):
super().__init__(task_config, run_tracker)
self.input_data = input_data
Expand All @@ -325,7 +327,7 @@ def __init__(
)
self.add_mcp_server(
"firefox-devtools",
build_devtools_server(
build_firefox_devtools_server(
firefox_path=firefox_path,
headless=True,
enable_script=True,
Expand All @@ -334,7 +336,13 @@ def __init__(
),
DEVTOOLS_TOOLS,
)
if self.input_data.type == "bug_id" != None:

self.add_mcp_server(
"chrome-devtools",
build_chrome_devtools_server(chrome_path=chrome_path, headless=True),
CHROME_DEVTOOLS_TOOLS,
)
if self.input_data.type == "bug_id":
self.add_mcp_server("bugzilla", bugzilla_mcp_server, BUGZILLA_READ_TOOLS)

def subject(self) -> Any:
Expand All @@ -347,16 +355,30 @@ def system_prompt(self) -> str:
.format(
task_details=f"""
1. Identify the affected URL and the described broken behavior.
2. Baseline: Navigate to the URL with the Firefox DevTools MCP and
try to reproduce the described broken behaviour.
3. If the issue reproduces AND the breakage is visual in nature (incorrect
layout or rendering, not broken interaction), capture a screenshot showing
it: call `screenshot_page` with `saveTo` set to `{self.screenshot_path}`.
This writes the image to that file instead of returning it — do not capture
or paste the image data yourself. Then set `screenshot_path` in your result
to exactly `{self.screenshot_path}`. For non-visual issues, take no
screenshot and leave `screenshot_path` null.
4. Submit your findings via `submit_result` (see "Reporting your result").
2. Baseline: Navigate to the URL with the Firefox DevTools MCP
and try to reproduce the described broken behaviour.
- Reproduce against the actual reported site. If you cannot reach or
reproduce on that site — e.g. it is behind a login wall, blocked,
gated by a captcha, or down — do not substitute a reduced testcase,
minimal example, or other bug attachment as a stand-in reproduction.
Report `reproduced` as false with the appropriate `failure_reason`. A
testcase may inform your analysis, but reproducing on it does not count
as reproducing the reported site issue.
3. Cross-check in Chrome: run the same steps in Chrome using the Chrome DevTools
MCP and report `chrome_reproduced`.
- A genuine web-compat issue reproduces in Firefox but not in Chrome. If the
behavior is identical in both, your steps may be wrong or this may not be a
web-compat issue; refine the steps and re-check before concluding.
- If the reported broken behaviour reproduces in both browsers, it is not a
Firefox web-compat issue: set `failure_reason` to `non_compat`.
4. If the issue reproduces AND the breakage is visual in nature (incorrect
layout or rendering, not broken interaction), capture a screenshot in Firefox
showing it: call `screenshot_page` with `saveTo` set to
`{self.screenshot_path}`. This writes the image to that file instead of
returning it — do not capture or paste the image data yourself. Then set
`screenshot_path` in your result to exactly `{self.screenshot_path}`. For
non-visual issues, take no screenshot and leave `screenshot_path` null.
5. Submit your findings via `submit_result` (see "Reporting your result").
"""
)
)
Expand Down Expand Up @@ -391,7 +413,7 @@ def __init__(
self.steps = steps
self.add_mcp_server(
"firefox_devtools",
build_devtools_server(
build_firefox_devtools_server(
firefox_path=firefox_path,
headless=True,
enable_script=True,
Expand Down Expand Up @@ -437,7 +459,7 @@ def __init__(
self.steps = steps
self.add_mcp_server(
"firefox_devtools",
build_devtools_server(
build_firefox_devtools_server(
firefox_path=firefox_path,
headless=True,
enable_script=True,
Expand Down Expand Up @@ -491,6 +513,7 @@ class InitialReproduction:
steps: str
summary: str
screenshot_path: Path | None
chrome_reproduced: bool | None


class ReproductionResults:
Expand All @@ -507,6 +530,19 @@ def __init__(self, publish_file: PublishFile, plan_result: TestPlanResult):
def reproduced(self) -> bool:
return self.initial_repro is not None

@property
def chrome_reproduced(self) -> bool | None:
if self.initial_repro is not None:
return self.initial_repro.chrome_reproduced

chrome_reproductions = [
result.chrome_reproduced
for result in self.results.values()
if isinstance(result, BugReproductionResult)
and result.chrome_reproduced is not None
]
return any(chrome_reproductions) if chrome_reproductions else None

@property
def summary(self) -> str:
return self.initial_repro.summary if self.initial_repro is not None else ""
Expand Down Expand Up @@ -555,7 +591,11 @@ def set_result(
if self.initial_repro is not None:
raise ValueError("Got duplicate steps / summary")
self.initial_repro = InitialReproduction(
channel, result.steps, result.summary, result.screenshot_path
channel,
result.steps,
result.summary,
result.screenshot_path,
result.chrome_reproduced,
)
elif isinstance(result, ChromeMaskResult):
if self.chrome_mask_fixed is not None:
Expand All @@ -578,6 +618,7 @@ def into_result(self) -> AutowebcompatReproResult:
if isinstance(value, ReproductionResult)
],
chrome_mask_fixed=self.chrome_mask_fixed,
chrome_reproduced=self.chrome_reproduced,
)


Expand All @@ -594,6 +635,7 @@ async def run_autowebcompat_repro(
:class:`AgentError` if the agent ends in an error.
"""
firefox_browser = FirefoxBrowsers()
chrome_browser = ChromeBrowsers()

test_plan_task = TestPlan(default_config, tracker, input_data, bugzilla_mcp_server)
test_plan_result = await test_plan_task.run()
Expand Down Expand Up @@ -626,6 +668,7 @@ async def next_repro_task(
input_data,
bugzilla_mcp_server,
screenshots_dir,
chrome_browser.stable,
)
else:
task = StepsReproduction(
Expand All @@ -647,6 +690,19 @@ async def next_repro_task(
# Always try in nightly first
await next_repro_task(FirefoxChannel.nightly)

# If the reported behavior reproduced in both Firefox and Chrome it is not a
# Firefox web-compat issue: stop early.
if repro_results.reproduced and repro_results.chrome_reproduced:
result = repro_results.into_result()
Comment thread
jgraham marked this conversation as resolved.
if result.failure_reason != "non_compat":
logger.warning(
"Issue reproduced in both Firefox and Chrome but failure_reason "
"was %r, should have been non_compat",
result.failure_reason,
)
result.failure_reason = "non_compat"
return result

if not repro_results.reproduced and test_plan_result.affects_platforms == [
"android"
]:
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
import logging
import os
import platform
import stat
import sys
import tempfile
import zipfile
from pathlib import Path
from typing import Literal

import mozdownload
import mozinstall
import requests

logger = logging.getLogger("autowebcompat-repro")

Expand Down Expand Up @@ -72,3 +77,138 @@ def esr(self) -> Path:
if self._esr is None:
self._esr = install_firefox(channel="esr")
return self._esr


def chrome_platform() -> str:
"""Chrome for Testing platform string for the current host."""
system = platform.system()
machine = platform.machine().lower()
if system == "Linux":
if machine not in {"x86_64", "amd64"}:
raise RuntimeError(
"Chrome for Testing has no linux build for "
f"{platform.machine()}; only x86_64/amd64 is supported. Run the "
"agent image as linux/amd64, e.g. DOCKER_DEFAULT_PLATFORM=linux/amd64."
)
return "linux64"
if system == "Darwin":
return "mac-arm64" if machine in {"arm64", "aarch64"} else "mac-x64"
if system == "Windows":
return "win64" if machine in {"x86_64", "amd64"} else "win32"
raise RuntimeError(f"Unsupported platform for Chrome for Testing: {system}")


def resolve_chrome_download_url(channel: str, cft_platform: str) -> str:
"""Look up the Chrome for Testing download URL for a channel + platform."""
versions_url = (
"https://googlechromelabs.github.io/chrome-for-testing/"
"last-known-good-versions-with-downloads.json"
)
response = requests.get(versions_url, timeout=120)
response.raise_for_status()
data = response.json()

entry = data["channels"][channel.capitalize()]
logger.info("Chrome for Testing %s: version %s", channel, entry["version"])

for download in entry["downloads"]["chrome"]:
if download["platform"] == cft_platform:
return download["url"]

raise RuntimeError(
f"no Chrome for Testing '{cft_platform}' download in {channel} channel"
)


def chrome_binary_path(install_dir: Path, cft_platform: str) -> Path:
"""Path to the Chrome for Testing binary within the unpacked archive.

Chrome for Testing uses a different executable name/layout per platform:
on macOS it is inside an `.app` bundle, on Windows it is `chrome.exe`,
and on Linux it is a `chrome`.
"""
package = install_dir / f"chrome-{cft_platform}"
if cft_platform.startswith("mac"):
return (
package
/ "Google Chrome for Testing.app"
/ "Contents"
/ "MacOS"
/ "Google Chrome for Testing"
)
if cft_platform.startswith("win"):
return package / "chrome.exe"
return package / "chrome"


def unzip(archive: Path, dest: Path) -> None:
"""Extract a zip, preserving unix permission bits and recreating symlinks.

This keeps the Chrome binary executable and, on macOS, keeps the ``.app``
bundle's internal symlinks intact.

Adapted from wpt's tools/wpt/utils.py::unzip.
"""
with zipfile.ZipFile(archive) as zip_data:
for info in zip_data.infolist():
# external_attr's two high bytes carry the unix st_mode, but only
# when the archive was created on a unix system (create_system == 3).
# A DOS/Windows-created archive, or extraction on Windows, carries no
# useful permission info, so fall back to a plain extract there.
if info.create_system == 0 or sys.platform == "win32":
zip_data.extract(info, path=dest)
continue

st_mode = info.external_attr >> 16
dst_path = os.path.join(dest, info.filename)
if stat.S_ISLNK(st_mode):
# Symlinks are stored as files whose contents are the target;
# recreate the link rather than extracting it as a file.
link_target = zip_data.read(info)
os.makedirs(os.path.dirname(dst_path), exist_ok=True)
if os.path.islink(dst_path):
os.unlink(dst_path)
os.symlink(link_target, dst_path)
else:
zip_data.extract(info, path=dest)
# Preserve the permission bits (rwxrwxrwx) only, dropping the
# sticky/setuid/setgid bits.
os.chmod(dst_path, st_mode & 0o777)


def install_chrome(channel: Literal["stable"] = "stable") -> Path:
"""Download Chrome for Testing and return the browser binary path."""
cft_platform = chrome_platform()
install_dir = Path(tempfile.mkdtemp(prefix=f"chrome-{channel}-", dir=Path.home()))

url = resolve_chrome_download_url(channel, cft_platform)
archive = install_dir / f"chrome-{cft_platform}.zip"

logger.info("downloading Chrome for Testing from %s", url)
with requests.get(url, stream=True, timeout=120) as response:
response.raise_for_status()
with archive.open("wb") as out:
for chunk in response.iter_content(chunk_size=1 << 20):
if chunk:
out.write(chunk)

unzip(archive, install_dir)
archive.unlink()

binary = chrome_binary_path(install_dir, cft_platform)
if not binary.exists():
raise RuntimeError(f"Chrome binary not found at {binary} after unpacking")

logger.info("installed Chrome at %s", binary)
return binary


class ChromeBrowsers:
def __init__(self) -> None:
self._stable: Path | None = None

@property
def stable(self) -> Path:
if self._stable is None:
self._stable = install_chrome(channel="stable")
return self._stable
Loading