deer-flow/backend/tests/test_output_names.py
Hyeonsang Cho 9b0c7cbc37
fix(browser): never replace an existing output file with browser_screenshot (#6913)
* fix(browser): never replace an existing output file with browser_screenshot

browser_screenshot published straight onto outputs/<name> through the
replacing atomic publisher, so a screenshot saved as report.png silently
replaced the user's own report.png, an earlier capture, or another
default-named capture taken in the same second (the default stem has
second resolution). The sibling web_capture tool already keeps existing
files and takes a -1, -2, ... suffix.

Stage the complete capture under a hidden sibling and hard-link it onto
the first free candidate name. The link is the whole no-overwrite guard:
it fails with FileExistsError when the name exists as anything, so no
name is checked before it is claimed and a concurrent writer cannot be
replaced; the final name never shows a partial file. Staging shares the
outputs directory, so the link is same-filesystem, and like Gateway
uploads other link failures propagate instead of falling back to
replacement. New files keep the publisher's normal creation mode.

Hidden per-action progress frames keep the replacing _write_screenshot:
their names carry microsecond timestamps in a server-owned directory.

* docs(changelog): reference #6913 for the browser_screenshot no-overwrite fix

* fix(browser): refuse a staged screenshot swapped before it is linked

The sandbox can write outputs/, so it can see the hidden staged capture
and replace it with a symlink before the hard link. os.link follows
symlinks by default, which would publish a hard link to the host file as
the screenshot artifact (the replacing publisher this PR moved away from
never followed one).

Stage the capture with an exclusively created descriptor and keep its
identity from fstat, link without following symlinks where the platform
supports it (decided once at import), and require the published name to
be that inode; anything else is removed and the capture fails, like
Gateway uploads' identity check. A failed staging write still leaves no
files behind.

* docs(readme): note that browser_screenshot never replaces an output file

* fix(browser): keep the staged screenshot open until its link is verified

Linux filesystems reuse a freed inode number at once. Closing the staged
capture before linking let a file swapped in at the staged name take the
same number and pass the samestat check, so the symlink and planted-file
swap tests failed on Linux CI while passing on macOS. Hold the staged
descriptor through the link and identity check, as Gateway uploads do,
and require the published name to be a regular file.

* test(browser): race the peer claim against the real publication step

The claimed-name test patched _publish_bytes_atomically, which the
screenshot path no longer calls, so its hook never fired and the race
assertions passed vacuously. Plant the peer file right before os.link or
os.replace lands bytes on a visible name, and assert that the claim
happened, the capture moved to report-1.png, and the peer file survived.

* refactor(capture): share one output-name candidate sequence

browser_screenshot hand-copied web_capture's probe bound and suffix
order, and the copies had already drifted: Browserless retried a held
timestamped name five times, the screenshot tool once. Move the bound,
the retry count and the candidate order into utils/output_names.py and
have both tools iterate it, so the naming cannot drift again. Browserless
behavior is unchanged; its tests now patch the shared module.

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-18 10:52:22 +08:00

79 lines
3.3 KiB
Python

# SPDX-License-Identifier: MIT
"""Both capture tools take output names from one shared candidate sequence."""
from __future__ import annotations
import importlib
from datetime import UTC, datetime
from itertools import islice
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from deerflow.utils import output_names
browser_automation_tools = importlib.import_module("deerflow.community.browser_automation.tools")
browserless_tools = importlib.import_module("deerflow.community.browserless.tools")
class _StepClock(datetime):
"""Each call advances one microsecond, so every fallback stamp is distinct."""
ticks = 0
@classmethod
def now(cls, tz=None):
cls.ticks += 1
return datetime(2026, 10, 18, 12, 0, 0, cls.ticks, tzinfo=UTC)
def test_candidates_keep_the_name_then_number_then_timestamp(monkeypatch):
monkeypatch.setattr(output_names, "MAX_COLLISION_PROBES", 2)
monkeypatch.setattr(_StepClock, "ticks", 0)
monkeypatch.setattr(output_names, "datetime", _StepClock)
assert list(output_names.output_name_candidates("report.png")) == [
"report.png",
"report-1.png",
"report-2.png",
*(f"report-20261018-120000-{tick:06d}.png" for tick in range(1, output_names.MAX_TIMESTAMP_ATTEMPTS + 1)),
]
def test_candidates_read_the_clock_only_when_a_fallback_is_reached(monkeypatch):
monkeypatch.setattr(_StepClock, "ticks", 0)
monkeypatch.setattr(output_names, "datetime", _StepClock)
assert len(list(islice(output_names.output_name_candidates("a.png"), output_names.MAX_COLLISION_PROBES + 1))) == output_names.MAX_COLLISION_PROBES + 1
assert _StepClock.ticks == 0
def test_both_capture_tools_share_the_candidate_sequence():
for module in (browser_automation_tools, browserless_tools):
assert module.output_name_candidates is output_names.output_name_candidates
@pytest.mark.asyncio
async def test_screenshot_retries_a_held_timestamp_fallback(tmp_path, monkeypatch):
# Browserless already retried a held timestamped name; the screenshot tool
# now shares that bound instead of giving up after one stamp.
monkeypatch.setattr(output_names, "MAX_COLLISION_PROBES", 1)
monkeypatch.setattr(_StepClock, "ticks", 0)
monkeypatch.setattr(output_names, "datetime", _StepClock)
held = ["report.png", "report-1.png", "report-20261018-120000-000001.png"]
for name in held:
(tmp_path / name).write_bytes(b"earlier")
session = MagicMock()
session.screenshot_bytes = AsyncMock(return_value=b"shot")
manager = MagicMock()
manager.get_session.return_value = session
runtime = SimpleNamespace(context={"thread_id": "thread-1"}, state={"thread_data": {"outputs_path": str(tmp_path)}})
with patch.object(browser_automation_tools, "get_browser_session_manager", return_value=manager), patch.object(browser_automation_tools, "_get_tool_config", return_value={}):
result = await browser_automation_tools.browser_screenshot_tool.coroutine(runtime=runtime, tool_call_id="t1", filename="report.png")
assert result.update["artifacts"] == ["/mnt/user-data/outputs/report-20261018-120000-000002.png"]
assert (tmp_path / "report-20261018-120000-000002.png").read_bytes() == b"shot"
assert all((tmp_path / name).read_bytes() == b"earlier" for name in held)