Playwright_Langgraph

python 复制代码
import asyncio
import json
import logging
import os
from typing import TypedDict, List, Dict, Optional, Annotated, Any
from pydantic import BaseModel, Field
from playwright.async_api import async_playwright
from langchain_core.tools import tool
from langchain_openai import ChatOpenAI
from langgraph.graph import StateGraph, START, END
from langgraph.prebuilt import ToolNode
import operator
from dotenv import load_dotenv
import os

# 必须在 os.getenv() 调用前执行
load_dotenv()

logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
logger = logging.getLogger("UI_Framework")


# ================= 1. 数据模型与状态 =================
class StepPlan(BaseModel):
    tool_name: str
    args: Dict[str, Any]
    description: str = ""
    expected_result: str = ""


class TestTask(BaseModel):
    id: str
    prompt: str
    base_url: Optional[str] = None


class FrameworkState(TypedDict):
    task: TestTask
    plan: List[StepPlan]
    current_step_idx: int
    execution_log: Annotated[List[Dict], operator.add]  # 自动追加
    status: str  # pending, planning, executing, success, failed
    error: Optional[str]
    final_report: Optional[Dict]


# ================= 2. 浏览器上下文管理器 =================
class PlaywrightContext:
    def __init__(self):
        self.pw = None
        self.browser = None
        self.context = None
        self.page = None

    async def init(self, headless: bool = True):
        self.pw = await async_playwright().start()
        self.browser = await self.pw.chromium.launch(headless=headless)
        self.context = await self.browser.new_context(
            viewport={"width": 1440, "height": 900},
            ignore_https_errors=True,
            user_agent="LangGraph-UITestBot/1.0"
        )
        self.page = await self.context.new_page()
        return self.page

    async def close(self):
        if self.page: await self.page.close()
        if self.context: await self.context.close()
        if self.browser: await self.browser.close()
        if self.pw: await self.pw.stop()


browser_ctx = PlaywrightContext()


# ================= 3. Playwright 工具集(显式命名对齐 Prompt) =================
@tool("navigate")
async def pw_navigate(url: str) -> str:
    """导航到指定URL"""
    await browser_ctx.page.goto(url, wait_until="domcontentloaded", timeout=15000)
    return f"✅ 导航成功: {url}"

@tool("click")
async def pw_click(selector: str, timeout: int = 5000) -> str:
    """点击元素"""
    await browser_ctx.page.click(selector, timeout=timeout)
    return f"✅ 点击成功: {selector}"

@tool("fill")
async def pw_fill(selector: str, value: str) -> str:
    """填写表单"""
    await browser_ctx.page.fill(selector, value)
    return f"✅ 填写成功: {selector} = {value}"

@tool("wait_for")
async def pw_wait_for(selector: str, state: str = "visible", timeout: int = 10000) -> str:
    """等待元素出现/可点击"""
    await browser_ctx.page.wait_for_selector(selector, state=state, timeout=timeout)
    return f"✅ 等待成功: {selector} ({state})"

@tool("assert_text")
async def pw_assert_text(selector: str, expected: str) -> str:
    """断言元素包含指定文本"""
    actual = await browser_ctx.page.inner_text(selector)
    if expected.strip().lower() in actual.strip().lower():
        return f"✅ 断言通过: 期望'{expected}', 实际'{actual}'"
    raise AssertionError(f"❌ 断言失败: 期望'{expected}', 实际'{actual}'")

@tool("screenshot")
async def pw_screenshot(path: str = "tmp/screenshot.png") -> str:
    """全屏截图"""
    import os
    # ✅ 安全处理路径(防止传入纯文件名时 dirname 为空导致 makedirs 报错)
    dir_name = os.path.dirname(path)
    if dir_name:
        os.makedirs(dir_name, exist_ok=True)
    await browser_ctx.page.screenshot(path=path, full_page=True)
    return f"✅ 截图已保存: {path}"

@tool("select")
async def pw_select(selector: str, value: str) -> str:
    """下拉框选择"""
    await browser_ctx.page.select_option(selector, value=value)
    return f"✅ 选择成功: {selector} = {value}"

# 工具列表保持不变
TOOLS = [pw_navigate, pw_click, pw_fill, pw_wait_for, pw_assert_text, pw_screenshot, pw_select]
tool_node = ToolNode(TOOLS)


# ================= 4. LLM 规划器(替换原代码) =================
class StepPlanList(BaseModel):
    """强制根节点为对象,兼容 DashScope/JSON Mode"""
    steps: List[StepPlan]

