| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396 |
- import json
- import mimetypes
- from pathlib import Path
- from urllib.parse import parse_qs, urlparse
- from playwright.sync_api import sync_playwright
- BASE_URL = "http://forge-ui.local/forge"
- DIST_DIR = Path(__file__).resolve().parents[3] / "forge-admin-ui" / "dist"
- SCREENSHOTS = {
- "desktop": "/private/tmp/job-workbench-1366.png",
- "wide": "/private/tmp/job-workbench-1920.png",
- "mobile": "/private/tmp/job-workbench-390.png",
- "list": "/private/tmp/job-list-1366.png",
- }
- def response(data=None, code=200, msg="success"):
- return {"code": code, "data": data, "msg": msg}
- def menu_data():
- return [
- {
- "id": 10,
- "resourceType": 1,
- "resourceName": "系统管理",
- "path": "/system",
- "visible": 1,
- "menuStatus": 1,
- "sort": 1,
- "children": [
- {
- "id": 20,
- "parentId": 10,
- "resourceType": 2,
- "resourceName": "定时任务",
- "path": "/system/job-config",
- "component": "system/job-config",
- "visible": 1,
- "menuStatus": 1,
- "sort": 1,
- "children": [
- {
- "id": 21,
- "parentId": 20,
- "resourceType": 2,
- "resourceName": "新建定时任务",
- "path": "/system/job-config/editor",
- "component": "system/job-config.editor",
- "visible": 0,
- "menuStatus": 1,
- "sort": 90,
- },
- {
- "id": 22,
- "parentId": 20,
- "resourceType": 2,
- "resourceName": "编辑定时任务",
- "path": "/system/job-config/editor/:id",
- "component": "system/job-config.editor.[id]",
- "visible": 0,
- "menuStatus": 1,
- "sort": 91,
- },
- ],
- }
- ],
- }
- ]
- def dict_data(dict_type):
- values = {
- "sys_job_status": [("停用", "0", "warning"), ("启用", "1", "success")],
- "sys_job_run_mode": [
- ("任务处理器", "HANDLER", "success"),
- ("本地服务方法", "BEAN", "info"),
- ("远程服务", "RPC", "warning"),
- ],
- "sys_job_sync_status": [
- ("等待同步", "PENDING", "warning"),
- ("同步成功", "SYNCED", "success"),
- ("同步失败", "FAILED", "error"),
- ],
- }
- return [
- {
- "dictLabel": label,
- "dictValue": value,
- "dictSort": index,
- "listClass": style,
- "dictStatus": 1,
- }
- for index, (label, value, style) in enumerate(values.get(dict_type, []), 1)
- ]
- def mock_api(request_log, fail_update=False):
- def handler(route, request):
- parsed = urlparse(request.url)
- path = parsed.path
- query = parse_qs(parsed.query)
- status = 200
- payload = response()
- if path.endswith("/crypto/config"):
- payload = response(
- {
- "enabled": False,
- "enableApiCrypto": False,
- "enableFieldCrypto": False,
- "enableDynamicKey": False,
- "enableReplay": False,
- }
- )
- elif path.endswith("/auth/userInfo"):
- payload = response(
- {
- "userId": 1,
- "username": "admin",
- "realName": "管理员",
- "tenantId": 1,
- "tenantName": "默认租户",
- "tenantIds": [1],
- "activeOrgId": 1,
- "activeOrgName": "平台管理部",
- "roleIds": [1],
- "roleKeys": ["admin"],
- "permissions": ["*:*:*"],
- "admin": True,
- "tenantAdmin": True,
- }
- )
- elif path.endswith("/system/tenant/userTenantConfig"):
- payload = response(
- {
- "tenantId": 1,
- "systemName": "Forge Admin",
- "browserTitle": "Forge Admin",
- "systemLayout": "top-menu",
- "systemTheme": "#2563eb",
- }
- )
- elif path.endswith("/auth/current/menu"):
- payload = response(menu_data())
- elif path.endswith("/system/dict/data/list"):
- payload = response(dict_data(query.get("dictType", [""])[0]))
- elif path.endswith("/job/config/executors"):
- payload = response(
- [
- {
- "code": "inventoryCloseHandler",
- "displayName": "库存日结处理器",
- "description": "每日汇总库存流水并生成日结结果",
- "group": "INVENTORY",
- "source": "JobHandler",
- "executeMode": "HANDLER",
- },
- {
- "code": "orderSyncHandler",
- "displayName": "订单同步处理器",
- "description": "同步待处理订单到业务系统",
- "group": "ORDER",
- "source": "ScheduledJob",
- "executeMode": "HANDLER",
- },
- ]
- )
- elif path.endswith("/job/config/cron/preview"):
- expression = (request.post_data_json or {}).get("cronExpression", "")
- payload = response(
- {
- "cronExpression": expression,
- "description": "工作日每天 02:15 执行"
- if expression == "0 15 2 ? * MON-FRI"
- else "每天 02:00 执行",
- "nextFireTimes": [
- "2026-07-20T02:00:00",
- "2026-07-21T02:00:00",
- "2026-07-22T02:00:00",
- "2026-07-23T02:00:00",
- "2026-07-24T02:00:00",
- ],
- }
- )
- elif path.endswith("/job/config/page"):
- payload = response(
- {
- "records": [
- {
- "id": 9,
- "jobName": "库存日结",
- "jobGroup": "INVENTORY",
- "description": "每日汇总库存流水",
- "executeMode": "HANDLER",
- "executorHandler": "inventoryCloseHandler",
- "executionSummary": "库存日结处理器",
- "cronExpression": "0 0 2 * * ?",
- "scheduleSummary": "每天 02:00 执行",
- "status": 1,
- "syncStatus": "SYNCED",
- "nextFireTime": "2026-07-20T02:00:00",
- "lastExecutionStatus": 1,
- "lastExecutionTime": "2026-07-19T02:00:03",
- }
- ],
- "total": 1,
- }
- )
- elif path.endswith("/job/config/9") and request.method == "GET":
- payload = response(
- {
- "id": 9,
- "jobName": "库存日结",
- "jobGroup": "INVENTORY",
- "description": "每日汇总库存流水",
- "executeMode": "HANDLER",
- "executorHandler": "inventoryCloseHandler",
- "cronExpression": "0 15 2 ? * MON-FRI",
- "jobParam": "{\"warehouseId\":1001}",
- "status": 1,
- "retryCount": 0,
- "version": 3,
- "syncStatus": "SYNCED",
- }
- )
- elif path.endswith("/job/config") and request.method == "POST":
- request_log.append({"createPayload": request.post_data_json})
- payload = response({"id": 10})
- elif path.endswith("/job/config") and request.method == "PUT":
- request_log.append({"updatePayload": request.post_data_json})
- if fail_update:
- status = 500
- payload = response(None, 500, "配置已保存,但调度同步失败:调度服务暂不可用")
- elif path.endswith("/job/log/clean"):
- payload = response(12)
- else:
- payload = response(None)
- route.fulfill(
- status=status,
- content_type="application/json; charset=utf-8",
- body=json.dumps(payload, ensure_ascii=False),
- )
- return handler
- def new_page(browser, viewport, request_log, fail_update=False):
- context = browser.new_context(viewport=viewport, locale="zh-CN")
- context.add_init_script(
- "localStorage.setItem('prod_auth', JSON.stringify({accessToken: 'ui-test-token'}));"
- )
- page = context.new_page()
- api_handler = mock_api(request_log, fail_update=fail_update)
- def app_handler(route, request):
- path = urlparse(request.url).path
- if path.startswith("/forge-api/"):
- api_handler(route, request)
- return
- relative_path = path.removeprefix("/forge/")
- target = DIST_DIR / relative_path
- if not target.is_file():
- target = DIST_DIR / "index.html"
- content_type = mimetypes.guess_type(target.name)[0] or "application/octet-stream"
- route.fulfill(status=200, content_type=content_type, body=target.read_bytes())
- page.route("http://forge-ui.local/**", app_handler)
- return context, page
- def wait_for_app(page, selector):
- page.wait_for_load_state("networkidle")
- page.locator(selector).wait_for(state="visible", timeout=30000)
- def layout_metrics(page):
- return page.evaluate(
- """
- () => {
- const visible = element => {
- const style = getComputedStyle(element)
- const rect = element.getBoundingClientRect()
- return style.display !== 'none' && style.visibility !== 'hidden'
- && rect.width > 0 && rect.height > 0
- }
- return {
- viewportWidth: innerWidth,
- rootScrollWidth: document.documentElement.scrollWidth,
- bodyScrollWidth: document.body.scrollWidth,
- horizontalOverflow: document.documentElement.scrollWidth > innerWidth + 1,
- clippedButtons: [...document.querySelectorAll('button')]
- .filter(visible)
- .filter(element => element.scrollWidth > element.clientWidth + 1)
- .map(element => element.textContent.trim()),
- }
- }
- """
- )
- def run():
- results = {"screenshots": SCREENSHOTS, "checks": {}}
- request_log = []
- console_errors = []
- with sync_playwright() as playwright:
- browser = playwright.chromium.launch(headless=True)
- context, page = new_page(browser, {"width": 1366, "height": 768}, request_log)
- page.on(
- "console",
- lambda message: console_errors.append(message.text)
- if message.type == "error"
- else None,
- )
- page.goto(f"{BASE_URL}/system/job-config/editor", wait_until="domcontentloaded")
- wait_for_app(page, ".job-workbench")
- assert page.locator(".job-config-page").count() == 0
- page.locator('input[placeholder="例如:库存日结"]').fill("库存日结")
- page.locator('input[placeholder="例如:INVENTORY"]').fill("INVENTORY")
- page.locator('textarea[placeholder="简要说明这个任务负责什么"]').fill(
- "每日汇总库存流水并生成日结结果"
- )
- page.get_by_text("搜索并选择任务处理器", exact=True).click()
- page.get_by_text("库存日结处理器", exact=True).last.click()
- page.get_by_text("每天 02:00 执行", exact=True).first.wait_for()
- assert page.get_by_text("保存后停用", exact=True).count() >= 1
- page.screenshot(path=SCREENSHOTS["desktop"], full_page=True)
- results["checks"]["desktop1366"] = layout_metrics(page)
- page.get_by_role("button", name="保存任务").first.click()
- page.wait_for_url(f"{BASE_URL}/system/job-config")
- wait_for_app(page, ".job-config-page")
- page.get_by_text("立即运行", exact=True).wait_for()
- page.get_by_text("查看日志", exact=True).wait_for()
- assert page.get_by_text("0 0 2 * * ?", exact=True).count() == 0
- page.get_by_role("button", name="更多").click()
- page.get_by_text("清空全部日志", exact=True).click()
- page.get_by_text("确定清空全部任务运行日志吗?此操作不可恢复。", exact=True).wait_for()
- page.get_by_role("button", name="取消").last.click()
- page.screenshot(path=SCREENSHOTS["list"], full_page=True)
- create_payload = next(
- item["createPayload"] for item in request_log if "createPayload" in item
- )
- assert create_payload["status"] == 0
- assert create_payload["executorHandler"] == "inventoryCloseHandler"
- assert create_payload["cronExpression"] == "0 0 2 * * ?"
- results["checks"]["createPayload"] = create_payload
- context.close()
- context, page = new_page(
- browser, {"width": 1920, "height": 1080}, request_log, fail_update=True
- )
- page.goto(f"{BASE_URL}/system/job-config/editor/9", wait_until="domcontentloaded")
- wait_for_app(page, ".job-workbench")
- assert page.locator(".job-config-page").count() == 0
- cron_input = page.locator('input[placeholder^="Quartz 6 段表达式"]')
- assert cron_input.input_value() == "0 15 2 ? * MON-FRI"
- assert page.locator('input[placeholder="例如:库存日结"]').is_disabled()
- assert page.locator(".future-list li").count() == 5
- page.screenshot(path=SCREENSHOTS["wide"], full_page=True)
- results["checks"]["wide1920"] = layout_metrics(page)
- page.locator('textarea[placeholder="简要说明这个任务负责什么"]').fill(
- "每日汇总库存流水和库存余额"
- )
- page.get_by_role("button", name="取消").first.click()
- page.get_by_text("未保存变更", exact=True).wait_for()
- page.get_by_role("button", name="继续编辑").click()
- assert page.url.endswith("/system/job-config/editor/9")
- page.get_by_role("button", name="保存任务").first.click()
- page.get_by_text("配置已保存,调度同步失败", exact=True).last.wait_for()
- assert page.get_by_role("button", name="保存任务").first.is_disabled()
- results["checks"]["complexCronPreserved"] = cron_input.input_value()
- results["checks"]["partialSaveVisible"] = True
- context.close()
- context, page = new_page(browser, {"width": 390, "height": 844}, request_log)
- page.goto(f"{BASE_URL}/system/job-config/editor", wait_until="domcontentloaded")
- wait_for_app(page, ".job-workbench")
- assert page.locator(".mobile-actions").is_visible()
- assert not page.get_by_role("button", name="保存任务").first.is_visible()
- assert page.locator(".section-nav button").count() == 4
- page.get_by_role("button", name="执行计划").click()
- page.screenshot(path=SCREENSHOTS["mobile"], full_page=True)
- results["checks"]["mobile390"] = layout_metrics(page)
- context.close()
- browser.close()
- results["consoleErrors"] = console_errors
- print(json.dumps(results, ensure_ascii=False, indent=2))
- if __name__ == "__main__":
- run()
|