from __future__ import annotations
import json
import zipfile
from pathlib import Path
from types import SimpleNamespace
import pytest
from app.gateway.routers import artifacts
from deerflow.html_runtime import offline_bundle
from deerflow.html_runtime.offline_bundle import build_html_offline_bundle
from deerflow.uploads.policy import default_upload_policy, set_runtime_upload_policy
@pytest.fixture(autouse=True)
def _restore_upload_policy():
set_runtime_upload_policy(default_upload_policy())
yield
set_runtime_upload_policy(default_upload_policy())
@pytest.mark.asyncio
async def test_artifact_preview_reads_small_text_without_unbounded_artifact_response(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
source = tmp_path / "notes.txt"
source.write_text("第一行\n第二行", encoding="utf-8")
async def resolve(_thread_id: str, _path: str) -> Path:
return source
monkeypatch.setattr(artifacts, "aresolve_thread_virtual_path", resolve)
response = await artifacts.preview_artifact.__wrapped__(
thread_id="thread-1",
request=SimpleNamespace(),
path="mnt/user-data/uploads/notes.txt",
max_chars=1024,
)
assert response.content.splitlines() == ["第一行", "第二行"]
assert response.truncated is False
assert response.strategy == "complete-text"
assert response.total_bytes == source.stat().st_size
@pytest.mark.asyncio
async def test_artifact_preview_samples_large_text_with_full_file_statistics(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
source = tmp_path / "words.txt"
source.write_text("".join(f"word-{index:04d}\n" for index in range(1, 1001)), encoding="utf-8")
policy = default_upload_policy()
policy["large_file"] = {
"threshold_bytes": 100,
"max_result_chars": 4096,
"hard_max_result_chars": 4096,
}
set_runtime_upload_policy(policy)
async def resolve(_thread_id: str, _path: str) -> Path:
return source
monkeypatch.setattr(artifacts, "aresolve_thread_virtual_path", resolve)
response = await artifacts.preview_artifact.__wrapped__(
thread_id="thread-1",
request=SimpleNamespace(),
path="mnt/user-data/uploads/words.txt",
max_chars=4096,
)
assert response.truncated is True
assert response.stats is not None
assert response.stats["lines"] == 1000
assert "流式扫描" in response.content
assert "word-0001" in response.content
assert len(response.content) <= 4096
assert "sampl" in response.strategy
@pytest.mark.asyncio
async def test_artifact_preview_rejects_binary_content(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
source = tmp_path / "payload.bin"
source.write_bytes(b"abc\x00def")
async def resolve(_thread_id: str, _path: str) -> Path:
return source
monkeypatch.setattr(artifacts, "aresolve_thread_virtual_path", resolve)
with pytest.raises(artifacts.HTTPException) as raised:
await artifacts.preview_artifact.__wrapped__(
thread_id="thread-1",
request=SimpleNamespace(),
path="mnt/user-data/uploads/payload.bin",
max_chars=1024,
)
assert raised.value.status_code == 415
@pytest.mark.asyncio
async def test_html_artifact_offline_bundle_uses_zip_export(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
source = tmp_path / "page.html"
source.write_text("
portable", encoding="utf-8")
calls: list[Path] = []
async def resolve(_thread_id: str, _path: str) -> Path:
return source
async def bundle_response(path: Path):
calls.append(path)
return artifacts.Response(content=b"zip-content", media_type="application/zip")
monkeypatch.setattr(artifacts, "aresolve_thread_virtual_path", resolve)
monkeypatch.setattr(artifacts, "build_html_offline_bundle_response", bundle_response)
response = await artifacts.get_artifact.__wrapped__(
thread_id="thread-1",
path="mnt/user-data/outputs/page.html",
request=SimpleNamespace(),
download=True,
offline_bundle=True,
)
assert response.media_type == "application/zip"
assert calls == [source]
def _write_offline_fixture(path: Path, content: str | bytes) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
if isinstance(content, bytes):
path.write_bytes(content)
else:
path.write_text(content, encoding="utf-8")
def _offline_runtime_fixture(root: Path) -> Path:
runtime = root / "runtime"
_write_offline_fixture(runtime / "echarts.min.js", "window.echarts={};")
_write_offline_fixture(runtime / "deerflow-runtime.js", "window.__runtimeLoaded=true;")
_write_offline_fixture(runtime / "brand-logo-dark.png", b"fallback-logo")
_write_offline_fixture(runtime / "LICENSE.echarts.txt", "ECharts license")
for name in (
"china-provinces.geojson",
"japan-prefectures.geojson",
"taiwan-counties.geojson",
"us-states.geojson",
):
_write_offline_fixture(runtime / "maps" / name, '{"type":"FeatureCollection","features":[]}')
return runtime
def test_html_offline_bundle_rewrites_and_packages_gateway_runtime(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
runtime = _offline_runtime_fixture(tmp_path)
logo = tmp_path / "branding" / "logo.webp"
_write_offline_fixture(logo, b"configured-logo")
uploaded_image = tmp_path / "uploads" / "background.png"
_write_offline_fixture(uploaded_image, b"uploaded-image")
monkeypatch.setattr(
offline_bundle,
"verify_html_image_token",
lambda _token: ("thread-1", "uploads", "background.png"),
)
monkeypatch.setattr(
offline_bundle,
"resolve_html_image_path",
lambda _thread_id, _kind, _filename: uploaded_image,
)
source = tmp_path / "态势报告.html"
source.write_text(
"""
来源""",
encoding="utf-8",
)
destination = tmp_path / "bundle.zip"
result = build_html_offline_bundle(
source,
destination,
runtime_base_url="http://gateway.local/prefix/api/public/html-runtime",
map_tile_url="https://tiles.example/{z}/{x}/{y}.png",
generated_logo_path=logo,
runtime_dir=runtime,
)
assert destination.is_file()
assert "assets/html-runtime/echarts.min.js" in result.bundled_assets
assert "https://example.com/source" in result.external_urls
assert "https://tiles.example/{z}/{x}/{y}.png" in result.external_urls
with zipfile.ZipFile(destination) as archive:
names = set(archive.namelist())
assert "index.html" in names
assert "README.txt" in names
assert "manifest.json" in names
assert "assets/html-runtime/echarts.min.js" in names
assert "assets/html-runtime/deerflow-runtime.js" in names
assert "assets/html-runtime/generated-page-logo.webp" in names
assert "assets/html-runtime/maps/china-provinces.geojson" in names
assert any(name.startswith("assets/html-images/") for name in names)
assert "THIRD_PARTY_LICENSES/LICENSE.echarts.txt" in names
html = archive.read("index.html").decode("utf-8")
assert "gateway.local" not in html
assert 'src="assets/html-runtime/echarts.min.js"' in html
assert "assets/html-runtime/generated-page-logo.webp" in html
assert "/api/public/html-images/" not in html
runtime_script = archive.read("assets/html-runtime/deerflow-runtime.js").decode("utf-8")
assert "DEERFLOW_HTML_RUNTIME_CONFIG" in runtime_script
assert "assets/html-runtime/maps/china-provinces.geojson" in runtime_script
assert "window.__runtimeLoaded=true" in runtime_script
manifest = json.loads(archive.read("manifest.json"))
assert manifest["entry"] == "index.html"
assert manifest["source_name"] == "态势报告.html"
assert manifest["external_urls"] == [
"https://example.com/source",
"https://tiles.example/{z}/{x}/{y}.png",
]
def test_html_offline_bundle_copies_cognitive_runtime_tree(tmp_path: Path) -> None:
runtime = _offline_runtime_fixture(tmp_path)
_write_offline_fixture(runtime / "cognitive-situation-map" / "app.js", "window.cognitive=true;")
_write_offline_fixture(runtime / "cognitive-situation-map" / "styles.css", "body{color:white}")
_write_offline_fixture(runtime / "cognitive-situation-map" / "assets" / "maps" / "taiwan.png", b"map")
source = tmp_path / "认知态势图.html"
source.write_text(
"""
""",
encoding="utf-8",
)
destination = tmp_path / "cognitive.zip"
build_html_offline_bundle(
source,
destination,
runtime_base_url="http://gw/api/public/html-runtime",
map_tile_url="https://tiles.example/{z}/{x}/{y}.png",
runtime_dir=runtime,
)
with zipfile.ZipFile(destination) as archive:
html = archive.read("index.html").decode("utf-8")
assert "http://gw" not in html
assert "assets/html-runtime/cognitive-situation-map" in html
assert "assets/html-runtime/cognitive-situation-map/app.js" in archive.namelist()
assert "assets/html-runtime/cognitive-situation-map/assets/maps/taiwan.png" in archive.namelist()