planner_llm = ChatOpenAI(
    api_key=os.getenv("API_KEY"),
    base_url=os.getenv("BASE_URL", "https://dashscope.yuncs.com/compatible-mode/v1"),
    model="qwen-plus",
    temperature=0.0
).with_structured_output(
    StepPlanList,
    method="function_calling"  # ✅ 阿里云/通义千问对 function_calling 支持更稳定
)

async def plan_node(state: FrameworkState) -> Dict:
    # ✅ 修复:TypedDict 运行时是 dict,必须用 [] 访问
    task_dict = state["task"]
    prompt = task_dict.get("prompt", "")
    base_url = task_dict.get("base_url")
    if base_url:
        prompt = f"🌐 目标网站: {base_url}\n📝 任务: {prompt}"

    system_msg = """你是资深 UI 自动化测试架构师。请将自然语言测试任务分解为可执行步骤。
    🔑 规则:
    1. 严格使用工具: navigate, click, fill, wait_for, assert_text, screenshot, select
    2. 选择器优先使用: data-testid, id, role(name="xxx"), 或稳定 CSS 路径
    3. 第一步必须是 navigate (除非任务明确说明已在页面)
    4. 复杂交互拆分为多步,每步只做一个动作"""

    try:
        plan_obj = await planner_llm.ainvoke([
            {"role": "system", "content": system_msg},
            {"role": "user", "content": prompt}
        ])
        return {
            "plan": [p.model_dump() for p in plan_obj.steps],
            "status": "planning",
            "current_step_idx": 0,
            "execution_log": [{"step": "plan", "msg": f"🤖 已生成 {len(plan_obj.steps)} 个测试步骤"}]
        }
    except Exception as e:
        return {"status": "failed", "error": f"⚠️ 规划失败: {str(e)}", "execution_log": []}


# ================= 5. 执行器(单步推进 + 错误拦截) =================
async def execute_node(state: FrameworkState) -> Dict:
    plan = state["plan"]
    idx = state["current_step_idx"]
    logs = []

    if idx >= len(plan):
        return {"status": "success", "execution_log": [{"step": "complete", "msg": "🏁 所有步骤执行完毕"}]}

    step = plan[idx]
    tool_name = step.get("tool_name")
    args = step.get("args", {})

    target_tool = next((t for t in TOOLS if t.name == tool_name), None)
    if not target_tool:
        return {"status": "failed", "error": f"🔧 未知工具: {tool_name}", "current_step_idx": idx}

    try:
        result = await target_tool.ainvoke(args)
        logs.append({"step": idx, "tool": tool_name, "result": result, "status": "success"})
        return {"current_step_idx": idx + 1, "execution_log": logs}
    except Exception as e:
        logs.append({"step": idx, "tool": tool_name, "error": str(e), "status": "failed"})
        return {
            "status": "failed",
            "error": f"❌ 步骤 {idx} ({tool_name}) 执行失败: {str(e)}",
            "execution_log": logs,
            "current_step_idx": idx
        }


# ================= 6. 验证与清理 =================
async def validate_node(state: FrameworkState) -> Dict:
    return {"execution_log": [{"step": "validate", "msg": "🔍 执行最终状态校验"}]}


async def teardown_node(state: FrameworkState) -> Dict:
    await browser_ctx.close()
    return {"execution_log": [{"step": "teardown", "msg": "🛑 浏览器资源已释放"}]}


# ================= 7. 组装 LangGraph =================
workflow = StateGraph(FrameworkState)
workflow.add_node("plan", plan_node)
workflow.add_node("execute", execute_node)
workflow.add_node("validate", validate_node)
workflow.add_node("teardown", teardown_node)

workflow.set_entry_point("plan")
workflow.add_edge("plan", "execute")
workflow.add_edge("validate", "teardown")
workflow.add_edge("teardown", END)


def route_after_execute(state: FrameworkState) -> str:
    if state.get("status") == "failed":
        return "validate"
    if state["current_step_idx"] < len(state["plan"]):
        return "execute"
    return "validate"


workflow.add_conditional_edges("execute", route_after_execute)
graph = workflow.compile()


