test/framework/business/cloud_desktop_page.py

615 lines
28 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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';
// 策略1aria-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)