更新新代码 + 删除旧文件
This commit is contained in:
parent
8464338ab3
commit
fec7cf7b6a
25
ensure_requests.py
Normal file
25
ensure_requests.py
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
|
||||||
|
print(f"Python executable: {sys.executable}")
|
||||||
|
print(f"Current working directory: {os.getcwd()}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
import requests
|
||||||
|
print(f"Requests is already installed: {requests.__version__}")
|
||||||
|
except ImportError:
|
||||||
|
print("Requests is NOT installed. Attempting installation...")
|
||||||
|
try:
|
||||||
|
# Use full path to pip in the same venv
|
||||||
|
pip_path = os.path.join(os.path.dirname(sys.executable), 'pip')
|
||||||
|
print(f"Using pip at: {pip_path}")
|
||||||
|
result = subprocess.run([pip_path, "install", "requests"], capture_output=True, text=True)
|
||||||
|
print("STDOUT:", result.stdout)
|
||||||
|
print("STDERR:", result.stderr)
|
||||||
|
|
||||||
|
import requests
|
||||||
|
print(f"Successfully installed requests: {requests.__version__}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Installation failed: {e}")
|
||||||
615
framework/business/cloud_desktop_page.py
Normal file
615
framework/business/cloud_desktop_page.py
Normal file
@ -0,0 +1,615 @@
|
|||||||
|
import time
|
||||||
|
from framework.core.base_page import BasePage
|
||||||
|
from framework.core.logger import get_logger
|
||||||
|
|
||||||
|
logger = get_logger("CloudDesktopPage")
|
||||||
|
|
||||||
|
class CloudDesktopPage(BasePage):
|
||||||
|
"""云桌面页面 POM - 纯原子操作层"""
|
||||||
|
|
||||||
|
MENU_TEXT = "地瓜桌面"
|
||||||
|
|
||||||
|
STATUS = {
|
||||||
|
"RUNNING": "运行中",
|
||||||
|
"STOPPED": "已关机",
|
||||||
|
"CREATING": "创建中",
|
||||||
|
"STARTING": "开机中"
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, page):
|
||||||
|
self.page = page
|
||||||
|
|
||||||
|
def get_desktop_status(self, desktop_name):
|
||||||
|
"""获取指定名称桌面的全行内容"""
|
||||||
|
return self.page.evaluate("""(name) => {
|
||||||
|
const rows = Array.from(document.querySelectorAll('tr'));
|
||||||
|
for (const row of rows) {
|
||||||
|
const cells = row.querySelectorAll('td');
|
||||||
|
if (cells.length === 0) continue;
|
||||||
|
// 逐列精确匹配名称
|
||||||
|
for (const cell of cells) {
|
||||||
|
const t = cell.innerText.trim();
|
||||||
|
if (t === name) return row.innerText.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 兜底: 模糊匹配
|
||||||
|
const dataRow = rows.find(r => {
|
||||||
|
const text = r.innerText;
|
||||||
|
return text.includes(name) && text.trim().length > name.length + 5;
|
||||||
|
});
|
||||||
|
if (dataRow) return dataRow.innerText.trim();
|
||||||
|
const anyRow = Array.from(document.querySelectorAll('.p-datatable-row, .p-selectable-row'))
|
||||||
|
.find(r => r.innerText.includes(name));
|
||||||
|
return anyRow ? anyRow.innerText.trim() : 'Not Found';
|
||||||
|
}""", desktop_name)
|
||||||
|
|
||||||
|
def wait_for_status(self, name, expected_status, timeout=600):
|
||||||
|
"""循环等待桌面达到指定状态"""
|
||||||
|
logger.info(f"⏳ 等待桌面 {name} 状态变为: {expected_status}...")
|
||||||
|
start = time.time()
|
||||||
|
last_log = 0
|
||||||
|
while time.time() - start < timeout:
|
||||||
|
current = self.get_desktop_status(name)
|
||||||
|
if expected_status in current:
|
||||||
|
logger.info(f"✅ 状态已达标: {expected_status}")
|
||||||
|
return True
|
||||||
|
if time.time() - last_log > 10:
|
||||||
|
snippet = (current[:50] + '...') if len(current) > 50 else current
|
||||||
|
logger.info(f" [状态巡检] {int(time.time()-start)}s | 当前内容: {snippet}")
|
||||||
|
last_log = time.time()
|
||||||
|
time.sleep(5)
|
||||||
|
raise TimeoutError(f"桌面 {name} 在 {timeout}s 内未达到 {expected_status} 状态。最后看到: {current}")
|
||||||
|
|
||||||
|
def navigate_to(self):
|
||||||
|
"""进入云桌面管理页面"""
|
||||||
|
logger.info("正在切换到【地瓜桌面】页面...")
|
||||||
|
try:
|
||||||
|
self.smart_click(self.MENU_TEXT, timeout=3000)
|
||||||
|
try:
|
||||||
|
self.page.wait_for_url("**/*cloud*", timeout=3000)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"原生导航失败,尝试兜底... {e}")
|
||||||
|
self.page.evaluate(f"""() => {{
|
||||||
|
const target = Array.from(document.querySelectorAll('a, span, li, div'))
|
||||||
|
.find(el => el.innerText && el.innerText.trim() === '{self.MENU_TEXT}');
|
||||||
|
if (target) {{
|
||||||
|
target.click();
|
||||||
|
if(target.parentElement) target.parentElement.click();
|
||||||
|
}}
|
||||||
|
}}""")
|
||||||
|
time.sleep(3)
|
||||||
|
|
||||||
|
def get_first_desktop_name(self):
|
||||||
|
"""获取列表首行名称"""
|
||||||
|
return self.page.evaluate("""() => {
|
||||||
|
const cells = document.querySelectorAll('td, .card-name');
|
||||||
|
return cells.length > 0 ? cells[0].innerText.trim() : 'N/A';
|
||||||
|
}""")
|
||||||
|
|
||||||
|
def open_create_dialog(self):
|
||||||
|
"""打开创建弹窗"""
|
||||||
|
logger.info("👉 点击 [创建桌面] 按钮")
|
||||||
|
self.smart_click("创建桌面")
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
def fill_name(self, name):
|
||||||
|
"""输入桌面名称"""
|
||||||
|
logger.info(f"⌨️ 输入桌面名称: {name}")
|
||||||
|
self.smart_fill("名称", name)
|
||||||
|
|
||||||
|
def select_sku(self, sku_id):
|
||||||
|
"""选择资源规格 (复用基类智能下拉框逻辑)"""
|
||||||
|
logger.info(f"🎯 云桌面规格选择: {sku_id}")
|
||||||
|
self.smart_select("资源规格", sku_id)
|
||||||
|
|
||||||
|
def select_image(self, image_keyword="Ubuntu"):
|
||||||
|
"""选择镜像 (增加搜索与精准点击)"""
|
||||||
|
logger.info(f"💿 选择镜像关键词: {image_keyword}")
|
||||||
|
self.smart_click("选择镜像")
|
||||||
|
time.sleep(2) # 等待列表对话框弹出
|
||||||
|
|
||||||
|
# 尝试使用搜索框
|
||||||
|
try:
|
||||||
|
search_input = self.page.locator("input[placeholder*='搜索'], .p-inputtext").first
|
||||||
|
if search_input.is_visible():
|
||||||
|
search_input.fill(image_keyword)
|
||||||
|
self.page.keyboard.press("Enter")
|
||||||
|
time.sleep(1.5)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 定位并点击包含关键词的镜像卡片/行
|
||||||
|
try:
|
||||||
|
# 策略:在 dialog 中寻找包含文本的元素
|
||||||
|
target_selector = f".p-dialog :text-is('{image_keyword}'), .p-dialog :text('{image_keyword}')"
|
||||||
|
self.page.locator(target_selector).first.click(timeout=5000)
|
||||||
|
except:
|
||||||
|
# 备选:智能点击
|
||||||
|
self.smart_click(image_keyword, timeout=3000)
|
||||||
|
|
||||||
|
time.sleep(1)
|
||||||
|
# 点击确定按钮 (弹窗中的)
|
||||||
|
try:
|
||||||
|
self.page.locator(".p-dialog-footer button:has-text('确定')").click()
|
||||||
|
except:
|
||||||
|
self.smart_click("确定", role="button")
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
def submit_creation(self):
|
||||||
|
"""提交创建"""
|
||||||
|
logger.info("🚀 提交创建请求")
|
||||||
|
self.smart_click("创建并开机")
|
||||||
|
time.sleep(3)
|
||||||
|
|
||||||
|
# ── 行内操作通用方法 ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _click_row_action(self, desktop_name, action_label):
|
||||||
|
"""通用:点击指定桌面行内的操作按钮(精确行匹配版)"""
|
||||||
|
logger.info(f"👉 在 {desktop_name} 行内寻找并点击 [{action_label}]")
|
||||||
|
|
||||||
|
clicked = self.page.evaluate("""(args) => {
|
||||||
|
const [name, action] = args;
|
||||||
|
const rows = Array.from(document.querySelectorAll('tr'));
|
||||||
|
let targetRow = null;
|
||||||
|
|
||||||
|
// 精确匹配:遍历每行的每个 td,要求某列文字完全等于 name
|
||||||
|
for (const row of rows) {
|
||||||
|
const cells = row.querySelectorAll('td');
|
||||||
|
if (cells.length === 0) continue;
|
||||||
|
for (const cell of cells) {
|
||||||
|
const t = cell.innerText.trim();
|
||||||
|
if (t === name) {
|
||||||
|
targetRow = row;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (targetRow) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 兜底:如果精确匹配失败,再用 includes(仅第一列)
|
||||||
|
if (!targetRow) {
|
||||||
|
for (const row of rows) {
|
||||||
|
const cells = row.querySelectorAll('td');
|
||||||
|
if (cells.length === 0) continue;
|
||||||
|
const firstCell = cells[0].innerText.trim();
|
||||||
|
if (firstCell === name || firstCell.includes(name)) {
|
||||||
|
targetRow = row;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!targetRow) return 'NO_ROW';
|
||||||
|
|
||||||
|
// 策略1:aria-label
|
||||||
|
let btn = targetRow.querySelector(`button[aria-label="${action}"], [aria-label="${action}"]`);
|
||||||
|
|
||||||
|
// 策略2:按钮/链接文字精确匹配
|
||||||
|
if (!btn) {
|
||||||
|
btn = Array.from(targetRow.querySelectorAll('button, a, span, .p-button'))
|
||||||
|
.find(el => {
|
||||||
|
const txt = el.innerText.trim();
|
||||||
|
const al = el.getAttribute('aria-label') || '';
|
||||||
|
return txt === action || al === action;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (btn) {
|
||||||
|
btn.scrollIntoView({block: 'center'});
|
||||||
|
btn.click();
|
||||||
|
return 'CLICKED';
|
||||||
|
}
|
||||||
|
return 'NO_BTN';
|
||||||
|
}""", [desktop_name, action_label])
|
||||||
|
|
||||||
|
logger.info(f" 行内按钮点击结果: {clicked}")
|
||||||
|
|
||||||
|
if clicked == 'NO_ROW':
|
||||||
|
raise Exception(f"❌ 表格中未找到包含 '{desktop_name}' 的行")
|
||||||
|
if clicked == 'NO_BTN':
|
||||||
|
logger.info(f" JS未命中,尝试 Playwright 定位(限定行内)...")
|
||||||
|
# 用 Playwright 定位到目标行,再在行内找按钮
|
||||||
|
row_locator = self.page.locator(f"tr:has(td:text-is('{desktop_name}'))")
|
||||||
|
try:
|
||||||
|
row_locator.get_by_role("button", name=action_label).first.click(timeout=3000)
|
||||||
|
except:
|
||||||
|
# 最后兜底:在行内找包含操作文字的可点击元素
|
||||||
|
row_locator.locator(f"text='{action_label}'").first.click(timeout=3000)
|
||||||
|
|
||||||
|
def _confirm_dialog(self, confirm_text="确定"):
|
||||||
|
"""确认弹窗 (支持 '预约', '确认', '确定' 等)"""
|
||||||
|
logger.info(f"🔍 正在寻找确认按钮 (目标: {confirm_text})...")
|
||||||
|
time.sleep(1.5) # 给弹窗动画留出充足时间
|
||||||
|
|
||||||
|
# 1. 优先尝试显式的定位器 (含截图中的 aria-label="预约")
|
||||||
|
task_specific_selectors = [
|
||||||
|
"button[aria-label='预约']",
|
||||||
|
"button[aria-label='确定']",
|
||||||
|
".p-confirm-dialog-accept",
|
||||||
|
".p-dialog-footer button:not(.p-button-secondary):not(.p-button-text)"
|
||||||
|
]
|
||||||
|
for sel in task_specific_selectors:
|
||||||
|
try:
|
||||||
|
btn = self.page.locator(sel).first
|
||||||
|
if btn.is_visible(timeout=500):
|
||||||
|
btn.click()
|
||||||
|
logger.info(f"✅ 通过选择器成功点击: {sel}")
|
||||||
|
return
|
||||||
|
except:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 2. 按文案智能匹配
|
||||||
|
keywords = [confirm_text, "预约", "确定", "确认", "提交", "立即"]
|
||||||
|
for kw in keywords:
|
||||||
|
try:
|
||||||
|
# 尝试直接定位包含文字的 PrimeVue 按钮
|
||||||
|
btn = self.page.locator(f"button .p-button-label:text-is('{kw}'), button:has-text('{kw}')").first
|
||||||
|
if btn.is_visible(timeout=500):
|
||||||
|
btn.click()
|
||||||
|
logger.info(f"✅ 通过文案成功点击: {kw}")
|
||||||
|
return
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# JS 强力匹配
|
||||||
|
clicked = self.page.evaluate(f"""(txt) => {{
|
||||||
|
const btns = Array.from(document.querySelectorAll('.p-dialog button, button'))
|
||||||
|
.filter(b => (b.innerText.includes(txt) || b.getAttribute('aria-label') === txt) && b.offsetWidth > 0);
|
||||||
|
if (btns.length > 0) {{
|
||||||
|
btns[0].scrollIntoView({{block: 'center'}});
|
||||||
|
btns[0].click();
|
||||||
|
return true;
|
||||||
|
}}
|
||||||
|
return false;
|
||||||
|
}}""", kw)
|
||||||
|
if clicked:
|
||||||
|
logger.info(f"✅ 通过 JS 模糊匹配成功点击: {kw}")
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.warning(f"⚠️ 即使使用 '预约' 兜底也未能关闭确认窗口")
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
# ── 业务操作 ─────────────────────────────────────────────────────
|
||||||
|
def convert_to_monthly(self, desktop_name):
|
||||||
|
"""按量付费转包月 —— 精确匹配目标行内的按量付费图标"""
|
||||||
|
self.wait_for_status(desktop_name, self.STATUS["RUNNING"])
|
||||||
|
logger.info(f"🎯 尝试转换桌面 {desktop_name} 为包月")
|
||||||
|
|
||||||
|
clicked = self.page.evaluate("""(name) => {
|
||||||
|
const rows = Array.from(document.querySelectorAll('tr'));
|
||||||
|
let targetRow = null;
|
||||||
|
|
||||||
|
// 精确匹配:遍历每行的每个 td,要求某列文字完全等于 name
|
||||||
|
for (const row of rows) {
|
||||||
|
const cells = row.querySelectorAll('td');
|
||||||
|
if (cells.length === 0) continue;
|
||||||
|
for (const cell of cells) {
|
||||||
|
const t = cell.innerText.trim();
|
||||||
|
if (t === name) {
|
||||||
|
targetRow = row;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (targetRow) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 兜底
|
||||||
|
if (!targetRow) {
|
||||||
|
for (const row of rows) {
|
||||||
|
const cells = row.querySelectorAll('td');
|
||||||
|
if (cells.length === 0) continue;
|
||||||
|
const firstCell = cells[0].innerText.trim();
|
||||||
|
if (firstCell === name || firstCell.includes(name)) {
|
||||||
|
targetRow = row;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!targetRow) return 'NO_ROW';
|
||||||
|
|
||||||
|
// 在目标行内找到"按量付费"所在的单元格
|
||||||
|
const cells = Array.from(targetRow.querySelectorAll('td'));
|
||||||
|
for (const cell of cells) {
|
||||||
|
if (cell.innerText.includes('按量')) {
|
||||||
|
// 优先点击内部的可交互元素(图标/链接/按钮)
|
||||||
|
// 注意:svg 元素没有 .click(),统一用 dispatchEvent
|
||||||
|
const clickable = cell.querySelector('a, button, i, span[role="button"], .p-button')
|
||||||
|
|| cell.querySelector('svg')
|
||||||
|
|| cell.querySelector('span, div');
|
||||||
|
if (clickable) {
|
||||||
|
clickable.scrollIntoView({block: 'center'});
|
||||||
|
clickable.dispatchEvent(new MouseEvent('click', {bubbles: true, cancelable: true}));
|
||||||
|
return 'CLICKED_INNER';
|
||||||
|
}
|
||||||
|
// 兜底:点击整个单元格
|
||||||
|
cell.scrollIntoView({block: 'center'});
|
||||||
|
cell.dispatchEvent(new MouseEvent('click', {bubbles: true, cancelable: true}));
|
||||||
|
return 'CLICKED_CELL';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 'NO_CELL';
|
||||||
|
}""", desktop_name)
|
||||||
|
|
||||||
|
logger.info(f" 按量付费点击结果: {clicked}")
|
||||||
|
if clicked == 'NO_ROW':
|
||||||
|
raise Exception(f"❌ 表格中未找到 {desktop_name}")
|
||||||
|
if clicked == 'NO_CELL':
|
||||||
|
logger.warning(" 目标行内未找到按量付费,尝试全局搜索...")
|
||||||
|
self.smart_click("按量付费", timeout=3000)
|
||||||
|
|
||||||
|
time.sleep(1)
|
||||||
|
self._confirm_dialog("确定")
|
||||||
|
time.sleep(2)
|
||||||
|
|
||||||
|
def open_desktop(self, desktop_name):
|
||||||
|
"""打开/开机桌面 (处理新开标签页)"""
|
||||||
|
# 显式等待状态变为运行中,防止操作过快导致按钮没出现
|
||||||
|
self.wait_for_status(desktop_name, self.STATUS["RUNNING"])
|
||||||
|
logger.info(f"🎯 尝试打开桌面 {desktop_name}")
|
||||||
|
|
||||||
|
# 捕获可能打开的新标签页
|
||||||
|
with self.page.context.expect_page(timeout=10000) as new_page_info:
|
||||||
|
self._click_row_action(desktop_name, "打开桌面")
|
||||||
|
|
||||||
|
new_page = new_page_info.value
|
||||||
|
logger.info(f"🌐 检测到桌面已在新标签页打开: {new_page.url}")
|
||||||
|
|
||||||
|
# 停留一会儿确保桌面加载,然后关掉它,回到控制台继续后续流程
|
||||||
|
time.sleep(60)
|
||||||
|
new_page.close()
|
||||||
|
logger.info("🔙 已关闭桌面标签页,返回控制台进行后续流程")
|
||||||
|
time.sleep(5)
|
||||||
|
|
||||||
|
def stop_desktop(self, desktop_name):
|
||||||
|
"""关机"""
|
||||||
|
self.wait_for_status(desktop_name, self.STATUS["RUNNING"])
|
||||||
|
logger.info(f"🎯 尝试关机桌面 {desktop_name}")
|
||||||
|
self._click_row_action(desktop_name, "关机")
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
def delete_desktop(self, desktop_name):
|
||||||
|
"""删除桌面"""
|
||||||
|
self.wait_for_status(desktop_name, self.STATUS["STOPPED"])
|
||||||
|
logger.info(f"🎯 尝试删除桌面 {desktop_name}")
|
||||||
|
self._click_row_action(desktop_name, "删除")
|
||||||
|
time.sleep(0.5)
|
||||||
|
self._confirm_dialog("确定删除")
|
||||||
|
time.sleep(2)
|
||||||
|
|
||||||
|
def _select_dropdown(self, label_text, option_text):
|
||||||
|
"""通用下拉框选择 (强力触发 + 强力点击)"""
|
||||||
|
logger.info(f"📋 下拉框 [{label_text}] 寻找目标: {option_text}")
|
||||||
|
|
||||||
|
# 1. 寻找触发器并点击 (多重探测逻辑)
|
||||||
|
triggered = self.page.evaluate(f"""(label) => {{
|
||||||
|
const dialog = document.querySelector('.p-dialog') || document.querySelector('[role="dialog"]') || document.body;
|
||||||
|
|
||||||
|
// A. 通过 placeholder 文案尝试直接点击最明显的选择框
|
||||||
|
const placeholders = ["请选择", "选择", label];
|
||||||
|
for (const p of placeholders) {{
|
||||||
|
const boxes = Array.from(dialog.querySelectorAll('.p-select, .p-dropdown, [role="combobox"]'));
|
||||||
|
const targetBox = boxes.find(b => b.innerText.includes(p) && b.offsetWidth > 0);
|
||||||
|
if (targetBox) {{
|
||||||
|
targetBox.scrollIntoView({{block: 'center'}});
|
||||||
|
targetBox.click();
|
||||||
|
return 'BOX_CLICKED';
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
|
||||||
|
// B. 通过 Label 文字向上查找再向下查找
|
||||||
|
const labels = Array.from(dialog.querySelectorAll('label, .form-item-label, span'));
|
||||||
|
const lbl = labels.find(el => el.innerText.trim().includes(label) && el.offsetWidth > 0);
|
||||||
|
if (lbl) {{
|
||||||
|
let p = lbl;
|
||||||
|
for (let i = 0; i < 5; i++) {{
|
||||||
|
const dd = p.querySelector('.p-select, .p-dropdown, [role="combobox"]');
|
||||||
|
if (dd) {{
|
||||||
|
dd.scrollIntoView({{block: 'center'}});
|
||||||
|
dd.click();
|
||||||
|
return 'LABEL_CLICKED';
|
||||||
|
}}
|
||||||
|
p = p.parentElement;
|
||||||
|
if (!p) break;
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
return 'NOT_FOUND';
|
||||||
|
}}""", label_text)
|
||||||
|
|
||||||
|
logger.info(f" 下拉框触发结果: {triggered}")
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
# 2. 在【专属容器】内寻找并匹配选项
|
||||||
|
success = False
|
||||||
|
keywords = [option_text]
|
||||||
|
if "其他" in option_text or "Other" in option_text:
|
||||||
|
keywords = ["其他", "其它", "Other", "Others", "other"]
|
||||||
|
|
||||||
|
# 尝试 2 次,给渲染留时间
|
||||||
|
for attempt in range(2):
|
||||||
|
clicked_res = self.page.evaluate(f"""(txts) => {{
|
||||||
|
const overlays = Array.from(document.querySelectorAll('.p-select-overlay, .p-select-panel, .p-dropdown-panel, .p-popover, .p-connected-overlay'));
|
||||||
|
const containers = overlays.length > 0 ? overlays : [document.body];
|
||||||
|
|
||||||
|
for (const container of containers) {{
|
||||||
|
const options = Array.from(container.querySelectorAll('li, [role="option"], .p-select-option, .p-dropdown-item'));
|
||||||
|
for (const opt of options) {{
|
||||||
|
const opText = (opt.innerText || opt.textContent).trim();
|
||||||
|
// 命中关键词
|
||||||
|
if (txts.some(t => opText === t || opText.includes(t))) {{
|
||||||
|
opt.scrollIntoView({{block: 'center', behavior: 'instant'}});
|
||||||
|
// 强力点击
|
||||||
|
opt.dispatchEvent(new MouseEvent('mousedown', {{ bubbles: true }}));
|
||||||
|
opt.dispatchEvent(new MouseEvent('mouseup', {{ bubbles: true }}));
|
||||||
|
opt.dispatchEvent(new MouseEvent('click', {{ bubbles: true }}));
|
||||||
|
return true;
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
return false;
|
||||||
|
}}""", keywords)
|
||||||
|
|
||||||
|
if clicked_res:
|
||||||
|
logger.info(f" ✅ 已成功选中目标选项")
|
||||||
|
success = True
|
||||||
|
break
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
if not success:
|
||||||
|
logger.error(f"❌ 流程卡在下拉框选项点击: {label_text} -> {option_text}")
|
||||||
|
# Playwright 原生点击兜底
|
||||||
|
try:
|
||||||
|
self.page.locator(f"li:has-text('{option_text}'), [role='option']:has-text('{option_text}')").last.click(timeout=1000)
|
||||||
|
success = True
|
||||||
|
except: pass
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
|
def _fill_tag_input(self, label_text, tag_value):
|
||||||
|
"""填写标签输入"""
|
||||||
|
logger.info(f"🏷️ 标签输入 [{label_text}]: {tag_value}")
|
||||||
|
# 找到标签区域的 "+" 按钮或输入框
|
||||||
|
found = self.page.evaluate("""(label) => {
|
||||||
|
const dialog = document.querySelector('.p-dialog') || document.querySelector('[role="dialog"]');
|
||||||
|
if (!dialog) return 'NO_DIALOG';
|
||||||
|
const labels = Array.from(dialog.querySelectorAll('label, span, div'));
|
||||||
|
const lbl = labels.find(el => el.innerText.trim().includes(label) && el.offsetWidth > 0);
|
||||||
|
if (!lbl) return 'NO_LABEL';
|
||||||
|
let container = lbl;
|
||||||
|
for (let i = 0; i < 6; i++) {
|
||||||
|
if (!container) break;
|
||||||
|
const addBtn = container.querySelector('button, .p-button, [role="button"]');
|
||||||
|
if (addBtn && addBtn.offsetWidth > 0) { addBtn.click(); return 'FOUND'; }
|
||||||
|
container = container.parentElement;
|
||||||
|
}
|
||||||
|
return 'NO_BTN';
|
||||||
|
}""", label_text)
|
||||||
|
logger.info(f" 标签输入框查找结果: {found}")
|
||||||
|
time.sleep(0.8)
|
||||||
|
|
||||||
|
# "+" 按钮点击后应该出现 input,填入标签值并回车
|
||||||
|
tag_filled = self.page.evaluate("""(tag) => {
|
||||||
|
const dialog = document.querySelector('.p-dialog') || document.querySelector('[role="dialog"]');
|
||||||
|
if (!dialog) return 'NO_DIALOG';
|
||||||
|
const inputs = Array.from(dialog.querySelectorAll('input[type="text"], input:not([type])'))
|
||||||
|
.filter(inp => !inp.readOnly && !inp.disabled && inp.offsetWidth > 0);
|
||||||
|
for (let i = inputs.length - 1; i >= 0; i--) {
|
||||||
|
const inp = inputs[i];
|
||||||
|
if (inp.value === '' || inp.placeholder?.includes('标签') || inp.placeholder?.includes('tag')) {
|
||||||
|
const nativeSet = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set;
|
||||||
|
nativeSet.call(inp, tag);
|
||||||
|
inp.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
inp.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', bubbles: true }));
|
||||||
|
inp.dispatchEvent(new KeyboardEvent('keyup', { key: 'Enter', code: 'Enter', bubbles: true }));
|
||||||
|
return 'FILLED';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 'NO_TAG_INPUT';
|
||||||
|
}""", tag_value)
|
||||||
|
logger.info(f" 标签输入结果: {tag_filled}")
|
||||||
|
|
||||||
|
if tag_filled != 'FILLED':
|
||||||
|
try:
|
||||||
|
tag_input = self.page.locator('.p-dialog input').last
|
||||||
|
tag_input.fill(tag_value)
|
||||||
|
tag_input.press("Enter")
|
||||||
|
except:
|
||||||
|
logger.warning("⚠️ 标签输入兜底也失败")
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
|
def save_image(self, desktop_name):
|
||||||
|
"""保存镜像 (完整弹窗交互)"""
|
||||||
|
self.wait_for_status(desktop_name, self.STATUS["RUNNING"])
|
||||||
|
logger.info(f"🎯 尝试保存桌面 {desktop_name} 为镜像")
|
||||||
|
|
||||||
|
self._click_row_action(desktop_name, "保存镜像")
|
||||||
|
time.sleep(1.5)
|
||||||
|
|
||||||
|
logger.info("📝 步骤1: 输入镜像名称")
|
||||||
|
self.smart_fill("请输入镜像名称", f"AutoImage_{desktop_name}")
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
|
logger.info("📝 步骤2: 选择任务类型")
|
||||||
|
self._select_dropdown("任务类型", "其他")
|
||||||
|
|
||||||
|
logger.info("📝 步骤3: 输入镜像标签")
|
||||||
|
self._fill_tag_input("镜像标签", "auto-test")
|
||||||
|
|
||||||
|
logger.info("📝 步骤4: 提交保存")
|
||||||
|
self.smart_click("确定", role="button", timeout=5000)
|
||||||
|
time.sleep(3)
|
||||||
|
|
||||||
|
def _fill_tag_input(self, label_text, tag_value):
|
||||||
|
"""填写标签输入"""
|
||||||
|
logger.info(f"🏷️ 标签输入 [{label_text}]: {tag_value}")
|
||||||
|
# 找到标签区域的 "+" 按钮或输入框
|
||||||
|
found = self.page.evaluate("""(label) => {
|
||||||
|
const dialog = document.querySelector('.p-dialog') || document.querySelector('[role="dialog"]');
|
||||||
|
if (!dialog) return 'NO_DIALOG';
|
||||||
|
const labels = Array.from(dialog.querySelectorAll('label, span, div'));
|
||||||
|
const lbl = labels.find(el => el.innerText.trim().includes(label) && el.offsetWidth > 0);
|
||||||
|
if (!lbl) return 'NO_LABEL';
|
||||||
|
let container = lbl;
|
||||||
|
for (let i = 0; i < 6; i++) {
|
||||||
|
if (!container) break;
|
||||||
|
const addBtn = container.querySelector('button, .p-button, [role="button"]');
|
||||||
|
if (addBtn && addBtn.offsetWidth > 0) { addBtn.click(); return 'FOUND'; }
|
||||||
|
container = container.parentElement;
|
||||||
|
}
|
||||||
|
return 'NO_BTN';
|
||||||
|
}""", label_text)
|
||||||
|
logger.info(f" 标签输入框查找结果: {found}")
|
||||||
|
time.sleep(0.8)
|
||||||
|
|
||||||
|
# "+" 按钮点击后应该出现 input,填入标签值并回车
|
||||||
|
tag_filled = self.page.evaluate("""(tag) => {
|
||||||
|
const dialog = document.querySelector('.p-dialog') || document.querySelector('[role="dialog"]');
|
||||||
|
if (!dialog) return 'NO_DIALOG';
|
||||||
|
const inputs = Array.from(dialog.querySelectorAll('input[type="text"], input:not([type])'))
|
||||||
|
.filter(inp => !inp.readOnly && !inp.disabled && inp.offsetWidth > 0);
|
||||||
|
for (let i = inputs.length - 1; i >= 0; i--) {
|
||||||
|
const inp = inputs[i];
|
||||||
|
if (inp.value === '' || inp.placeholder?.includes('标签') || inp.placeholder?.includes('tag')) {
|
||||||
|
const nativeSet = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set;
|
||||||
|
nativeSet.call(inp, tag);
|
||||||
|
inp.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
inp.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', bubbles: true }));
|
||||||
|
inp.dispatchEvent(new KeyboardEvent('keyup', { key: 'Enter', code: 'Enter', bubbles: true }));
|
||||||
|
return 'FILLED';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 'NO_TAG_INPUT';
|
||||||
|
}""", tag_value)
|
||||||
|
logger.info(f" 标签输入结果: {tag_filled}")
|
||||||
|
|
||||||
|
if tag_filled != 'FILLED':
|
||||||
|
try:
|
||||||
|
tag_input = self.page.locator('.p-dialog input').last
|
||||||
|
tag_input.fill(tag_value)
|
||||||
|
tag_input.press("Enter")
|
||||||
|
except:
|
||||||
|
logger.warning("⚠️ 标签输入兜底也失败")
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
|
def save_image(self, desktop_name):
|
||||||
|
"""保存镜像 (完整弹窗交互)"""
|
||||||
|
self.wait_for_status(desktop_name, self.STATUS["RUNNING"])
|
||||||
|
logger.info(f"🎯 尝试保存桌面 {desktop_name} 为镜像")
|
||||||
|
|
||||||
|
self._click_row_action(desktop_name, "保存镜像")
|
||||||
|
time.sleep(1.5)
|
||||||
|
|
||||||
|
logger.info("📝 步骤1: 输入镜像名称")
|
||||||
|
self.smart_fill("请输入镜像名称", f"AutoImage_{desktop_name}")
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
|
logger.info("📝 步骤2: 选择任务类型")
|
||||||
|
self._select_dropdown("任务类型", "其他")
|
||||||
|
|
||||||
|
logger.info("📝 步骤3: 输入镜像标签")
|
||||||
|
self._fill_tag_input("镜像标签", "auto-test")
|
||||||
|
|
||||||
|
logger.info("📝 步骤4: 提交保存")
|
||||||
|
self.smart_click("确定", role="button", timeout=5000)
|
||||||
|
time.sleep(3)
|
||||||
125
framework/business/data_management.py
Normal file
125
framework/business/data_management.py
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
from framework.config.settings import Config
|
||||||
|
from framework.core.logger import get_logger
|
||||||
|
from framework.business.login_page import LoginPage
|
||||||
|
from framework.business.file_manager_page import FileManagerPage
|
||||||
|
from framework.business.dev_machine_page import DevMachinePage
|
||||||
|
from framework.business.cloud_desktop_page import CloudDesktopPage
|
||||||
|
|
||||||
|
# 引入抽离出来的业务场景
|
||||||
|
from framework.scripts.file_system_scenario import run_full_file_lifecycle
|
||||||
|
from framework.scripts.compute_resource_scenario import run_dev_machine_lifecycle
|
||||||
|
from framework.scripts.desktop_lifecycle import run_cloud_desktop_lifecycle
|
||||||
|
from framework.scripts.mirror_assets import run_mirror_assets_lifecycle
|
||||||
|
from framework.business.mirror_assets_page import MirrorAssetsPage
|
||||||
|
|
||||||
|
import time
|
||||||
|
import os
|
||||||
|
|
||||||
|
logger = get_logger("DataManagementRunner")
|
||||||
|
|
||||||
|
class DataManagement:
|
||||||
|
"""符合 PO 模式的场景指挥官"""
|
||||||
|
|
||||||
|
def __init__(self, headless=False):
|
||||||
|
self.ui = LoginPage(headless=headless)
|
||||||
|
self.page = None
|
||||||
|
self.fm = None
|
||||||
|
self.dm = None
|
||||||
|
self.cd = None
|
||||||
|
self.ma = None
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
"""启动浏览器并初始化组件"""
|
||||||
|
self.page = self.ui.start()
|
||||||
|
# 初始化页面原子对象
|
||||||
|
self.fm = FileManagerPage(self.page)
|
||||||
|
self.dm = DevMachinePage(self.page)
|
||||||
|
self.cd = CloudDesktopPage(self.page)
|
||||||
|
self.ma = MirrorAssetsPage(self.page)
|
||||||
|
|
||||||
|
def login(self, user, pwd):
|
||||||
|
"""执行登录流程"""
|
||||||
|
return self.ui.login(user, pwd)
|
||||||
|
|
||||||
|
def _safe_screenshot(self, name):
|
||||||
|
"""安全截图,防止浏览器已关闭时报错"""
|
||||||
|
try:
|
||||||
|
if self.page:
|
||||||
|
self.page.screenshot(path=name)
|
||||||
|
except:
|
||||||
|
logger.warning(f"⚠️ 截图失败(浏览器可能已关闭): {name}")
|
||||||
|
|
||||||
|
def run_all_scenarios(self):
|
||||||
|
"""
|
||||||
|
场景指挥:依次执行所有的业务流
|
||||||
|
每个场景独立 try-except,一个失败不阻塞后续场景
|
||||||
|
"""
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
# 1. 执行文件系统场景 (跳过)
|
||||||
|
try:
|
||||||
|
run_full_file_lifecycle(self.fm, Config.FOLDER_NAME)
|
||||||
|
self._safe_screenshot("file_system_final.png")
|
||||||
|
logger.info("✅ 文件系统场景通过")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ 文件系统场景失败: {e}")
|
||||||
|
self._safe_screenshot("file_system_error.png")
|
||||||
|
errors.append(f"文件系统: {e}")
|
||||||
|
|
||||||
|
# 2. 执行开发机场景 (跳过)
|
||||||
|
try:
|
||||||
|
run_dev_machine_lifecycle(self.dm)
|
||||||
|
self._safe_screenshot("dev_machine_final.png")
|
||||||
|
logger.info("✅ 开发机场景通过")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ 开发机场景失败: {e}")
|
||||||
|
self._safe_screenshot("dev_machine_error.png")
|
||||||
|
errors.append(f"开发机: {e}")
|
||||||
|
|
||||||
|
# 3. 执行云桌面场景
|
||||||
|
try:
|
||||||
|
run_cloud_desktop_lifecycle(self.cd)
|
||||||
|
self._safe_screenshot("cloud_desktop_final.png")
|
||||||
|
logger.info("✅ 云桌面场景通过")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ 云桌面场景失败: {e}")
|
||||||
|
self._safe_screenshot("cloud_desktop_error.png")
|
||||||
|
errors.append(f"云桌面: {e}")
|
||||||
|
|
||||||
|
# 4. 执行镜像资产场景
|
||||||
|
try:
|
||||||
|
run_mirror_assets_lifecycle(self.ma, self.cd)
|
||||||
|
self._safe_screenshot("mirror_assets_final.png")
|
||||||
|
logger.info("✅ 镜像资产场景通过")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ 镜像资产场景失败: {e}")
|
||||||
|
self._safe_screenshot("mirror_assets_error.png")
|
||||||
|
errors.append(f"镜像资产: {e}")
|
||||||
|
|
||||||
|
# 汇总
|
||||||
|
if errors:
|
||||||
|
summary = " | ".join(errors)
|
||||||
|
logger.error(f"❌ {len(errors)} 个场景失败: {summary}")
|
||||||
|
raise Exception(f"{len(errors)} 个场景失败: {summary}")
|
||||||
|
|
||||||
|
logger.info("🎉 所有 UI 模块遍历测试圆满完成!")
|
||||||
|
|
||||||
|
def run(self, user, pwd):
|
||||||
|
"""主入口"""
|
||||||
|
try:
|
||||||
|
self.start()
|
||||||
|
if not self.login(user, pwd):
|
||||||
|
return
|
||||||
|
|
||||||
|
# 开始执行指挥任务
|
||||||
|
self.run_all_scenarios()
|
||||||
|
|
||||||
|
finally:
|
||||||
|
self.ui.stop()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
account = input("账号: ")
|
||||||
|
password = input("密码: ")
|
||||||
|
|
||||||
|
dm = DataManagement(headless=False)
|
||||||
|
dm.run(account, password)
|
||||||
215
framework/business/dev_machine_page.py
Normal file
215
framework/business/dev_machine_page.py
Normal file
@ -0,0 +1,215 @@
|
|||||||
|
import time
|
||||||
|
from framework.core.base_page import BasePage
|
||||||
|
from framework.core.logger import get_logger
|
||||||
|
|
||||||
|
logger = get_logger("DevMachinePage")
|
||||||
|
|
||||||
|
class DevMachinePage(BasePage):
|
||||||
|
"""开发机页面 POM - 纯原子操作层"""
|
||||||
|
|
||||||
|
MENU_TEXT = "开发机"
|
||||||
|
URL_FRAGMENT = "/dev-machine"
|
||||||
|
|
||||||
|
def __init__(self, page):
|
||||||
|
self.page = page
|
||||||
|
|
||||||
|
def navigate_to(self):
|
||||||
|
"""进入开发机管理页面 (含强力跳转)"""
|
||||||
|
logger.info("正在切换到【开发机】页面...")
|
||||||
|
if self.URL_FRAGMENT in self.page.url:
|
||||||
|
return
|
||||||
|
|
||||||
|
for i in range(3):
|
||||||
|
self.page.evaluate("document.querySelectorAll('.p-dialog-mask').forEach(el => el.style.display='none')")
|
||||||
|
self.page.evaluate(f"""() => {{
|
||||||
|
const target = Array.from(document.querySelectorAll('.p-menuitem-link, a, span'))
|
||||||
|
.find(el => el.innerText.trim() === '{self.MENU_TEXT}');
|
||||||
|
if (target) target.click();
|
||||||
|
}}""")
|
||||||
|
time.sleep(3)
|
||||||
|
if self.URL_FRAGMENT in self.page.url:
|
||||||
|
logger.info("✅ 成功进入开发机页面")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 强制重定向兜底
|
||||||
|
if self.URL_FRAGMENT not in self.page.url:
|
||||||
|
target_url = self.page.url.split('#')[0].split('?')[0].replace('/file-manager', self.URL_FRAGMENT)
|
||||||
|
self.page.goto(target_url)
|
||||||
|
time.sleep(3)
|
||||||
|
|
||||||
|
def get_first_machine_status(self):
|
||||||
|
"""查询首台实例状态"""
|
||||||
|
try:
|
||||||
|
self.page.wait_for_selector("tr", timeout=5000)
|
||||||
|
status = self.page.evaluate("""() => {
|
||||||
|
const rows = document.querySelectorAll('tr');
|
||||||
|
if (rows.length < 2) return 'None';
|
||||||
|
const cells = rows[1].querySelectorAll('td');
|
||||||
|
return cells.length > 2 ? cells[2].innerText.trim() : 'Unknown';
|
||||||
|
}""")
|
||||||
|
logger.info(f"✅ 探测到首台开发机状态: {status}")
|
||||||
|
return status
|
||||||
|
except:
|
||||||
|
return "Empty"
|
||||||
|
|
||||||
|
def open_apply_dialog(self):
|
||||||
|
"""点击申请按钮"""
|
||||||
|
logger.info("👉 点击 [申请开发机] 按钮")
|
||||||
|
self.smart_click("申请开发机")
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
def fill_name(self, name):
|
||||||
|
"""填写开发机名称"""
|
||||||
|
logger.info(f"⌨️ 正在输入名称: {name}")
|
||||||
|
self.smart_fill("名称", name)
|
||||||
|
|
||||||
|
def select_sku(self, sku_id):
|
||||||
|
"""选择配置 SKU"""
|
||||||
|
logger.info(f"🎯 尝试选择 SKU: {sku_id}")
|
||||||
|
self.page.evaluate(f"""(id) => {{
|
||||||
|
const input = document.getElementById(id + '-input') || document.getElementById(id);
|
||||||
|
if (input) {{ input.click(); return true; }}
|
||||||
|
return false;
|
||||||
|
}}""", sku_id)
|
||||||
|
|
||||||
|
def select_image(self, image_keyword="CUDA"):
|
||||||
|
"""选择镜像"""
|
||||||
|
logger.info(f"💿 正在选择镜像: {image_keyword}")
|
||||||
|
self.smart_click("选择镜像")
|
||||||
|
time.sleep(1)
|
||||||
|
self.smart_click(image_keyword)
|
||||||
|
time.sleep(1)
|
||||||
|
self.smart_click("确定", role="button")
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
def fill_ssh_key(self, ssh_key):
|
||||||
|
"""填写 SSH 公钥 (使用增强加固后的智能填写)"""
|
||||||
|
logger.info(f"⌨️ 正在输入 SSH 公钥")
|
||||||
|
self.smart_fill("SSH公钥", ssh_key)
|
||||||
|
|
||||||
|
def fill_system_disk(self, size="100"):
|
||||||
|
"""填写系统盘大小"""
|
||||||
|
logger.info(f"⌨️ 正在输入系统盘大小: {size}")
|
||||||
|
self.smart_fill("请输入系统盘大小", size)
|
||||||
|
|
||||||
|
def submit_application(self):
|
||||||
|
"""点击申请创建按钮"""
|
||||||
|
logger.info("🚀 提交申请创建")
|
||||||
|
self.smart_click("申请创建")
|
||||||
|
time.sleep(2)
|
||||||
|
|
||||||
|
def get_machine_status(self, machine_name):
|
||||||
|
"""查找指定名称机器的真实行文字 (精准锚点)"""
|
||||||
|
return self.page.evaluate(f"""(name) => {{
|
||||||
|
// 优先查找表格行
|
||||||
|
const rows = Array.from(document.querySelectorAll('tr'));
|
||||||
|
const dataRow = rows.find(r => {{
|
||||||
|
const text = r.innerText;
|
||||||
|
// 必须包含名字,且为了避免误中面包屑,行内字符数应该较多
|
||||||
|
return text.includes(name) && text.trim().length > name.length + 5;
|
||||||
|
}});
|
||||||
|
|
||||||
|
if (dataRow) return dataRow.innerText.trim();
|
||||||
|
|
||||||
|
// 如果没找到表格行,尝试找包含该名字的最近的一个容器类元素
|
||||||
|
const anyRow = Array.from(document.querySelectorAll('.p-datatable-row, .p-selectable-row'))
|
||||||
|
.find(r => r.innerText.includes(name));
|
||||||
|
|
||||||
|
return anyRow ? anyRow.innerText.trim() : 'Not Found';
|
||||||
|
}}""", machine_name)
|
||||||
|
|
||||||
|
def wait_for_status(self, name, expected_status, timeout=600):
|
||||||
|
"""带心跳的状态等待器 (增强版)"""
|
||||||
|
logger.info(f"⏳ 等待开发机 {name} 状态变为: {expected_status}...")
|
||||||
|
start = time.time()
|
||||||
|
last_log = 0
|
||||||
|
while time.time() - start < timeout:
|
||||||
|
current = self.get_machine_status(name)
|
||||||
|
|
||||||
|
# 命中状态
|
||||||
|
if expected_status in current:
|
||||||
|
logger.info(f"✅ 状态达标: {current}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
# 心跳日志
|
||||||
|
if time.time() - last_log > 10:
|
||||||
|
# 截取前 50 个字符避免日志过长
|
||||||
|
snippet = (current[:50] + '...') if len(current) > 50 else current
|
||||||
|
logger.info(f" [状态巡检] {int(time.time()-start)}s | 当前实时内容: {snippet}")
|
||||||
|
last_log = time.time()
|
||||||
|
|
||||||
|
time.sleep(5)
|
||||||
|
raise TimeoutError(f"超时: 巡检 400s 仍未发现关键字 [{expected_status}]。当前最后看到的内容: {current}")
|
||||||
|
|
||||||
|
def _click_row_action(self, machine_name, action_label):
|
||||||
|
"""通用:点击指定机器行内的操作按钮(关机/删除等)"""
|
||||||
|
logger.info(f"👉 在 {machine_name} 行内寻找并点击 [{action_label}] 按钮")
|
||||||
|
|
||||||
|
# 策略1:在包含该机器名的行内,找 aria-label 匹配的按钮
|
||||||
|
clicked = self.page.evaluate(f"""(args) => {{
|
||||||
|
const [name, action] = args;
|
||||||
|
const rows = Array.from(document.querySelectorAll('tr'));
|
||||||
|
const row = rows.find(r => r.innerText.includes(name) && r.querySelectorAll('td').length > 0);
|
||||||
|
if (!row) return 'NO_ROW';
|
||||||
|
|
||||||
|
// 尝试 aria-label 匹配
|
||||||
|
let btn = row.querySelector(`button[aria-label="${{action}}"], [aria-label="${{action}}"]`);
|
||||||
|
|
||||||
|
// 尝试按钮文本匹配
|
||||||
|
if (!btn) {{
|
||||||
|
btn = Array.from(row.querySelectorAll('button, .p-button, a, span.p-button-label'))
|
||||||
|
.find(el => el.innerText.trim() === action ||
|
||||||
|
(el.getAttribute('aria-label') || '').includes(action));
|
||||||
|
}}
|
||||||
|
|
||||||
|
if (btn) {{
|
||||||
|
btn.scrollIntoView({{block: 'center'}});
|
||||||
|
btn.click();
|
||||||
|
return 'CLICKED';
|
||||||
|
}}
|
||||||
|
return 'NO_BTN';
|
||||||
|
}}""", [machine_name, action_label])
|
||||||
|
|
||||||
|
logger.info(f" 行内按钮点击结果: {clicked}")
|
||||||
|
|
||||||
|
if clicked == 'NO_ROW':
|
||||||
|
raise Exception(f"❌ 表格中未找到包含 '{machine_name}' 的行")
|
||||||
|
if clicked == 'NO_BTN':
|
||||||
|
# 最后兜底:用 Playwright 原生按钮定位
|
||||||
|
logger.info(f" JS未命中,尝试 Playwright role 定位...")
|
||||||
|
self.page.get_by_role("button", name=action_label).first.click()
|
||||||
|
|
||||||
|
def _confirm_dialog(self):
|
||||||
|
"""确认弹窗"""
|
||||||
|
time.sleep(0.5)
|
||||||
|
try:
|
||||||
|
# PrimeVue 确认弹窗
|
||||||
|
confirm = self.page.locator('.p-confirm-dialog-accept, .p-dialog-footer button.p-button-danger, .p-dialog-footer button.p-button-primary').first
|
||||||
|
confirm.wait_for(state="visible", timeout=3000)
|
||||||
|
confirm.click()
|
||||||
|
except:
|
||||||
|
try:
|
||||||
|
self.smart_click("确定", role="button", timeout=3000)
|
||||||
|
except:
|
||||||
|
self.page.evaluate("""() => {
|
||||||
|
const btn = Array.from(document.querySelectorAll('button'))
|
||||||
|
.find(b => b.innerText.includes('确定') || b.innerText.includes('确认'));
|
||||||
|
btn?.click();
|
||||||
|
}""")
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
def delete_machine(self, machine_name):
|
||||||
|
"""删除开发机"""
|
||||||
|
self.wait_for_status(machine_name, "已关机")
|
||||||
|
logger.info(f"🎯 尝试删除开发机 {machine_name}")
|
||||||
|
self._click_row_action(machine_name, "删除")
|
||||||
|
self._confirm_dialog()
|
||||||
|
time.sleep(2)
|
||||||
|
|
||||||
|
def stop_machine(self, machine_name):
|
||||||
|
"""停止/关机 开发机"""
|
||||||
|
self.wait_for_status(machine_name, "运行中")
|
||||||
|
logger.info(f"🎯 尝试下发关机指令: {machine_name}")
|
||||||
|
self._click_row_action(machine_name, "关机")
|
||||||
|
self._confirm_dialog()
|
||||||
|
time.sleep(2)
|
||||||
118
framework/business/file_manager_page.py
Normal file
118
framework/business/file_manager_page.py
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
import time
|
||||||
|
import os
|
||||||
|
from framework.core.base_page import BasePage
|
||||||
|
from framework.core.logger import get_logger
|
||||||
|
|
||||||
|
logger = get_logger("FileManagerPage")
|
||||||
|
|
||||||
|
class FileManagerPage(BasePage):
|
||||||
|
"""文件管理页面 POM - 纯原子操作层"""
|
||||||
|
|
||||||
|
MENU_TEXT = "数据管理"
|
||||||
|
|
||||||
|
def __init__(self, page):
|
||||||
|
self.page = page
|
||||||
|
|
||||||
|
def navigate_to(self):
|
||||||
|
"""进入页面"""
|
||||||
|
logger.info("正在切换到数据管理页面...")
|
||||||
|
self.smart_click(self.MENU_TEXT)
|
||||||
|
# 等待页面核心元素出现,确保切换完成
|
||||||
|
try:
|
||||||
|
self.page.wait_for_selector(".p-datatable, button:has-text('新建文件夹')", timeout=5000)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
time.sleep(2)
|
||||||
|
|
||||||
|
def create_folder(self, name):
|
||||||
|
"""原子:创建文件夹"""
|
||||||
|
logger.info(f"📁 新建文件夹: {name}")
|
||||||
|
self.smart_click("新建文件夹")
|
||||||
|
self.page.fill("#folderName", name)
|
||||||
|
self.smart_click("确定", role="button")
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
def enter_folder(self, name):
|
||||||
|
"""原子:双击进入文件夹"""
|
||||||
|
logger.info(f"📂 进入文件夹: {name}")
|
||||||
|
self.page.click(f"text='{name}'")
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
def upload_files(self, file_paths):
|
||||||
|
"""原子:触发上传并选择文件"""
|
||||||
|
if isinstance(file_paths, str): file_paths = [file_paths]
|
||||||
|
logger.info(f"📤 准备上传文件: {file_paths}")
|
||||||
|
self.smart_click("上传")
|
||||||
|
time.sleep(0.5)
|
||||||
|
self.page.set_input_files("input[type='file']", file_paths)
|
||||||
|
|
||||||
|
def cancel_upload(self, slow_mode=True):
|
||||||
|
"""原子:在上传中途取消"""
|
||||||
|
logger.info("⏹️ 尝试取消上传任务")
|
||||||
|
if slow_mode: time.sleep(1)
|
||||||
|
try:
|
||||||
|
# 缩短超时时间,取消操作不应阻塞太久
|
||||||
|
self.smart_click("取消上传", timeout=1500)
|
||||||
|
except:
|
||||||
|
# 兜底:尝试点关闭图标或按 ESC
|
||||||
|
try:
|
||||||
|
self.page.evaluate("document.querySelector('button.p-dialog-header-close')?.click()")
|
||||||
|
self.page.keyboard.press("Escape")
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
def wait_for_success(self, count=1):
|
||||||
|
"""原子:等待上传进度条跑完"""
|
||||||
|
logger.info(f"⏳ 等待进度达成 ({count})...")
|
||||||
|
start = time.time()
|
||||||
|
while time.time() - start < 300:
|
||||||
|
content = self.page.evaluate("() => document.querySelector('.p-dialog')?.innerText || ''")
|
||||||
|
if ("成功" in content and str(count) in content) or f"{count}/{count}" in content:
|
||||||
|
break
|
||||||
|
time.sleep(2)
|
||||||
|
time.sleep(2)
|
||||||
|
self.page.evaluate("document.querySelector('button.p-dialog-header-close')?.click()")
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
def rename_item(self, old_name, new_name):
|
||||||
|
"""原子:执行行内重命名"""
|
||||||
|
logger.info(f"✏️ 重命名: {old_name} -> {new_name}")
|
||||||
|
self.page.evaluate(f"""() => {{
|
||||||
|
const row = Array.from(document.querySelectorAll('tr')).find(r => r.innerText.includes('{old_name}'));
|
||||||
|
row?.querySelector('button[aria-label="重命名"], .p-button-text')?.click();
|
||||||
|
}}""")
|
||||||
|
time.sleep(0.5)
|
||||||
|
self.page.fill("input[placeholder*='名称']", new_name)
|
||||||
|
# 强力点击确定 (支持文本、类名、角色)
|
||||||
|
self.smart_click("确定", role="button")
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
def delete_item(self, name):
|
||||||
|
"""原子:执行行内删除"""
|
||||||
|
logger.info(f"🗑️ 删除: {name}")
|
||||||
|
self.page.evaluate(f"""() => {{
|
||||||
|
const row = Array.from(document.querySelectorAll('tr')).find(r => r.innerText.includes('{name}'));
|
||||||
|
if (!row) return;
|
||||||
|
const delBtn = row.querySelector('button[aria-label="删除"]') ||
|
||||||
|
Array.from(row.querySelectorAll('button')).find(b => b.innerText.includes('删除'));
|
||||||
|
delBtn?.click();
|
||||||
|
}}""")
|
||||||
|
time.sleep(1) # 等待弹窗动画完成
|
||||||
|
|
||||||
|
# 针对确认框的多种可能属性进行强力点击
|
||||||
|
try:
|
||||||
|
self.smart_click("确定", role="button", timeout=3000)
|
||||||
|
except:
|
||||||
|
# 兜底:直接寻找 PrimeVue 或 通用确认框的“接受”按钮类
|
||||||
|
self.page.evaluate("""() => {
|
||||||
|
const ok = document.querySelector('.p-confirm-dialog-accept, .p-dialog-footer button:last-child, .btn-primary');
|
||||||
|
if (ok) ok.click();
|
||||||
|
}""")
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
def back_to_root(self):
|
||||||
|
"""原子:通过面包屑返回"""
|
||||||
|
logger.info("🔙 返回根目录")
|
||||||
|
self.smart_click("数据管理", timeout=2000)
|
||||||
|
time.sleep(1)
|
||||||
53
framework/business/login_page.py
Normal file
53
framework/business/login_page.py
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
from framework.core.base_ui import BaseUI
|
||||||
|
from framework.config.settings import Config
|
||||||
|
from framework.core.logger import get_logger
|
||||||
|
import time
|
||||||
|
|
||||||
|
logger = get_logger("LoginPage")
|
||||||
|
|
||||||
|
class LoginPage(BaseUI):
|
||||||
|
# --- 元素定位器 ---
|
||||||
|
USERNAME_INPUT = "input[placeholder*='账号'], #account"
|
||||||
|
PASSWORD_INPUT = "input[placeholder*='密码'], #password"
|
||||||
|
LOGIN_BUTTON = "button.loginBtn"
|
||||||
|
|
||||||
|
SUCCESS_INDICATOR = ".ant-layout-sider, .user-name"
|
||||||
|
|
||||||
|
def __init__(self, headless=False):
|
||||||
|
super().__init__(headless=headless)
|
||||||
|
|
||||||
|
def login(self, username, password):
|
||||||
|
"""执行登录流程"""
|
||||||
|
# 直接使用 Config.LOGIN_URL
|
||||||
|
login_url = Config.LOGIN_URL
|
||||||
|
self.navigate(login_url)
|
||||||
|
|
||||||
|
logger.info("等待登录页面加载...")
|
||||||
|
self.wait_for_selector(self.USERNAME_INPUT)
|
||||||
|
|
||||||
|
self.fill(self.USERNAME_INPUT, username)
|
||||||
|
self.fill(self.PASSWORD_INPUT, password)
|
||||||
|
|
||||||
|
logger.info("正在点击登录按钮...")
|
||||||
|
time.sleep(1)
|
||||||
|
self.click(self.LOGIN_BUTTON)
|
||||||
|
|
||||||
|
logger.info("登录请求已发送,等待进入系统...")
|
||||||
|
try:
|
||||||
|
# 1. 强制等待关键 UI 元素出现 (证明已经跳过 SSO 到了主页)
|
||||||
|
self.page.wait_for_selector(self.SUCCESS_INDICATOR, timeout=10000)
|
||||||
|
|
||||||
|
# 2. 二次确认 URL 是否已经离开 SSO,回到业务主域名
|
||||||
|
if "robogo-fat" in self.page.url:
|
||||||
|
logger.info(f"🎉 登录流程圆满成功!进入主系统 URL: {self.page.url}")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"等待主站 UI 加载超时 (可能是 SSO 跳转慢): {e}")
|
||||||
|
|
||||||
|
# 兜底:如果还是在 login 或 sso 页面则失败
|
||||||
|
if "/login" in self.page.url or "sso" in self.page.url:
|
||||||
|
self.page.screenshot(path="login_failed.png")
|
||||||
|
logger.error(f"❌ 登录认证失败,目前停留在: {self.page.url}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
202
framework/business/mirror_assets_page.py
Normal file
202
framework/business/mirror_assets_page.py
Normal file
@ -0,0 +1,202 @@
|
|||||||
|
import time
|
||||||
|
from framework.core.base_page import BasePage
|
||||||
|
from framework.core.logger import get_logger
|
||||||
|
from framework.config.settings import Config
|
||||||
|
|
||||||
|
logger = get_logger("MirrorAssetsPage")
|
||||||
|
|
||||||
|
class MirrorAssetsPage(BasePage):
|
||||||
|
"""镜像资产页面操作"""
|
||||||
|
|
||||||
|
def __init__(self, page):
|
||||||
|
super().__init__(page)
|
||||||
|
|
||||||
|
def navigate_to(self):
|
||||||
|
"""导航到镜像资产页面"""
|
||||||
|
logger.info("🚀 导航到镜像资产页面")
|
||||||
|
# 根据截图修正 URL
|
||||||
|
self.page.goto(f"{Config.BASE_URL}/mirror-center/private")
|
||||||
|
time.sleep(3)
|
||||||
|
|
||||||
|
def click_my_mirror(self):
|
||||||
|
"""进入镜像资产并确保切换到 [我的镜像] (Prod 强化版)"""
|
||||||
|
logger.info("👉 准备切换至 [我的镜像] 标签")
|
||||||
|
|
||||||
|
# 1. 等待页面基础渲染
|
||||||
|
timeout = 15
|
||||||
|
start_time = time.time()
|
||||||
|
while time.time() - start_time < timeout:
|
||||||
|
# 尝试定位任何包含“我的镜像”的可见元素
|
||||||
|
loc = self.page.locator("text='我的镜像'").filter(has_not=self.page.locator(".p-hidden")).first
|
||||||
|
if loc.is_visible():
|
||||||
|
loc.click()
|
||||||
|
logger.info("✅ 已点击 [我的镜像]")
|
||||||
|
break
|
||||||
|
time.sleep(1)
|
||||||
|
else:
|
||||||
|
logger.error(f"❌ 15s 后仍未看到 [我的镜像] 标签。URL: {self.page.url}")
|
||||||
|
# 这里的 JS 诊断会更彻底一些
|
||||||
|
diag_log = self.page.evaluate("""() => {
|
||||||
|
const all = Array.from(document.querySelectorAll('*'));
|
||||||
|
return all.filter(el => el.innerText && el.innerText.includes('镜像') && el.offsetWidth > 0)
|
||||||
|
.map(el => ({ tag: el.tagName, text: el.innerText.trim().slice(0, 20), id: el.id, class: el.className }))
|
||||||
|
.slice(0, 10);
|
||||||
|
}""")
|
||||||
|
logger.info(f"🔍 故障诊断 - 包含'镜像'关键词的可见元素: {diag_log}")
|
||||||
|
raise Exception("未能定位到 [我的镜像] 标签页")
|
||||||
|
|
||||||
|
# 2. 确认切换成功 (等待高亮)
|
||||||
|
try:
|
||||||
|
self.page.wait_for_selector(".p-highlight, [aria-selected='true'], .active", timeout=5000)
|
||||||
|
logger.info("✅ 标签页高亮确认成功")
|
||||||
|
except:
|
||||||
|
logger.warning("⚠️ 未能确认高亮状态,但已尝试点击")
|
||||||
|
|
||||||
|
# 3. 记录当前 Tab 状态
|
||||||
|
tabs = self.page.evaluate("""() => {
|
||||||
|
return Array.from(document.querySelectorAll('li, a, span, div'))
|
||||||
|
.filter(el => ['我的镜像', '群组镜像'].some(t => el.innerText?.includes(t)) && el.offsetWidth > 0)
|
||||||
|
.map(el => el.innerText.trim().slice(0,10));
|
||||||
|
}""")
|
||||||
|
logger.info(f"📊 当前侦测到的标签页: {list(set(tabs))}")
|
||||||
|
time.sleep(2)
|
||||||
|
|
||||||
|
def click_add_button(self):
|
||||||
|
"""点击 [添加镜像] 按钮"""
|
||||||
|
logger.info("👉 点击 [添加镜像] 按钮")
|
||||||
|
self.smart_click("添加镜像")
|
||||||
|
|
||||||
|
def click_mirror_in_list(self, mirror_name=None):
|
||||||
|
"""点击列表末尾的可用镜像 (不限名称)"""
|
||||||
|
# 调试日志:探测当前所有镜像状态
|
||||||
|
try:
|
||||||
|
dom_info = self.page.evaluate("""() => {
|
||||||
|
const rows = Array.from(document.querySelectorAll('.p-datatable-row, tr, .p-card'));
|
||||||
|
return rows.filter(r => r.innerText.includes('可用') || r.innerText.includes('Available'))
|
||||||
|
.map(r => r.innerText.slice(0, 50).replace(/\\n/g, ' '));
|
||||||
|
}""")
|
||||||
|
logger.info(f"📊 当前页面可用镜像列表: {dom_info}")
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
logger.info("👉 尝试开启【可用】镜像列表中最后一个详情页")
|
||||||
|
# 直接通过状态文案定位行或卡片
|
||||||
|
selector = "tr:has-text('可用'), div.p-card:has-text('可用'), .p-datatable-row:has-text('可用')"
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 等待至少一个可用镜像
|
||||||
|
self.page.wait_for_selector(selector, timeout=10000)
|
||||||
|
locators = self.page.locator(selector)
|
||||||
|
count = locators.count()
|
||||||
|
if count > 0:
|
||||||
|
logger.info(f"✅ 找到 {count} 个可用镜像,准备点击最后一个...")
|
||||||
|
target = locators.nth(count - 1)
|
||||||
|
target.scroll_into_view_if_needed()
|
||||||
|
target.click()
|
||||||
|
else:
|
||||||
|
raise Exception("未找到处于 '可用' 状态的镜像")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ 镜像列表尝试点击失败: {e}")
|
||||||
|
# 暴力兜底:点击表格最后一行的第一个单元格
|
||||||
|
self.page.evaluate("""() => {
|
||||||
|
const rows = document.querySelectorAll('.p-datatable-row, tr');
|
||||||
|
if (rows.length > 0) rows[rows.length - 1].click();
|
||||||
|
}""")
|
||||||
|
|
||||||
|
# 跳转监控
|
||||||
|
logger.info("⏳ 等待跳转至详情页 (检测 [快速创建] 按钮)...")
|
||||||
|
try:
|
||||||
|
self.page.wait_for_selector("text='快速创建'", timeout=15000)
|
||||||
|
logger.info("✅ 已进入详情页")
|
||||||
|
except:
|
||||||
|
logger.error("❌ 跳转详情页失败,可能点击未奏效")
|
||||||
|
raise Exception("进入详情页超时")
|
||||||
|
|
||||||
|
def click_quick_create(self):
|
||||||
|
"""点击 [快速创建] 按钮 (增加 JS 兜底)"""
|
||||||
|
logger.info("👉 点击 [快速创建] 按钮")
|
||||||
|
try:
|
||||||
|
# 策略1:Playwright 智能点击
|
||||||
|
self.smart_click("快速创建", timeout=5000)
|
||||||
|
except:
|
||||||
|
# 策略2:JS 暴力点击
|
||||||
|
logger.warning("⚠️ smart_click 失败,正在执行 JS [快速创建] 强制点击")
|
||||||
|
self.page.evaluate("""() => {
|
||||||
|
const buttons = Array.from(document.querySelectorAll('button, a, .p-button'));
|
||||||
|
const target = buttons.find(b => b.innerText && b.innerText.includes('快速创建'));
|
||||||
|
if (target) {
|
||||||
|
target.scrollIntoView({block: 'center'});
|
||||||
|
target.click();
|
||||||
|
}
|
||||||
|
}""")
|
||||||
|
|
||||||
|
time.sleep(3) # 给弹窗充足的渲染时间
|
||||||
|
|
||||||
|
def fill_mirror_name(self, name):
|
||||||
|
"""输入名称 (增加深度探测与日志)"""
|
||||||
|
logger.info(f"⌨️ 正在尝试定位 [名称] 输入框...")
|
||||||
|
|
||||||
|
# 1. 尝试常见业务 Label (智能探测模式)
|
||||||
|
for lbl in ["名称", "桌面名称", "实例名称"]:
|
||||||
|
try:
|
||||||
|
# 尝试极短的时间验证,快进快出
|
||||||
|
self.smart_fill(lbl, name, timeout=2000)
|
||||||
|
logger.info(f"✅ 成功命中标签 [{lbl}]")
|
||||||
|
return
|
||||||
|
except:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 2. 尝试 Placeholder 模式
|
||||||
|
try:
|
||||||
|
loc = self.page.locator("input[placeholder*='名称'], input[placeholder*='name']").first
|
||||||
|
if loc.is_visible():
|
||||||
|
loc.fill(name)
|
||||||
|
logger.info("✅ 通过 Placeholder 定位成功")
|
||||||
|
return
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 3. 终极兜底:弹窗内的第一个可见 input (针对弹窗内 label 定位偏移的情况)
|
||||||
|
try:
|
||||||
|
# 找到当前可见的 dialog
|
||||||
|
dialog = self.page.locator(".p-dialog, [role='dialog']").first
|
||||||
|
if dialog.is_visible():
|
||||||
|
target_input = dialog.locator("input:not([type='hidden'])").first
|
||||||
|
target_input.fill(name)
|
||||||
|
logger.info("✅ 通过 [弹窗内首个输入框] 定位成功")
|
||||||
|
return
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 4. 诊断日志:如果走到这里说明彻底找不到,获取当前页面所有输入框上下文,发往日志
|
||||||
|
try:
|
||||||
|
dom_report = self.page.evaluate("""() => {
|
||||||
|
const inputs = Array.from(document.querySelectorAll('input, select, textarea'));
|
||||||
|
return inputs.map(i => {
|
||||||
|
const visible = i.offsetWidth > 0;
|
||||||
|
if(!visible) return null;
|
||||||
|
return {
|
||||||
|
tag: i.tagName,
|
||||||
|
placeholder: i.placeholder,
|
||||||
|
id: i.id,
|
||||||
|
className: i.className,
|
||||||
|
parentText: i.parentElement?.innerText?.slice(0, 30)
|
||||||
|
};
|
||||||
|
}).filter(x => x);
|
||||||
|
}""")
|
||||||
|
logger.error(f"❌ 无法定位输入框。当前页面所有可见输入框报告: {dom_report}")
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
raise Exception(f"❌ 无法在镜像创建弹窗找到名称输入框,报告已记录")
|
||||||
|
|
||||||
|
def select_sku(self, sku_id):
|
||||||
|
"""选择资源规格 (复用智能下拉框逻辑)"""
|
||||||
|
logger.info(f"🎯 镜像资产规格选择: {sku_id}")
|
||||||
|
self.smart_select("资源规格", sku_id)
|
||||||
|
|
||||||
|
def click_create_button(self):
|
||||||
|
"""点击 创建并开机 按钮"""
|
||||||
|
logger.info("👉 点击 [创建并开机] 按钮")
|
||||||
|
# 通常弹窗下方是 确定 或 立即创建
|
||||||
|
self.smart_click("创建并开机")
|
||||||
@ -1,49 +1,45 @@
|
|||||||
|
import random
|
||||||
import os
|
import os
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
# --- 基础配置 ---
|
# --- 基础配置 ---
|
||||||
BASE_URL = "https://robogo-fat.d-robotics.cc"
|
BASE_URL = "https://robogo.d-robotics.cc"
|
||||||
CLOUD_BASE_URL = "https://cloud-fat.d-robotics.cc"
|
LOGIN_URL = f"{BASE_URL}/cloud-desktop/login"
|
||||||
VOLCANO_BASE_URL = "http://120.48.128.161:16001/api"
|
|
||||||
|
|
||||||
# --- 认证信息 ---
|
# --- 文件管理配置 ---
|
||||||
AUTH_TOKEN = "Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6Inl4LXl5ZHMta2V5IiwidHlwIjoiSldUIn0.eyJ1c2VyX2lkIjoiY2M5MDE5NjEtZTIxNS00NDRjLWIwMTMtMmQyMDZkZjU2ODc1IiwidXNlcm5hbWUiOiJ0YW9oYW5neXUxIiwiaXNzIjoidXNlci1jZW50ZXIiLCJzdWIiOiJ0YW9oYW5neXUxIiwiZXhwIjoxNzczMzAwMTUzLCJuYmYiOjE3NzI2OTUzNTMsImlhdCI6MTc3MjY5NTM1M30.HSjbyXUJOR2QqezPxfCAiUgunquaNjOceSeOFS-kVg6c6Y74hw2vnfWzoBaXprKIBJMqUJaB5S4mek7icDbX-MKTOOz0ClDrHNxJGDzkMV5WNsuJSPnSjqzjm0gUQ73A3Dh3FeagPvkG6fi3gzdvXCVEz4MbHI3BeogCr-Thode0DqGNm2yG-Dn3cqctMak-PUiT9V1konENRUj-jYRq28uFVM8OWh7e70P-ToyRBASPIJdTUN408eZ_4m_6mFtYgvPV6WI7-B6VWea31oX9bWX5MHvARseZiTrOuoonG9Y7QB_TUX90WwgTUbqiTRU89kOCw4UhrwkTKXURTb5xhw"
|
# 定义为静态字符串,避免导入时触发 input
|
||||||
COOKIE = "_bl_uid=0amOhkt1m77etFp8gi37oyk7wjg8; acw_tc=276a376517727797494882119e27aa25f45d6b05e3eb7501724f9c9d13faaa"
|
FOLDER_NAME = f"UI_TEST_{random.randint(1, 1000000)}"
|
||||||
CLOUD_AUTH_TOKEN = "Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6Inl4LXl5ZHMta2V5IiwidHlwIjoiSldUIn0.eyJ1c2VyX2lkIjoiOWJiNmZjZTYtZDk3NC0xMWVmLWJiMjUtZmEyMDIwMzQxMWNlIiwidXNlcm5hbWUiOiJBZG1pbmlzdHJhdG9yIiwiaXNzIjoidXNlci1jZW50ZXIiLCJzdWIiOiJBZG1pbmlzdHJhdG9yIiwiZXhwIjoxNzczNjUxNzE5LCJuYmYiOjE3NzMwNDY5MTksImlhdCI6MTc3MzA0NjkxOX0.DzjEc48rVPr6zOqRDqURxR-Fsu3DfZxa4gbapdIS3wSUEusC-hGSw9TDAPysfsFSigfZP1GDP9J8OJhZwPPbaHSum9IvUhIagxqaD8xKuzDlDc_cAGij7nHVed6h4SG-lFJNq-jaXhx08GQ6E1d79jRdF-2IFl23trCjoMVa_70qIzS6ZWhvUTKcJ96hzOQhYJhUYVJjdphChPABPkpMH8ljU5gD-VHsx2Lr_xTtOI1HiEmkukiS0frwbqLkGl2Lja6-tvZhc3hQifJMIDNLRNvtzvbuC412ljUQxhA7euE4JIfDuU3S3l7BQ7bQkSIwd0HzaVqg_Agw"
|
|
||||||
|
|
||||||
# --- 飞书机器人配置 ---
|
# --- 开发机申请配置 ---
|
||||||
FEISHU_APP_ID = "cli_a9aeb4fb2c78dcb6"
|
DEV_MACHINE_NAME = f"UI_TEST_{random.randint(1, 1000000)}"
|
||||||
FEISHU_APP_SECRET = "7nYs724srjEn4jgNPJW9cfuqL4e2OVT6"
|
DEV_MACHINE_SKU = "sku-e154fd3a-4719-db77-3911-21c6355349ec"
|
||||||
FEISHU_ALERT_CHAT_ID = "oc_866c9db02166986e0607f0f62d1c698e"
|
SSH_PUBLIC_KEY = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBGp6Be0pt0Xy3Ipxm+AQTz6JQq8DAzIU6XHqD+/gzH6"
|
||||||
|
SYSTEM_DISK = "100"
|
||||||
# --- 资源参数 ---
|
|
||||||
class CloudDesktop:
|
|
||||||
SKU_ID = "4c7d50d9-b6c5-4b90-b829-3b6e8a08a13f"
|
|
||||||
DISK_SIZE = 500
|
|
||||||
SPEC_ID = "eds.graphics_flagship_pro.16c32g.12g"
|
|
||||||
TIMER_ID = "NO_SHUTDOWN"
|
|
||||||
PRICE_POLICY_ID = "57bf71a4-f80c-ff87-6a0b-d74e4ed981a7"
|
|
||||||
|
|
||||||
POST_IMAGE_ID = "34ef57b5-29e5-45e4-9cd4-202fab3ea9a8"
|
|
||||||
PRE_IMAGE_ID = "4b2a81d4-7f8b-4515-b4b5-bb212557aec1"
|
|
||||||
BP_IMAGE_ID = "2af0347a-b825-4272-a313-dde1d350d73d"
|
|
||||||
|
|
||||||
class DevMachine:
|
# --- 登录凭证 (如果环境变量没有,则逻辑中会提示输入) ---
|
||||||
SKU_ID = "e154fd3a-4719-db77-3911-21c6355349ec"
|
AUTH_ACCOUNT = os.getenv("ROBOGO_USER", "")
|
||||||
IMAGE_ID = "ed2ae2e7-0373-4d15-9fdb-b44c83f81231"
|
AUTH_PASSWORD = os.getenv("ROBOGO_PWD", "")
|
||||||
DISK_SIZE = 512
|
|
||||||
SSH_KEY = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBGp6Be0pt0Xy3Ipxm+AQTz6JQq8DAzIU6XHqD+/gzH6"
|
|
||||||
|
|
||||||
# --- 高德地图 API 配置 ---
|
# --- 其他框架配置 ---
|
||||||
AMAP_KEY = "71ef48b583ad060c20416868c30f6b99"
|
TIMEOUT = 30000
|
||||||
AMAP_LOCATION_NAME = "北京市海淀区中关村壹号"
|
|
||||||
AMAP_COORDINATES = "116.239322,40.074312"
|
|
||||||
|
|
||||||
# --- 超时等待 (秒) ---
|
|
||||||
WAIT = {
|
|
||||||
"create": 120,
|
|
||||||
"stop": 60,
|
|
||||||
"start": 60,
|
|
||||||
"switch": 30
|
|
||||||
}
|
|
||||||
INSPECTION_INTERVAL = 300
|
INSPECTION_INTERVAL = 300
|
||||||
|
|
||||||
|
# --- 框架状态等待时长 (秒) ---
|
||||||
|
WAIT = {
|
||||||
|
'create': 60,
|
||||||
|
'stop': 30,
|
||||||
|
'delete': 30
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- 云桌面 ---
|
||||||
|
CLOUD_DESKTOP_SKU = "desktop-5880gpu12g-16c32g"
|
||||||
|
CLOUD_DESKTOP_NAME = f"UI_TEST_{random.randint(1, 1000000)}"
|
||||||
|
|
||||||
|
# --- 路径配置 ---
|
||||||
|
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
TEST_DATA_DIR = os.path.join(BASE_DIR, "test_data")
|
||||||
|
TEST_FILE = os.path.join(TEST_DATA_DIR, "Fruits-15.zip")
|
||||||
|
|
||||||
|
# --- 镜像资产 ---
|
||||||
|
MIRROR_NAME = f"UI_TEST_{random.randint(1, 1000000)}"
|
||||||
|
MIRROR_SKU = "desktop-5880gpu12g-16c32g"
|
||||||
214
framework/core/base_page.py
Normal file
214
framework/core/base_page.py
Normal file
@ -0,0 +1,214 @@
|
|||||||
|
from framework.core.logger import get_logger
|
||||||
|
import time
|
||||||
|
|
||||||
|
logger = get_logger("BasePage")
|
||||||
|
|
||||||
|
class BasePage:
|
||||||
|
"""所有页面对象的基类,提供智能识别与常用操作封装"""
|
||||||
|
|
||||||
|
def __init__(self, page):
|
||||||
|
self.page = page
|
||||||
|
|
||||||
|
def smart_find(self, description, role=None, timeout=5000):
|
||||||
|
"""核心智能定位器:按优先级尝试语义定位"""
|
||||||
|
strategies = [
|
||||||
|
lambda: self.page.get_by_role(role, name=description, exact=False) if role else None,
|
||||||
|
lambda: self.page.get_by_text(description, exact=False),
|
||||||
|
# 新增:直接属性扫描以应对 aria-label 类按钮
|
||||||
|
lambda: self.page.locator(f'[aria-label*="{description}"]').first,
|
||||||
|
lambda: self.page.get_by_placeholder(description, exact=False),
|
||||||
|
lambda: self.page.get_by_label(description, exact=False),
|
||||||
|
lambda: self.page.get_by_title(description, exact=False)
|
||||||
|
]
|
||||||
|
|
||||||
|
for get_loc in strategies:
|
||||||
|
try:
|
||||||
|
loc = get_loc()
|
||||||
|
if loc and loc.count() > 0:
|
||||||
|
return loc.first
|
||||||
|
except:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 兜底:尝试 CSS
|
||||||
|
if any(c in description for c in [".", "#", "[", ">"]):
|
||||||
|
try:
|
||||||
|
return self.page.locator(description).first
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
raise Exception(f"❌ 无法在页面找到元素: '{description}'")
|
||||||
|
|
||||||
|
def smart_click(self, text, role=None, timeout=5000):
|
||||||
|
"""语义化点击:带重试与可见性校验"""
|
||||||
|
logger.info(f"👉 [SmartClick] 尝试点击: {text}")
|
||||||
|
try:
|
||||||
|
el = self.smart_find(text, role=role, timeout=timeout)
|
||||||
|
# 等待可见与稳定
|
||||||
|
el.wait_for(state="visible", timeout=timeout)
|
||||||
|
|
||||||
|
# 使用自带重试的点击逻辑,应对 SPA 瞬时刷新
|
||||||
|
try:
|
||||||
|
el.click(timeout=timeout)
|
||||||
|
except Exception as e:
|
||||||
|
if "detached" in str(e).lower():
|
||||||
|
logger.warning(f"⚠️ 节点失效,正在重新寻找并点击: {text}")
|
||||||
|
self.smart_find(text, role=role, timeout=timeout).click(timeout=timeout)
|
||||||
|
else:
|
||||||
|
raise e
|
||||||
|
except Exception as e:
|
||||||
|
# 暴力循环 JS 点击作为最终兜底
|
||||||
|
success = self.page.evaluate(f"""(txt) => {{
|
||||||
|
const target = Array.from(document.querySelectorAll('button, a, span, .p-button-label, label, input'))
|
||||||
|
.find(el => {{
|
||||||
|
const it = el.innerText.trim();
|
||||||
|
const al = el.getAttribute('aria-label') || "";
|
||||||
|
const ph = el.getAttribute('placeholder') || "";
|
||||||
|
return it.includes(txt) || al.includes(txt) || ph.includes(txt);
|
||||||
|
}});
|
||||||
|
if (target) {{
|
||||||
|
target.scrollIntoView({{behavior: "smooth", block: "center"}});
|
||||||
|
setTimeout(() => target.click(), 100);
|
||||||
|
return true;
|
||||||
|
}}
|
||||||
|
return false;
|
||||||
|
}}""", text)
|
||||||
|
if not success:
|
||||||
|
raise e
|
||||||
|
|
||||||
|
def smart_fill(self, label, value, timeout=5000):
|
||||||
|
"""语义化输入:支持 Vue/React 的深度绑定注入"""
|
||||||
|
logger.info(f"⌨️ [SmartFill] 在 [{label}] 填写: {value}")
|
||||||
|
try:
|
||||||
|
# 1. 尝试直接命中 (Label/Placeholder等原生属性)
|
||||||
|
el = self.smart_find(label, timeout=timeout)
|
||||||
|
el.wait_for(state="visible", timeout=timeout)
|
||||||
|
el.fill(value, timeout=timeout)
|
||||||
|
except Exception as first_err:
|
||||||
|
try:
|
||||||
|
# 2. 邻近注入方案:借助 JS 寻找关联输入框,并使用 Playwright 原生 fill 保障前端框架双向绑定
|
||||||
|
logger.info(f"🔍 [SmartFill] 常规规则未命中,正在执行深度邻近探测 [{label}]...")
|
||||||
|
success = self.page.evaluate(f"""(lbl) => {{
|
||||||
|
document.querySelectorAll('[data-smart-target]').forEach(e => e.removeAttribute('data-smart-target'));
|
||||||
|
|
||||||
|
// 1. 过滤:必须是有高度/宽度的真实可见节点
|
||||||
|
const allNodes = Array.from(document.querySelectorAll('label, span, div, p, legend'))
|
||||||
|
.filter(el => el.offsetWidth > 0 && el.offsetHeight > 0 && el.innerText && el.innerText.includes(lbl));
|
||||||
|
|
||||||
|
// 2. 最深层过滤
|
||||||
|
const tags = allNodes.filter(el => {{
|
||||||
|
return !Array.from(el.children).some(child => child.offsetWidth > 0 && child.innerText && child.innerText.includes(lbl));
|
||||||
|
}});
|
||||||
|
|
||||||
|
for (const t of tags) {{
|
||||||
|
const container = t.closest('.p-inputgroup, .field, .flex, div');
|
||||||
|
const input = container?.querySelector('input:not([type="hidden"]), textarea') ||
|
||||||
|
t.parentElement?.querySelector('input:not([type="hidden"]), textarea');
|
||||||
|
if (input && input.offsetWidth > 0) {{
|
||||||
|
input.setAttribute('data-smart-target', 'true');
|
||||||
|
return true;
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
return false;
|
||||||
|
}}""", label)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
target = self.page.locator("[data-smart-target='true']").first
|
||||||
|
target.scroll_into_view_if_needed()
|
||||||
|
target.fill(value, timeout=timeout)
|
||||||
|
target.blur()
|
||||||
|
else:
|
||||||
|
logger.warning(f"⚠️ [SmartFill] JS 探测失败: 页面上可见区域内没找到 '{label}' 及其相邻输入框。")
|
||||||
|
raise first_err
|
||||||
|
except Exception as second_err:
|
||||||
|
logger.error(f"⚠️ [SmartFill] JS注入或执行时发生错误: {second_err}")
|
||||||
|
raise second_err
|
||||||
|
|
||||||
|
def smart_delete(self,text,role=None,timeout=5000):
|
||||||
|
logger.info(f"👉 [SmartDelete] 尝试删除: {text}")
|
||||||
|
try:
|
||||||
|
el = self.smart_find(text, role=role, timeout=timeout)
|
||||||
|
el.wait_for(state="visible", timeout=timeout)
|
||||||
|
el.click(timeout=timeout)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"⚠️ [SmartDelete] JS注入或执行时发生错误: {e}")
|
||||||
|
raise e
|
||||||
|
|
||||||
|
def smart_select(self, label, value):
|
||||||
|
"""智能下拉框选择 (适配 PrimeVue)"""
|
||||||
|
logger.info(f"📋 [SmartSelect] 在 [{label}] 下拉框寻找目标: {value}")
|
||||||
|
|
||||||
|
# 1. 寻找并打开下拉框
|
||||||
|
opened = self.page.evaluate("""([lbl, val]) => {
|
||||||
|
const root = document.querySelector('.p-dialog') || document.querySelector('[role="dialog"]') || document;
|
||||||
|
const elements = Array.from(root.querySelectorAll('label, span, div'));
|
||||||
|
const targetLabel = elements.find(el => {
|
||||||
|
const t = el.innerText?.trim();
|
||||||
|
return t && (t === lbl || t.includes(lbl)) && el.offsetWidth > 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!targetLabel) return 'NO_LABEL';
|
||||||
|
|
||||||
|
let container = targetLabel;
|
||||||
|
for (let i = 0; i < 8; i++) {
|
||||||
|
if (!container) break;
|
||||||
|
// 查找下拉框容器
|
||||||
|
const dd = container.querySelector('.p-select, .p-dropdown, [data-pc-name="select"], [data-pc-name="dropdown"]');
|
||||||
|
if (dd && dd.offsetWidth > 0) {
|
||||||
|
dd.scrollIntoView({block: 'center'});
|
||||||
|
dd.click();
|
||||||
|
return 'OPENED';
|
||||||
|
}
|
||||||
|
// 检查兄弟元素
|
||||||
|
if (container.parentElement) {
|
||||||
|
const sibling = container.parentElement.querySelector('.p-select, .p-dropdown, [data-pc-name="select"]');
|
||||||
|
if (sibling && sibling.offsetWidth > 0) {
|
||||||
|
sibling.scrollIntoView({block: 'center'});
|
||||||
|
sibling.click();
|
||||||
|
return 'OPENED';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
container = container.parentElement;
|
||||||
|
}
|
||||||
|
return 'NO_DROPDOWN';
|
||||||
|
}""", [label, value])
|
||||||
|
|
||||||
|
if opened != 'OPENED':
|
||||||
|
logger.warning(f"⚠️ [SmartSelect] 无法精准找到标签 '{label}' 关联的下拉框,尝试直接搜索首个 visible 的下拉框...")
|
||||||
|
self.page.locator(".p-select, .p-dropdown").first.click()
|
||||||
|
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
# 2. 尝试多种定位器点击选项
|
||||||
|
try:
|
||||||
|
# 优先精准文本
|
||||||
|
selectors = [
|
||||||
|
f"li[role='option']:has-text('{value}')",
|
||||||
|
f".p-select-option:has-text('{value}')",
|
||||||
|
f".p-dropdown-item:has-text('{value}')"
|
||||||
|
]
|
||||||
|
for sel in selectors:
|
||||||
|
loc = self.page.locator(sel).first
|
||||||
|
if loc.is_visible(timeout=500):
|
||||||
|
loc.click()
|
||||||
|
logger.info(f"✅ [SmartSelect] 成功通过选择器选中: {value}")
|
||||||
|
return True
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 3. JS 暴力匹配并点击
|
||||||
|
clicked = self.page.evaluate("""(val) => {
|
||||||
|
const options = Array.from(document.querySelectorAll('li[role="option"], .p-select-option, .p-dropdown-item'));
|
||||||
|
const target = options.find(opt => opt.offsetWidth > 0 && opt.innerText.includes(val));
|
||||||
|
if (target) {
|
||||||
|
target.scrollIntoView({block: 'center'});
|
||||||
|
target.dispatchEvent(new MouseEvent('click', {bubbles: true, cancelable: true}));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}""", value)
|
||||||
|
|
||||||
|
if clicked:
|
||||||
|
logger.info(f"✅ [SmartSelect] 通过 JS 暴力匹配选中: {value}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
raise Exception(f"❌ [SmartSelect] 无法在下拉框中选择选项: {value}")
|
||||||
@ -65,3 +65,97 @@ class BaseUI:
|
|||||||
|
|
||||||
def wait_for_selector(self, selector, timeout=10000):
|
def wait_for_selector(self, selector, timeout=10000):
|
||||||
self.page.wait_for_selector(selector, timeout=timeout)
|
self.page.wait_for_selector(selector, timeout=timeout)
|
||||||
|
|
||||||
|
def smart_find(self, description, role=None, timeout=5000):
|
||||||
|
"""核心智能定位器:按优先级尝试语义定位"""
|
||||||
|
strategies = [
|
||||||
|
lambda: self.page.get_by_role(role, name=description, exact=False) if role else None,
|
||||||
|
lambda: self.page.get_by_text(description, exact=False),
|
||||||
|
lambda: self.page.get_by_placeholder(description, exact=False),
|
||||||
|
lambda: self.page.get_by_label(description, exact=False),
|
||||||
|
lambda: self.page.get_by_title(description, exact=False)
|
||||||
|
]
|
||||||
|
|
||||||
|
for get_loc in strategies:
|
||||||
|
try:
|
||||||
|
loc = get_loc()
|
||||||
|
if loc and loc.count() > 0:
|
||||||
|
# 优先返回第一个匹配的可交互元素
|
||||||
|
return loc.first
|
||||||
|
except:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 最后的兜底尝试:简单的 CSS 选择器尝试 (如果 description looks like one)
|
||||||
|
if "." in description or "#" in description or "[" in description:
|
||||||
|
try:
|
||||||
|
return self.page.locator(description).first
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
raise Exception(f"❌ 无法识别页面元素: '{description}'")
|
||||||
|
|
||||||
|
def smart_click(self, text, role=None, timeout=5000):
|
||||||
|
"""语义化点击"""
|
||||||
|
logger.info(f"👉 [SmartClick] 正在尝试点击: {text}")
|
||||||
|
try:
|
||||||
|
el = self.smart_find(text, role=role, timeout=timeout)
|
||||||
|
el.scroll_into_view_if_needed()
|
||||||
|
el.click(timeout=timeout)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"⚠️ [SmartClick] 常规语义匹配失败: {e}. 正在尝试暴力文本搜索...")
|
||||||
|
# 暴力 JS 点击兜底
|
||||||
|
success = self.page.evaluate(f"""(txt) => {{
|
||||||
|
const items = Array.from(document.querySelectorAll('button, a, span, .p-button-label'));
|
||||||
|
const target = items.find(el => el.innerText.trim().includes(txt));
|
||||||
|
if (target) {{ target.click(); return true; }}
|
||||||
|
return false;
|
||||||
|
}}""", text)
|
||||||
|
if not success:
|
||||||
|
self.page.screenshot(path=f"smart_click_failed_{int(time.time())}.png")
|
||||||
|
raise e
|
||||||
|
|
||||||
|
def smart_fill(self, label, value, timeout=5000):
|
||||||
|
"""语义化输入"""
|
||||||
|
logger.info(f"⌨️ [SmartFill] 正在尝试在 [{label}] 填写: {value}")
|
||||||
|
try:
|
||||||
|
# 1. 尝试直接通过 Label/Placeholder 寻找
|
||||||
|
el = self.smart_find(label, role="textbox", timeout=timeout)
|
||||||
|
el.fill(value)
|
||||||
|
except:
|
||||||
|
# 2. 如果失败,尝试找到标签文字,然后填充它附近的 input
|
||||||
|
logger.info(f"🔍 [SmartFill] 尝试寻找临近 [{label}] 的输入框...")
|
||||||
|
self.page.evaluate(f"""([lbl, val]) => {{
|
||||||
|
const allTexts = Array.from(document.querySelectorAll('label, span, div'))
|
||||||
|
.filter(el => el.innerText.trim() === lbl);
|
||||||
|
for (const textItem of allTexts) {{
|
||||||
|
const parent = textItem.parentElement;
|
||||||
|
const input = parent.querySelector('input, textarea');
|
||||||
|
if (input) {{
|
||||||
|
input.value = val;
|
||||||
|
input.dispatchEvent(new Event('input', {{ bubbles: true }}));
|
||||||
|
input.dispatchEvent(new Event('change', {{ bubbles: true }}));
|
||||||
|
return true;
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
return false;
|
||||||
|
}}""", [label, value])
|
||||||
|
|
||||||
|
def get_session_info(self):
|
||||||
|
"""动态从浏览器提取 Session 信息"""
|
||||||
|
logger.info("正在从浏览器会话提取认证信息...")
|
||||||
|
# 提取 Cookie
|
||||||
|
cookies = self.context.cookies()
|
||||||
|
cookie_str = "; ".join([f"{c['name']}={c['value']}" for c in cookies])
|
||||||
|
|
||||||
|
# 尝试从 localStorage 提取 Token (通常 key 可能是 token, access_token 或 bearer)
|
||||||
|
token = self.page.evaluate("""() => {
|
||||||
|
return localStorage.getItem('token') ||
|
||||||
|
localStorage.getItem('access_token') ||
|
||||||
|
localStorage.getItem('bearer') ||
|
||||||
|
JSON.parse(localStorage.getItem('user_info') || '{}').token;
|
||||||
|
}""")
|
||||||
|
|
||||||
|
if token and not token.startswith("Bearer "):
|
||||||
|
token = f"Bearer {token}"
|
||||||
|
|
||||||
|
return {"token": token, "cookie": cookie_str}
|
||||||
|
|||||||
42
framework/scripts/compute_resource_scenario.py
Normal file
42
framework/scripts/compute_resource_scenario.py
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
# framework/scripts/compute_resource_scenario.py
|
||||||
|
import time
|
||||||
|
from framework.config.settings import Config
|
||||||
|
from framework.core.logger import get_logger
|
||||||
|
|
||||||
|
logger = get_logger("ComputeResourceScenario")
|
||||||
|
|
||||||
|
def run_dev_machine_lifecycle(dm):
|
||||||
|
"""
|
||||||
|
业务逻辑:开发机申请与验证流程
|
||||||
|
"""
|
||||||
|
logger.info("--- 开启【开发机】业务总线巡检 ---")
|
||||||
|
dm.navigate_to()
|
||||||
|
|
||||||
|
# 1. 状态巡检
|
||||||
|
status = dm.get_first_machine_status()
|
||||||
|
if status == "Empty":
|
||||||
|
logger.info("当前列表为空,准备首次申请...")
|
||||||
|
|
||||||
|
# 2. 执行申请全过程
|
||||||
|
dm.open_apply_dialog()
|
||||||
|
dm.fill_name(Config.DEV_MACHINE_NAME)
|
||||||
|
dm.select_sku(Config.DEV_MACHINE_SKU)
|
||||||
|
dm.select_image("CUDA")
|
||||||
|
dm.fill_ssh_key(Config.SSH_PUBLIC_KEY)
|
||||||
|
dm.fill_system_disk(Config.SYSTEM_DISK)
|
||||||
|
dm.submit_application()
|
||||||
|
|
||||||
|
# 3. 深度巡检:等待部署 -> 关机 -> 删除
|
||||||
|
name = Config.DEV_MACHINE_NAME
|
||||||
|
logger.info(f"⌛ 正在等待开发机 {name} 部署就绪...")
|
||||||
|
|
||||||
|
# 注意:PageObject 内部的 wait_for_status 会处理具体轮询
|
||||||
|
# 这里给一个基础 10s 避免页面刚刷新时 DOM 状态不稳定
|
||||||
|
time.sleep(10)
|
||||||
|
|
||||||
|
# 执行业务链路巡检
|
||||||
|
dm.stop_machine(name)
|
||||||
|
dm.delete_machine(name)
|
||||||
|
|
||||||
|
logger.info("✅ 开发机【全生命周期】业务巡检圆满完成,环境已清理")
|
||||||
|
return True
|
||||||
@ -1,52 +1,46 @@
|
|||||||
|
# framework/scripts/desktop_lifecycle.py
|
||||||
|
"""云桌面 UI 全生命周期巡检脚本"""
|
||||||
import time
|
import time
|
||||||
from framework.business.cloud_desktop import CloudDesktopService
|
|
||||||
from framework.models.result import StepResult
|
|
||||||
from framework.config.settings import Config
|
from framework.config.settings import Config
|
||||||
from framework.core.logger import get_logger
|
from framework.core.logger import get_logger
|
||||||
|
|
||||||
logger = get_logger("LifecycleScript")
|
logger = get_logger("DesktopLifecycleScenario")
|
||||||
|
|
||||||
def run_postpaid_lifecycle(name):
|
def run_cloud_desktop_lifecycle(cd):
|
||||||
"""
|
"""
|
||||||
业务逻辑:按量转包月完整生命周期示例
|
业务逻辑:地瓜桌面创建与全生命周期验证流程 (UI 自动化版)
|
||||||
|
流程:创建 -> 按量转包月 -> 打开 -> 保存镜像 -> 关机 -> 删除
|
||||||
"""
|
"""
|
||||||
service = CloudDesktopService()
|
logger.info("--- 开启【地瓜桌面】业务总线巡检 ---")
|
||||||
results = []
|
cd.navigate_to()
|
||||||
|
|
||||||
# 1. 创建
|
# 1. 检测已有实例
|
||||||
logger.info(f"Step 1: 创建云桌面 {name}")
|
name = cd.get_first_desktop_name()
|
||||||
t0 = time.time()
|
logger.info(f"✅ 探测到首台云桌面实例名称: {name}")
|
||||||
try:
|
|
||||||
desktop_id = service.create_desktop(name, charge_type="PostPaid")
|
# 2. 执行创建桌面的完整业务
|
||||||
results.append(StepResult("创建云桌面", True, f"成功 ID: {desktop_id}", time.time()-t0))
|
name = Config.CLOUD_DESKTOP_NAME
|
||||||
except Exception as e:
|
cd.open_create_dialog()
|
||||||
results.append(StepResult("创建云桌面", False, str(e), time.time()-t0))
|
cd.fill_name(name)
|
||||||
return results, None
|
cd.select_sku(Config.CLOUD_DESKTOP_SKU)
|
||||||
|
cd.select_image("Ubuntu")
|
||||||
# 等待部署
|
cd.submit_creation()
|
||||||
time.sleep(Config.WAIT['create'])
|
|
||||||
|
# 3. 按量转包月
|
||||||
# 2. 关机
|
# cd.convert_to_monthly(name)
|
||||||
logger.info("Step 2: 关机")
|
|
||||||
t0 = time.time()
|
# 4. 打开桌面
|
||||||
try:
|
cd.open_desktop(name)
|
||||||
service.stop_desktop(desktop_id)
|
time.sleep(10)
|
||||||
results.append(StepResult("关机", True, "成功", time.time()-t0))
|
|
||||||
except Exception as e:
|
# 5. 保存镜像
|
||||||
results.append(StepResult("关机", False, str(e), time.time()-t0))
|
cd.save_image(name)
|
||||||
|
|
||||||
# 3. 删除 (示例简化,不写全部逻辑)
|
# 6. 关机
|
||||||
logger.info("Step 3: 删除")
|
cd.stop_desktop(name)
|
||||||
t0 = time.time()
|
|
||||||
try:
|
# 7. 删除
|
||||||
service.delete_desktop(desktop_id)
|
cd.delete_desktop(name)
|
||||||
results.append(StepResult("删除", True, "成功", time.time()-t0))
|
|
||||||
except Exception as e:
|
logger.info("🎉 云桌面全生命周期业务场景巡检完毕!")
|
||||||
results.append(StepResult("删除", False, str(e), time.time()-t0))
|
return True
|
||||||
|
|
||||||
return results, desktop_id
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
res, d_id = run_postpaid_lifecycle("RobotFramework-Test")
|
|
||||||
for r in res:
|
|
||||||
print(r)
|
|
||||||
|
|||||||
60
framework/scripts/file_system_scenario.py
Normal file
60
framework/scripts/file_system_scenario.py
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
# framework/scripts/file_system_scenario.py
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from framework.core.logger import get_logger
|
||||||
|
|
||||||
|
logger = get_logger("FileSystemScenario")
|
||||||
|
|
||||||
|
def run_stress_upload_test(fm, file_path, cycles=3):
|
||||||
|
"""
|
||||||
|
业务逻辑:文件上传压力测试(大文件上传-取消)
|
||||||
|
"""
|
||||||
|
logger.info(f"🚀 启动上传-取消压测 - {cycles} 次")
|
||||||
|
for i in range(1, cycles + 1):
|
||||||
|
logger.info(f"--- 循环第 {i} 次 ---")
|
||||||
|
try:
|
||||||
|
fm.upload_files(file_path)
|
||||||
|
# 大文件需要等一下让上传进度条和取消按钮出现
|
||||||
|
time.sleep(3)
|
||||||
|
fm.cancel_upload()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"压测第 {i} 次异常(可忽略): {e}")
|
||||||
|
# 确保弹窗关闭,不影响下一轮
|
||||||
|
try:
|
||||||
|
fm.page.evaluate("document.querySelector('button.p-dialog-header-close')?.click()")
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
def run_full_file_lifecycle(fm, folder_name):
|
||||||
|
"""
|
||||||
|
业务逻辑:完整的文件生命周期流程(大文件版本)
|
||||||
|
"""
|
||||||
|
logger.info(f"--- 开启文件系统全生命周期测试 [{folder_name}] ---")
|
||||||
|
fm.navigate_to()
|
||||||
|
|
||||||
|
# 1. 创建并进入
|
||||||
|
fm.create_folder(folder_name)
|
||||||
|
fm.enter_folder(folder_name)
|
||||||
|
|
||||||
|
# 2. 上传压力测试
|
||||||
|
from framework.config.settings import Config
|
||||||
|
test_file = Config.TEST_FILE
|
||||||
|
if os.path.exists(test_file):
|
||||||
|
logger.info(f"📄 测试文件: {test_file} ({os.path.getsize(test_file)} bytes)")
|
||||||
|
run_stress_upload_test(fm, test_file, cycles=3)
|
||||||
|
|
||||||
|
# 3. 正式上传(等待完成)
|
||||||
|
fm.upload_files(test_file)
|
||||||
|
fm.wait_for_success(count=1)
|
||||||
|
|
||||||
|
# 4. 重命名与删除
|
||||||
|
fm.rename_item("Fruits-15.zip", "UI_TEST_RENAMED.zip")
|
||||||
|
fm.delete_item("UI_TEST_RENAMED.zip")
|
||||||
|
else:
|
||||||
|
logger.warning(f"⚠️ 测试文件不存在: {test_file},跳过上传测试")
|
||||||
|
|
||||||
|
# 5. 清理
|
||||||
|
fm.back_to_root()
|
||||||
|
fm.delete_item(folder_name)
|
||||||
|
logger.info("✅ 文件系统场景测试完成")
|
||||||
37
framework/scripts/mirror_assets.py
Normal file
37
framework/scripts/mirror_assets.py
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import time
|
||||||
|
from framework.config.settings import Config
|
||||||
|
from framework.core.logger import get_logger
|
||||||
|
from framework.business.cloud_desktop_page import CloudDesktopPage
|
||||||
|
|
||||||
|
logger = get_logger("MirrorAssetsScenario")
|
||||||
|
|
||||||
|
def run_mirror_assets_lifecycle(ma, cd):
|
||||||
|
"""
|
||||||
|
业务逻辑:镜像资产全生命周期流程
|
||||||
|
"""
|
||||||
|
logger.info(f"--- 开启【镜像资产】业务总线巡检 ---")
|
||||||
|
ma.navigate_to()
|
||||||
|
ma.click_my_mirror()
|
||||||
|
ma.click_mirror_in_list()
|
||||||
|
ma.click_quick_create()
|
||||||
|
|
||||||
|
mirror_name = Config.MIRROR_NAME
|
||||||
|
ma.fill_mirror_name(mirror_name)
|
||||||
|
time.sleep(2)
|
||||||
|
ma.select_sku(Config.MIRROR_SKU)
|
||||||
|
time.sleep(2)
|
||||||
|
ma.click_create_button()
|
||||||
|
|
||||||
|
logger.info(f"✅ 镜像资产 {mirror_name} 已提交创建,正在跳转往云桌面进行后续关机操作...")
|
||||||
|
|
||||||
|
# 执行后续关机:跳转切换到云桌面页面
|
||||||
|
cd.navigate_to()
|
||||||
|
# 等待创建成功并进入运行中
|
||||||
|
cd.wait_for_status(mirror_name, "运行中")
|
||||||
|
# 执行关机
|
||||||
|
cd.stop_desktop(mirror_name)
|
||||||
|
cd.wait_for_status(mirror_name, "已关机")
|
||||||
|
cd.delete_desktop(mirror_name)
|
||||||
|
time.sleep(3)
|
||||||
|
|
||||||
|
logger.info("✅ 镜像资产场景测试完成")
|
||||||
1343
platform/index.html
Normal file
1343
platform/index.html
Normal file
File diff suppressed because it is too large
Load Diff
310
platform_app.py
Normal file
310
platform_app.py
Normal file
@ -0,0 +1,310 @@
|
|||||||
|
"""
|
||||||
|
自动化平台 - Flask 后端服务
|
||||||
|
启动: ./venv/bin/python platform_app.py
|
||||||
|
访问: http://127.0.0.1:5001
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
import time
|
||||||
|
import queue
|
||||||
|
import threading
|
||||||
|
import subprocess
|
||||||
|
from datetime import datetime
|
||||||
|
from flask import Flask, request, jsonify, Response, send_from_directory
|
||||||
|
from flask_cors import CORS
|
||||||
|
|
||||||
|
app = Flask(__name__, static_folder='platform/static', template_folder='platform/templates')
|
||||||
|
CORS(app)
|
||||||
|
|
||||||
|
|
||||||
|
# ── 持久化存储配置 ─────────────────────────────────────────────────────────────
|
||||||
|
DB_FILE = "platform_db.json"
|
||||||
|
|
||||||
|
def _load_db():
|
||||||
|
"""从本地文件加载数据,若不存在则初始化空文件"""
|
||||||
|
if os.path.exists(DB_FILE):
|
||||||
|
try:
|
||||||
|
with open(DB_FILE, 'r', encoding='utf-8') as f:
|
||||||
|
data = json.load(f)
|
||||||
|
return data.get("tasks", {}), data.get("reports", {})
|
||||||
|
except Exception as e:
|
||||||
|
print(f"⚠️ 数据库加载失败: {e}")
|
||||||
|
else:
|
||||||
|
# 初次运行,初始化一个空文件,方便用户看到文件位置
|
||||||
|
try:
|
||||||
|
with open(DB_FILE, 'w', encoding='utf-8') as f:
|
||||||
|
json.dump({"tasks": {}, "reports": {}}, f)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
return {}, {}
|
||||||
|
|
||||||
|
def _save_db():
|
||||||
|
"""保存数据到本地文件"""
|
||||||
|
try:
|
||||||
|
# 保护性写入:先写临时文件再 rename
|
||||||
|
tmp_file = DB_FILE + ".tmp"
|
||||||
|
with open(tmp_file, 'w', encoding='utf-8') as f:
|
||||||
|
json.dump({
|
||||||
|
"tasks": tasks_db,
|
||||||
|
"reports": reports_db
|
||||||
|
}, f, indent=2, ensure_ascii=False)
|
||||||
|
os.replace(tmp_file, DB_FILE)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ 数据库保存失败: {e}")
|
||||||
|
|
||||||
|
# ── 全局状态加载 ─────────────────────────────────────────────────────────────
|
||||||
|
tasks_db, reports_db = _load_db() # 启动时恢复历史数据
|
||||||
|
log_queues = {} # 实时日志队列无需持久化,仅用于当前会话流转
|
||||||
|
|
||||||
|
PRODUCTS = {
|
||||||
|
"robogo": {
|
||||||
|
"name": "Robogo FAT",
|
||||||
|
"desc": "Robogo FAT 环境全链路 UI 巡检 (文件管理/开发机/云桌面)",
|
||||||
|
"icon": "🤖",
|
||||||
|
"entry": "run_ui_tests.py"
|
||||||
|
},
|
||||||
|
"data_loop": {
|
||||||
|
"name": "数据闭环",
|
||||||
|
"desc": "数据闭环平台端到端验证",
|
||||||
|
"icon": "🔄",
|
||||||
|
"entry": None # 待接入
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── 任务运行核心 ───────────────────────────────────────────────────────────────
|
||||||
|
def _stream_run(task_id: str, entry: str, account: str, password: str, run_count: int):
|
||||||
|
"""在后台线程中运行自动化脚本,并把日志实时推到队列"""
|
||||||
|
log_q = log_queues.get(task_id) or queue.Queue()
|
||||||
|
log_queues[task_id] = log_q
|
||||||
|
|
||||||
|
task = tasks_db[task_id]
|
||||||
|
task["status"] = "running"
|
||||||
|
task["started_at"] = datetime.now().isoformat()
|
||||||
|
_save_db() # 4. 任务进入运行状态时保存
|
||||||
|
|
||||||
|
total_pass = total_fail = 0
|
||||||
|
logs_all = []
|
||||||
|
|
||||||
|
def push(line: str, level: str = "INFO"):
|
||||||
|
msg = {"ts": datetime.now().strftime("%H:%M:%S"), "level": level, "msg": line}
|
||||||
|
log_q.put(json.dumps(msg))
|
||||||
|
logs_all.append(msg)
|
||||||
|
|
||||||
|
push(f"🚀 任务启动 [{task['name']}] | 产品: {task['product']} | 计划运行次数: {run_count}", "INFO")
|
||||||
|
|
||||||
|
python_bin = os.path.join(os.path.dirname(sys.executable), "python")
|
||||||
|
if not os.path.exists(python_bin):
|
||||||
|
python_bin = sys.executable
|
||||||
|
|
||||||
|
for run_idx in range(1, run_count + 1):
|
||||||
|
push(f"─────── 第 {run_idx}/{run_count} 次运行 ───────", "INFO")
|
||||||
|
run_has_error = False
|
||||||
|
try:
|
||||||
|
env = os.environ.copy()
|
||||||
|
env["ROBOGO_USER"] = account
|
||||||
|
env["ROBOGO_PWD"] = password
|
||||||
|
|
||||||
|
proc = subprocess.Popen(
|
||||||
|
[python_bin, entry],
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.STDOUT,
|
||||||
|
text=True,
|
||||||
|
cwd=os.path.dirname(os.path.abspath(__file__)),
|
||||||
|
env=env,
|
||||||
|
bufsize=1
|
||||||
|
)
|
||||||
|
task["pid"] = proc.pid
|
||||||
|
|
||||||
|
for line in proc.stdout:
|
||||||
|
line = line.rstrip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
# 解析日志级别
|
||||||
|
level = "INFO"
|
||||||
|
if "[ERROR]" in line or "❌" in line:
|
||||||
|
level = "ERROR"
|
||||||
|
run_has_error = True
|
||||||
|
elif "[WARNING]" in line or "⚠️" in line:
|
||||||
|
level = "WARN"
|
||||||
|
elif "✅" in line or "🎉" in line or "🎊" in line:
|
||||||
|
level = "SUCCESS"
|
||||||
|
push(line, level)
|
||||||
|
|
||||||
|
proc.wait()
|
||||||
|
# 综合判断:退出码 + 日志中是否有 ERROR
|
||||||
|
success = (proc.returncode == 0) and not run_has_error
|
||||||
|
|
||||||
|
if success:
|
||||||
|
total_pass += 1
|
||||||
|
push(f"✅ 第 {run_idx} 次运行结束 — 成功", "SUCCESS")
|
||||||
|
else:
|
||||||
|
total_fail += 1
|
||||||
|
push(f"❌ 第 {run_idx} 次运行结束 — 失败", "ERROR")
|
||||||
|
|
||||||
|
# 失败重跑逻辑
|
||||||
|
if task.get("retry_on_fail") and run_idx == run_count:
|
||||||
|
push(f"🔁 触发失败重跑 (额外第 1 次)", "WARN")
|
||||||
|
# 追加一次额外运行(简化版:仅追加日志标记)
|
||||||
|
run_count += 1
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
push(f"💥 执行异常: {e}", "ERROR")
|
||||||
|
total_fail += 1
|
||||||
|
|
||||||
|
# ── 生成报告 ───────────────────────────────────────────────────────────────
|
||||||
|
finished_at = datetime.now().isoformat()
|
||||||
|
report = {
|
||||||
|
"task_id": task_id,
|
||||||
|
"task_name": task["name"],
|
||||||
|
"product": task["product"],
|
||||||
|
"total_runs": run_count,
|
||||||
|
"pass": total_pass,
|
||||||
|
"fail": total_fail,
|
||||||
|
"started_at": task.get("started_at"),
|
||||||
|
"finished_at": finished_at,
|
||||||
|
"logs": logs_all,
|
||||||
|
"result": "PASS" if total_fail == 0 else "FAIL"
|
||||||
|
}
|
||||||
|
reports_db[task_id] = report
|
||||||
|
task["status"] = "pass" if total_fail == 0 else "fail"
|
||||||
|
task["finished_at"] = finished_at
|
||||||
|
task["report_id"] = task_id
|
||||||
|
_save_db() # 2. 报告生成完成,任务状态确立后保存
|
||||||
|
|
||||||
|
push(f"\n━━━━━━━━━ 测试完成 ━━━━━━━━━", "INFO")
|
||||||
|
push(f"总计: {run_count} 次 | 通过: {total_pass} | 失败: {total_fail}", "INFO")
|
||||||
|
push(f"整体结论: {'✅ PASS' if total_fail == 0 else '❌ FAIL'}", "SUCCESS" if total_fail == 0 else "ERROR")
|
||||||
|
push("__DONE__", "DONE")
|
||||||
|
|
||||||
|
|
||||||
|
# ── API 路由 ──────────────────────────────────────────────────────────────────
|
||||||
|
@app.route("/api/products")
|
||||||
|
def get_products():
|
||||||
|
return jsonify(PRODUCTS)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/tasks", methods=["GET"])
|
||||||
|
def list_tasks():
|
||||||
|
return jsonify(list(tasks_db.values()))
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/tasks", methods=["POST"])
|
||||||
|
def create_task():
|
||||||
|
body = request.json
|
||||||
|
task_id = str(uuid.uuid4())[:8]
|
||||||
|
product_key = body.get("product", "robogo")
|
||||||
|
product = PRODUCTS.get(product_key, {})
|
||||||
|
entry = product.get("entry", "run_ui_tests.py")
|
||||||
|
|
||||||
|
if entry is None:
|
||||||
|
return jsonify({"error": "该产品暂未接入运行入口"}), 400
|
||||||
|
|
||||||
|
task = {
|
||||||
|
"id": task_id,
|
||||||
|
"name": body.get("name", f"任务_{task_id}"),
|
||||||
|
"product": product_key,
|
||||||
|
"product_name": product.get("name", product_key),
|
||||||
|
"run_count": int(body.get("run_count", 1)),
|
||||||
|
"retry_on_fail": body.get("retry_on_fail", False),
|
||||||
|
"scheduled_at": body.get("scheduled_at"),
|
||||||
|
"created_at": datetime.now().isoformat(),
|
||||||
|
"status": "pending",
|
||||||
|
"pid": None,
|
||||||
|
"started_at": None,
|
||||||
|
"finished_at": None,
|
||||||
|
"report_id": None
|
||||||
|
}
|
||||||
|
tasks_db[task_id] = task
|
||||||
|
log_queues[task_id] = queue.Queue()
|
||||||
|
_save_db() # 3. 任务创建后保存初始状态
|
||||||
|
|
||||||
|
account = body.get("account", "")
|
||||||
|
password = body.get("password", "")
|
||||||
|
scheduled_at = task.get("scheduled_at")
|
||||||
|
|
||||||
|
def _run_task():
|
||||||
|
"""统一入口:处理定时等待后再执行"""
|
||||||
|
log_q = log_queues[task_id]
|
||||||
|
if scheduled_at:
|
||||||
|
try:
|
||||||
|
# 解析定时时间
|
||||||
|
sched_time = datetime.fromisoformat(scheduled_at)
|
||||||
|
task["status"] = "pending"
|
||||||
|
wait_secs = (sched_time - datetime.now()).total_seconds()
|
||||||
|
if wait_secs > 0:
|
||||||
|
msg = {"ts": datetime.now().strftime("%H:%M:%S"), "level": "INFO", "msg": f"⏰ 任务已定时,将在 {scheduled_at} 执行(等待 {int(wait_secs)}秒)"}
|
||||||
|
log_q.put(json.dumps(msg))
|
||||||
|
# 每 30 秒发心跳,防止 SSE 超时断开
|
||||||
|
while (sched_time - datetime.now()).total_seconds() > 0:
|
||||||
|
remaining = int((sched_time - datetime.now()).total_seconds())
|
||||||
|
heartbeat = {"ts": datetime.now().strftime("%H:%M:%S"), "level": "INFO", "msg": f"⏳ 距离定时执行还有 {remaining} 秒..."}
|
||||||
|
log_q.put(json.dumps(heartbeat))
|
||||||
|
time.sleep(min(30, max(remaining, 1)))
|
||||||
|
launch_msg = {"ts": datetime.now().strftime("%H:%M:%S"), "level": "SUCCESS", "msg": "🚀 定时时间已到,开始执行任务!"}
|
||||||
|
log_q.put(json.dumps(launch_msg))
|
||||||
|
except Exception as e:
|
||||||
|
err_msg = {"ts": datetime.now().strftime("%H:%M:%S"), "level": "WARN", "msg": f"⚠️ 定时解析异常,立即执行: {e}"}
|
||||||
|
log_q.put(json.dumps(err_msg))
|
||||||
|
|
||||||
|
_stream_run(task_id, entry, account, password, task["run_count"])
|
||||||
|
|
||||||
|
t = threading.Thread(target=_run_task, daemon=True)
|
||||||
|
t.start()
|
||||||
|
return jsonify(task), 201
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/tasks/<task_id>")
|
||||||
|
def get_task(task_id):
|
||||||
|
task = tasks_db.get(task_id)
|
||||||
|
if not task:
|
||||||
|
return jsonify({"error": "Not Found"}), 404
|
||||||
|
return jsonify(task)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/tasks/<task_id>/logs")
|
||||||
|
def stream_logs(task_id):
|
||||||
|
"""Server-Sent Events 实时日志流"""
|
||||||
|
q = log_queues.get(task_id)
|
||||||
|
if not q:
|
||||||
|
return jsonify({"error": "No log stream"}), 404
|
||||||
|
|
||||||
|
def event_stream():
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
msg = q.get(timeout=30)
|
||||||
|
yield f"data: {msg}\n\n"
|
||||||
|
data = json.loads(msg)
|
||||||
|
if data.get("level") == "DONE":
|
||||||
|
break
|
||||||
|
except queue.Empty:
|
||||||
|
yield f"data: {json.dumps({'level':'PING','msg':''})}\n\n"
|
||||||
|
return Response(event_stream(), content_type="text/event-stream",
|
||||||
|
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/reports")
|
||||||
|
def list_reports():
|
||||||
|
return jsonify(list(reports_db.values()))
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/reports/<task_id>")
|
||||||
|
def get_report(task_id):
|
||||||
|
report = reports_db.get(task_id)
|
||||||
|
if not report:
|
||||||
|
return jsonify({"error": "Not Found"}), 404
|
||||||
|
return jsonify(report)
|
||||||
|
|
||||||
|
|
||||||
|
# ── 前端路由 (SPA 单页) ───────────────────────────────────────────────────────
|
||||||
|
@app.route("/")
|
||||||
|
@app.route("/<path:path>")
|
||||||
|
def serve_index(path=""):
|
||||||
|
return send_from_directory("platform", "index.html")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
print("🚀 自动化平台启动中... http://127.0.0.1:5001")
|
||||||
|
app.run(host="127.0.0.1", port=5001, debug=False, threaded=True)
|
||||||
66556
platform_db.json
Normal file
66556
platform_db.json
Normal file
File diff suppressed because it is too large
Load Diff
21
run_ui_tests.py
Normal file
21
run_ui_tests.py
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from framework.business.data_management import DataManagement
|
||||||
|
from framework.config.settings import Config
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""全业务流程 UI 测试统一入口 (PO模式)"""
|
||||||
|
account = getattr(Config, 'AUTH_ACCOUNT', None) or input("请输入账号:")
|
||||||
|
password = getattr(Config, 'AUTH_PASSWORD', None) or input("请输入密码:")
|
||||||
|
|
||||||
|
dm = DataManagement(headless=False)
|
||||||
|
try:
|
||||||
|
dm.run(account, password)
|
||||||
|
print("✅ 巡检任务执行成功")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ 巡检任务执行失败: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import time
|
||||||
|
main()
|
||||||
Loading…
x
Reference in New Issue
Block a user