# ================= 8. 框架运行器(支持批量/配置驱动) =================
class UITestFramework:
    def __init__(self):
        self.graph = graph

    async def run_task(self, prompt: str, base_url: str = None, channel: str=None,headless: bool = True, task_id: str = "auto",
                       **kwargs) -> Dict:
        # ✅ 兼容配置中的 "id" 字段,覆盖默认 task_id
        task_id = kwargs.pop("id", task_id)
        print("task_id >>",task_id)

        # ✅ 过滤其他未定义参数(防配置污染)
        unknown_keys = [k for k in kwargs if k not in ("task_id",)]
        if unknown_keys:
            import logging
            logging.warning(f"⚠️ 忽略未定义参数: {unknown_keys}")

        await browser_ctx.init(headless=headless)
        initial_state = {
            "task": {"id": task_id, "prompt": prompt, "base_url": base_url,"channel":channel},
            "plan": [],
            "current_step_idx": 0,
            "execution_log": [],
            "status": "pending",
            "error": None,
            "final_report": None
        }
        try:
            print("initial_state >>",initial_state)
            return await self.graph.ainvoke(initial_state)
        except Exception as e:
            await browser_ctx.close()
            return {"status": "critical_failed", "error": str(e), "execution_log": []}

    async def run_from_yaml(self, yaml_path: str, headless: bool = True) -> List[Dict]:
        import yaml
        with open(yaml_path, "r", encoding="utf-8") as f:
            config = yaml.safe_load(f)

        results = []
        for task in config.get("tasks", []):
            logger.info(f"🚀 执行: {task.get('prompt', 'Unknown')[:60]}...")
            res = await self.run_task(
                prompt=task["prompt"],
                base_url=task.get("base_url"),
                headless=headless,
                task_id=task.get("id", f"task_{len(results) + 1}")
            )
            results.append(res)
        return results


# ================= 9. 使用示例 =================
class UITestFramework:
    def __init__(self):
        self.graph = graph

    async def run_task(self, prompt: str, base_url: str = None, channel: str = None,
                       headless: bool = True, task_id: str = "auto", **kwargs) -> Dict:
        task_id = kwargs.pop("id", task_id)

        try:
            await browser_ctx.init(headless=headless)
        except Exception as e:
            return {"status": "critical_failed", "error": f"浏览器启动失败: {str(e)}", "execution_log": []}

        initial_state = {
            "task": {"id": task_id, "prompt": prompt, "base_url": base_url, "channel": channel},
            "plan": [],
            "current_step_idx": 0,
            "execution_log": [],
            "status": "pending",
            "error": None,
            "final_report": None
        }
        try:
            return await self.graph.ainvoke(initial_state)
        except Exception as e:
            await browser_ctx.close()
            return {"status": "critical_failed", "error": str(e), "execution_log": []}


# ================= 9. 使用示例(替换 main) =================
async def main():
    framework = UITestFramework()

    tasks_config = {
        "tasks": [
            {
                "id": "login_test",
                "prompt": "访问首页,点击 Log In 按钮,aaa@163.com,然后登录",
                "base_url": "https://www.baidu.com"
            }
        ]
    }

    for t in tasks_config["tasks"]:
        print(f"\n🚀 开始执行: {t['id']}")
        res = await framework.run_task(**t, headless=False)

        print(f"📊 状态: {res['status'].upper()}")
        if res.get("error"):
            print(f"🔴 错误详情: {res['error']}")
        print("📝 执行日志:")
        for log in res.get("execution_log", []):
            print(f"  {log.get('msg', log)}")


if __name__ == "__main__":
    asyncio.run(main())
相关推荐
@atweiwei8 小时前
用 Rust 构建 LLM 应用的高性能框架
开发语言·后端·ai·rust·langchain·llm
CoderJia程序员甲9 小时前
GitHub 热榜项目 - 日榜(2026-04-07)
ai·大模型·llm·github·ai教程
羊小猪~~10 小时前
LLM--大模型快速展示(Gradio)
人工智能·python·大模型·llm·部署·gradio·ai算法
sun_tao111 小时前
主流大语言模型的损失函数异同
人工智能·llm·损失函数·loss
EdisonZhou11 小时前
MAF快速入门(22)声明式Agent实战
llm·aigc·agent·.net core
linux开发之路12 小时前
C++实现Whisper+Kimi端到端AI智能语音助手
c++·人工智能·llm·whisper·openai
小林学编程12 小时前
模型上下文协议(MCP)的理解
java·后端·llm·prompt·resource·tool·mcp协议
土豆125021 小时前
LangGraph TypeScript 版入门与实践
人工智能·llm
土豆125021 小时前
OpenSpec:让 AI 编码助手从"乱猜"到"照单执行"
人工智能·llm