from __future__ import annotations import json import re import subprocess import sys from pathlib import Path import pytest BACKEND_ROOT = Path(__file__).resolve().parents[1] SKILLS_ROOT = BACKEND_ROOT / "skills" / "public" LEAD_PROMPT_PATH = BACKEND_ROOT / "packages" / "harness" / "deerflow" / "agents" / "lead_agent" / "prompt.py" FAST_SKILLS = ( "tiktok-page-generator", "youtube-page-generator", "threads-page-generator", "cnn-page-generator", "bbc-page-generator", "xinhua-page-generator", "white-house-page-generator", "manila-times-page-generator", "gma-news-page-generator", "yahoo-japan-page-generator", "yomiuri-news-page-generator", ) BRANDED_PAGE_SKILLS = ( "facebook-page-generator", "x-page-generator", *FAST_SKILLS, ) BRAND_HEADER_OFFSET_SNIPPETS = { "facebook-page-generator": ( ".app-header{position:fixed;inset:var(--df-generated-header-height,0px)0auto;", ".sidebar{position:sticky;top:calc(var(--df-generated-header-height,0px)+var(--header-h)+20px);", ), "x-page-generator": ( ".left-fixed{position:fixed;top:var(--df-generated-header-height,0px);", ".feed-header{position:sticky;z-index:30;top:var(--df-generated-header-height,0px);", ), "tiktok-page-generator": ( ".app-header{position:fixed;z-index:80;inset:var(--df-generated-header-height,0px)0auto;", ".channel-tabs{position:fixed;z-index:55;top:calc(var(--df-generated-header-height,0px)+var(--header-h)+10px);", ), "youtube-page-generator": ( ".app-header{position:fixed;z-index:90;inset:var(--df-generated-header-height,0px)0auto;", ".category-bar{position:sticky;z-index:60;top:calc(var(--df-generated-header-height,0px)+var(--header-h));", ), "threads-page-generator": ( ".topbar{position:fixed;z-index:80;inset:var(--df-generated-header-height,0px)0auto;", ".mobile-header{position:fixed;z-index:75;inset:var(--df-generated-header-height,0px)0auto;", ), "cnn-page-generator": ( ".topbar{background:var(--nav);color:white;position:sticky;top:var(--df-generated-header-height,0px);", ), "bbc-page-generator": ( ".settings-trigger{position:fixed;z-index:70;top:calc(var(--df-generated-header-height,0px)+10px);", ), "xinhua-page-generator": ( ".settings-trigger{position:fixed;right:18px;top:calc(var(--df-generated-header-height,0px)+122px);", ), "white-house-page-generator": ( ".site-head{position:absolute;top:calc(var(--df-generated-header-height,0px)+6px);", ".site-head.scrolled{position:fixed;top:var(--df-generated-header-height,0px);", ), "manila-times-page-generator": ( ".primary-nav.stuck{top:var(--df-generated-header-height,0px)}", ".settings-button{top:calc(var(--df-generated-header-height,0px)+18px)}", ), "gma-news-page-generator": ( "body.sticky.nav{top:var(--df-generated-header-height,0px)}", ".settings-button{top:calc(var(--df-generated-header-height,0px)+58px)}", ), "yahoo-japan-page-generator": ( ".settings-button{top:calc(var(--df-generated-header-height,0px)+20px)}", ), "yomiuri-news-page-generator": ( ".sticky.nav{top:var(--df-generated-header-height,0px)}", ".settings-button{top:calc(var(--df-generated-header-height,0px)+20px)}", ), } SPECIALIZED_FAST_SKILLS = {"threads-page-generator", "cnn-page-generator"} SHARED_FAST_SKILLS = tuple(skill_name for skill_name in FAST_SKILLS if skill_name not in SPECIALIZED_FAST_SKILLS) @pytest.mark.parametrize("skill_name", BRANDED_PAGE_SKILLS) def test_platform_chrome_is_offset_below_generated_brand_header(skill_name: str) -> None: templates = list((SKILLS_ROOT / skill_name / "assets").glob("*-template.html")) assert len(templates) == 1 compact_template = re.sub(r"\s+", "", templates[0].read_text(encoding="utf-8")) snippets = BRAND_HEADER_OFFSET_SNIPPETS[skill_name] for snippet in snippets: assert snippet in compact_template @pytest.mark.parametrize("skill_name", BRANDED_PAGE_SKILLS) def test_platform_modals_render_above_generated_brand_header(skill_name: str) -> None: templates = list((SKILLS_ROOT / skill_name / "assets").glob("*-template.html")) assert len(templates) == 1 template = templates[0].read_text(encoding="utf-8") compact_template = re.sub(r"\s+", "", template) assert "z-index:1200" in compact_template assert "z-index:1300" in compact_template if skill_name == "manila-times-page-generator": assert ".skip-link:focus{top:calc(var(--df-generated-header-height,0px)+10px)}" in compact_template else: assert "translateY(calc(-100%-var(--df-generated-header-height,0px)-24px))" in compact_template def test_fast_workflow_is_declared_by_all_fixed_template_skills() -> None: for skill_name in FAST_SKILLS: skill_text = (SKILLS_ROOT / skill_name / "SKILL.md").read_text(encoding="utf-8") assert "## 极速工作流(默认)" in skill_text assert "/scripts/fast_page.py --spec" in skill_text for skill_name in SHARED_FAST_SKILLS: skill_text = (SKILLS_ROOT / skill_name / "SKILL.md").read_text(encoding="utf-8") assert "禁止读取默认数据、契约、模板、运行时或脚本源码" in skill_text lead_prompt = LEAD_PROMPT_PATH.read_text(encoding="utf-8") assert "专用固定模板极速规则" in lead_prompt assert "fast_page.py --spec" in lead_prompt support_manifest = json.loads((SKILLS_ROOT / "_shared" / ".deerflow-skill-support.json").read_text(encoding="utf-8")) assert set(support_manifest["required_by"]) == set(SHARED_FAST_SKILLS) @pytest.mark.parametrize( ("skill_name", "headline_path", "output_stem"), [ ("tiktok-page-generator", "videos.items.0.text", "tiktok-page"), ("youtube-page-generator", "videos.items.0.title", "youtube-page"), ("bbc-page-generator", "lead.title", "bbc-page"), ("xinhua-page-generator", "headline.title", "xinhua-page"), ("white-house-page-generator", "hero.title", "white-house-page"), ("manila-times-page-generator", "lead.main.title", "manila-times-page"), ("gma-news-page-generator", "lead.main.title", "gma-news-page"), ("yahoo-japan-page-generator", "news.feature.title", "yahoo-japan-page"), ("yomiuri-news-page-generator", "lead.main.title", "yomiuri-news-page"), ], ) def test_compact_spec_renders_complete_page_in_one_call( tmp_path: Path, skill_name: str, headline_path: str, output_stem: str, ) -> None: skill_root = SKILLS_ROOT / skill_name spec_path = tmp_path / "page-spec.json" spec_path.write_text( json.dumps( { "site": {"pageTitle": f"极速生成 · {skill_name}"}, "$set": {headline_path: "这是通过小型规格 JSON 更新的中文主题内容"}, }, ensure_ascii=False, ), encoding="utf-8", ) command = [ sys.executable, str(skill_root / "scripts" / "fast_page.py"), "--spec", str(spec_path), "--output-dir", str(tmp_path), ] completed = subprocess.run( command, check=False, capture_output=True, text=True, encoding="utf-8", ) assert completed.returncode == 0, completed.stderr result = json.loads(completed.stdout) assert result["status"] == "ok" assert result["action"] == "fast-page-spec" data_path = Path(result["data"]) html_path = Path(result["html"]) assert data_path.name == f"{output_stem}-data.json" assert html_path.name == f"{output_stem}.html" assert data_path.exists() assert html_path.exists() data = json.loads(data_path.read_text(encoding="utf-8")) assert data["site"]["pageTitle"] == f"极速生成 · {skill_name}" current: object = data for part in headline_path.split("."): current = current[int(part)] if isinstance(current, list) else current[part] assert current == "这是通过小型规格 JSON 更新的中文主题内容" html = html_path.read_text(encoding="utf-8") assert "这是通过小型规格 JSON 更新的中文主题内容" in html second = subprocess.run( command, check=False, capture_output=True, text=True, encoding="utf-8", ) assert second.returncode == 0, second.stderr second_result = json.loads(second.stdout) assert second_result["data"] != result["data"] assert second_result["html"] != result["html"] def test_compact_spec_rejects_unknown_modules(tmp_path: Path) -> None: skill_root = SKILLS_ROOT / "tiktok-page-generator" spec_path = tmp_path / "invalid-spec.json" spec_path.write_text( json.dumps({"unknownModule": {"title": "不允许"}}, ensure_ascii=False), encoding="utf-8", ) completed = subprocess.run( [ sys.executable, str(skill_root / "scripts" / "fast_page.py"), "--spec", str(spec_path), "--output-dir", str(tmp_path), ], check=False, capture_output=True, text=True, encoding="utf-8", ) assert completed.returncode == 2 result = json.loads(completed.stdout) assert result["status"] == "error" assert "Unknown top-level spec keys" in result["error"]