ui-validation.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396
  1. import json
  2. import mimetypes
  3. from pathlib import Path
  4. from urllib.parse import parse_qs, urlparse
  5. from playwright.sync_api import sync_playwright
  6. BASE_URL = "http://forge-ui.local/forge"
  7. DIST_DIR = Path(__file__).resolve().parents[3] / "forge-admin-ui" / "dist"
  8. SCREENSHOTS = {
  9. "desktop": "/private/tmp/job-workbench-1366.png",
  10. "wide": "/private/tmp/job-workbench-1920.png",
  11. "mobile": "/private/tmp/job-workbench-390.png",
  12. "list": "/private/tmp/job-list-1366.png",
  13. }
  14. def response(data=None, code=200, msg="success"):
  15. return {"code": code, "data": data, "msg": msg}
  16. def menu_data():
  17. return [
  18. {
  19. "id": 10,
  20. "resourceType": 1,
  21. "resourceName": "系统管理",
  22. "path": "/system",
  23. "visible": 1,
  24. "menuStatus": 1,
  25. "sort": 1,
  26. "children": [
  27. {
  28. "id": 20,
  29. "parentId": 10,
  30. "resourceType": 2,
  31. "resourceName": "定时任务",
  32. "path": "/system/job-config",
  33. "component": "system/job-config",
  34. "visible": 1,
  35. "menuStatus": 1,
  36. "sort": 1,
  37. "children": [
  38. {
  39. "id": 21,
  40. "parentId": 20,
  41. "resourceType": 2,
  42. "resourceName": "新建定时任务",
  43. "path": "/system/job-config/editor",
  44. "component": "system/job-config.editor",
  45. "visible": 0,
  46. "menuStatus": 1,
  47. "sort": 90,
  48. },
  49. {
  50. "id": 22,
  51. "parentId": 20,
  52. "resourceType": 2,
  53. "resourceName": "编辑定时任务",
  54. "path": "/system/job-config/editor/:id",
  55. "component": "system/job-config.editor.[id]",
  56. "visible": 0,
  57. "menuStatus": 1,
  58. "sort": 91,
  59. },
  60. ],
  61. }
  62. ],
  63. }
  64. ]
  65. def dict_data(dict_type):
  66. values = {
  67. "sys_job_status": [("停用", "0", "warning"), ("启用", "1", "success")],
  68. "sys_job_run_mode": [
  69. ("任务处理器", "HANDLER", "success"),
  70. ("本地服务方法", "BEAN", "info"),
  71. ("远程服务", "RPC", "warning"),
  72. ],
  73. "sys_job_sync_status": [
  74. ("等待同步", "PENDING", "warning"),
  75. ("同步成功", "SYNCED", "success"),
  76. ("同步失败", "FAILED", "error"),
  77. ],
  78. }
  79. return [
  80. {
  81. "dictLabel": label,
  82. "dictValue": value,
  83. "dictSort": index,
  84. "listClass": style,
  85. "dictStatus": 1,
  86. }
  87. for index, (label, value, style) in enumerate(values.get(dict_type, []), 1)
  88. ]
  89. def mock_api(request_log, fail_update=False):
  90. def handler(route, request):
  91. parsed = urlparse(request.url)
  92. path = parsed.path
  93. query = parse_qs(parsed.query)
  94. status = 200
  95. payload = response()
  96. if path.endswith("/crypto/config"):
  97. payload = response(
  98. {
  99. "enabled": False,
  100. "enableApiCrypto": False,
  101. "enableFieldCrypto": False,
  102. "enableDynamicKey": False,
  103. "enableReplay": False,
  104. }
  105. )
  106. elif path.endswith("/auth/userInfo"):
  107. payload = response(
  108. {
  109. "userId": 1,
  110. "username": "admin",
  111. "realName": "管理员",
  112. "tenantId": 1,
  113. "tenantName": "默认租户",
  114. "tenantIds": [1],
  115. "activeOrgId": 1,
  116. "activeOrgName": "平台管理部",
  117. "roleIds": [1],
  118. "roleKeys": ["admin"],
  119. "permissions": ["*:*:*"],
  120. "admin": True,
  121. "tenantAdmin": True,
  122. }
  123. )
  124. elif path.endswith("/system/tenant/userTenantConfig"):
  125. payload = response(
  126. {
  127. "tenantId": 1,
  128. "systemName": "Forge Admin",
  129. "browserTitle": "Forge Admin",
  130. "systemLayout": "top-menu",
  131. "systemTheme": "#2563eb",
  132. }
  133. )
  134. elif path.endswith("/auth/current/menu"):
  135. payload = response(menu_data())
  136. elif path.endswith("/system/dict/data/list"):
  137. payload = response(dict_data(query.get("dictType", [""])[0]))
  138. elif path.endswith("/job/config/executors"):
  139. payload = response(
  140. [
  141. {
  142. "code": "inventoryCloseHandler",
  143. "displayName": "库存日结处理器",
  144. "description": "每日汇总库存流水并生成日结结果",
  145. "group": "INVENTORY",
  146. "source": "JobHandler",
  147. "executeMode": "HANDLER",
  148. },
  149. {
  150. "code": "orderSyncHandler",
  151. "displayName": "订单同步处理器",
  152. "description": "同步待处理订单到业务系统",
  153. "group": "ORDER",
  154. "source": "ScheduledJob",
  155. "executeMode": "HANDLER",
  156. },
  157. ]
  158. )
  159. elif path.endswith("/job/config/cron/preview"):
  160. expression = (request.post_data_json or {}).get("cronExpression", "")
  161. payload = response(
  162. {
  163. "cronExpression": expression,
  164. "description": "工作日每天 02:15 执行"
  165. if expression == "0 15 2 ? * MON-FRI"
  166. else "每天 02:00 执行",
  167. "nextFireTimes": [
  168. "2026-07-20T02:00:00",
  169. "2026-07-21T02:00:00",
  170. "2026-07-22T02:00:00",
  171. "2026-07-23T02:00:00",
  172. "2026-07-24T02:00:00",
  173. ],
  174. }
  175. )
  176. elif path.endswith("/job/config/page"):
  177. payload = response(
  178. {
  179. "records": [
  180. {
  181. "id": 9,
  182. "jobName": "库存日结",
  183. "jobGroup": "INVENTORY",
  184. "description": "每日汇总库存流水",
  185. "executeMode": "HANDLER",
  186. "executorHandler": "inventoryCloseHandler",
  187. "executionSummary": "库存日结处理器",
  188. "cronExpression": "0 0 2 * * ?",
  189. "scheduleSummary": "每天 02:00 执行",
  190. "status": 1,
  191. "syncStatus": "SYNCED",
  192. "nextFireTime": "2026-07-20T02:00:00",
  193. "lastExecutionStatus": 1,
  194. "lastExecutionTime": "2026-07-19T02:00:03",
  195. }
  196. ],
  197. "total": 1,
  198. }
  199. )
  200. elif path.endswith("/job/config/9") and request.method == "GET":
  201. payload = response(
  202. {
  203. "id": 9,
  204. "jobName": "库存日结",
  205. "jobGroup": "INVENTORY",
  206. "description": "每日汇总库存流水",
  207. "executeMode": "HANDLER",
  208. "executorHandler": "inventoryCloseHandler",
  209. "cronExpression": "0 15 2 ? * MON-FRI",
  210. "jobParam": "{\"warehouseId\":1001}",
  211. "status": 1,
  212. "retryCount": 0,
  213. "version": 3,
  214. "syncStatus": "SYNCED",
  215. }
  216. )
  217. elif path.endswith("/job/config") and request.method == "POST":
  218. request_log.append({"createPayload": request.post_data_json})
  219. payload = response({"id": 10})
  220. elif path.endswith("/job/config") and request.method == "PUT":
  221. request_log.append({"updatePayload": request.post_data_json})
  222. if fail_update:
  223. status = 500
  224. payload = response(None, 500, "配置已保存,但调度同步失败:调度服务暂不可用")
  225. elif path.endswith("/job/log/clean"):
  226. payload = response(12)
  227. else:
  228. payload = response(None)
  229. route.fulfill(
  230. status=status,
  231. content_type="application/json; charset=utf-8",
  232. body=json.dumps(payload, ensure_ascii=False),
  233. )
  234. return handler
  235. def new_page(browser, viewport, request_log, fail_update=False):
  236. context = browser.new_context(viewport=viewport, locale="zh-CN")
  237. context.add_init_script(
  238. "localStorage.setItem('prod_auth', JSON.stringify({accessToken: 'ui-test-token'}));"
  239. )
  240. page = context.new_page()
  241. api_handler = mock_api(request_log, fail_update=fail_update)
  242. def app_handler(route, request):
  243. path = urlparse(request.url).path
  244. if path.startswith("/forge-api/"):
  245. api_handler(route, request)
  246. return
  247. relative_path = path.removeprefix("/forge/")
  248. target = DIST_DIR / relative_path
  249. if not target.is_file():
  250. target = DIST_DIR / "index.html"
  251. content_type = mimetypes.guess_type(target.name)[0] or "application/octet-stream"
  252. route.fulfill(status=200, content_type=content_type, body=target.read_bytes())
  253. page.route("http://forge-ui.local/**", app_handler)
  254. return context, page
  255. def wait_for_app(page, selector):
  256. page.wait_for_load_state("networkidle")
  257. page.locator(selector).wait_for(state="visible", timeout=30000)
  258. def layout_metrics(page):
  259. return page.evaluate(
  260. """
  261. () => {
  262. const visible = element => {
  263. const style = getComputedStyle(element)
  264. const rect = element.getBoundingClientRect()
  265. return style.display !== 'none' && style.visibility !== 'hidden'
  266. && rect.width > 0 && rect.height > 0
  267. }
  268. return {
  269. viewportWidth: innerWidth,
  270. rootScrollWidth: document.documentElement.scrollWidth,
  271. bodyScrollWidth: document.body.scrollWidth,
  272. horizontalOverflow: document.documentElement.scrollWidth > innerWidth + 1,
  273. clippedButtons: [...document.querySelectorAll('button')]
  274. .filter(visible)
  275. .filter(element => element.scrollWidth > element.clientWidth + 1)
  276. .map(element => element.textContent.trim()),
  277. }
  278. }
  279. """
  280. )
  281. def run():
  282. results = {"screenshots": SCREENSHOTS, "checks": {}}
  283. request_log = []
  284. console_errors = []
  285. with sync_playwright() as playwright:
  286. browser = playwright.chromium.launch(headless=True)
  287. context, page = new_page(browser, {"width": 1366, "height": 768}, request_log)
  288. page.on(
  289. "console",
  290. lambda message: console_errors.append(message.text)
  291. if message.type == "error"
  292. else None,
  293. )
  294. page.goto(f"{BASE_URL}/system/job-config/editor", wait_until="domcontentloaded")
  295. wait_for_app(page, ".job-workbench")
  296. assert page.locator(".job-config-page").count() == 0
  297. page.locator('input[placeholder="例如:库存日结"]').fill("库存日结")
  298. page.locator('input[placeholder="例如:INVENTORY"]').fill("INVENTORY")
  299. page.locator('textarea[placeholder="简要说明这个任务负责什么"]').fill(
  300. "每日汇总库存流水并生成日结结果"
  301. )
  302. page.get_by_text("搜索并选择任务处理器", exact=True).click()
  303. page.get_by_text("库存日结处理器", exact=True).last.click()
  304. page.get_by_text("每天 02:00 执行", exact=True).first.wait_for()
  305. assert page.get_by_text("保存后停用", exact=True).count() >= 1
  306. page.screenshot(path=SCREENSHOTS["desktop"], full_page=True)
  307. results["checks"]["desktop1366"] = layout_metrics(page)
  308. page.get_by_role("button", name="保存任务").first.click()
  309. page.wait_for_url(f"{BASE_URL}/system/job-config")
  310. wait_for_app(page, ".job-config-page")
  311. page.get_by_text("立即运行", exact=True).wait_for()
  312. page.get_by_text("查看日志", exact=True).wait_for()
  313. assert page.get_by_text("0 0 2 * * ?", exact=True).count() == 0
  314. page.get_by_role("button", name="更多").click()
  315. page.get_by_text("清空全部日志", exact=True).click()
  316. page.get_by_text("确定清空全部任务运行日志吗?此操作不可恢复。", exact=True).wait_for()
  317. page.get_by_role("button", name="取消").last.click()
  318. page.screenshot(path=SCREENSHOTS["list"], full_page=True)
  319. create_payload = next(
  320. item["createPayload"] for item in request_log if "createPayload" in item
  321. )
  322. assert create_payload["status"] == 0
  323. assert create_payload["executorHandler"] == "inventoryCloseHandler"
  324. assert create_payload["cronExpression"] == "0 0 2 * * ?"
  325. results["checks"]["createPayload"] = create_payload
  326. context.close()
  327. context, page = new_page(
  328. browser, {"width": 1920, "height": 1080}, request_log, fail_update=True
  329. )
  330. page.goto(f"{BASE_URL}/system/job-config/editor/9", wait_until="domcontentloaded")
  331. wait_for_app(page, ".job-workbench")
  332. assert page.locator(".job-config-page").count() == 0
  333. cron_input = page.locator('input[placeholder^="Quartz 6 段表达式"]')
  334. assert cron_input.input_value() == "0 15 2 ? * MON-FRI"
  335. assert page.locator('input[placeholder="例如:库存日结"]').is_disabled()
  336. assert page.locator(".future-list li").count() == 5
  337. page.screenshot(path=SCREENSHOTS["wide"], full_page=True)
  338. results["checks"]["wide1920"] = layout_metrics(page)
  339. page.locator('textarea[placeholder="简要说明这个任务负责什么"]').fill(
  340. "每日汇总库存流水和库存余额"
  341. )
  342. page.get_by_role("button", name="取消").first.click()
  343. page.get_by_text("未保存变更", exact=True).wait_for()
  344. page.get_by_role("button", name="继续编辑").click()
  345. assert page.url.endswith("/system/job-config/editor/9")
  346. page.get_by_role("button", name="保存任务").first.click()
  347. page.get_by_text("配置已保存,调度同步失败", exact=True).last.wait_for()
  348. assert page.get_by_role("button", name="保存任务").first.is_disabled()
  349. results["checks"]["complexCronPreserved"] = cron_input.input_value()
  350. results["checks"]["partialSaveVisible"] = True
  351. context.close()
  352. context, page = new_page(browser, {"width": 390, "height": 844}, request_log)
  353. page.goto(f"{BASE_URL}/system/job-config/editor", wait_until="domcontentloaded")
  354. wait_for_app(page, ".job-workbench")
  355. assert page.locator(".mobile-actions").is_visible()
  356. assert not page.get_by_role("button", name="保存任务").first.is_visible()
  357. assert page.locator(".section-nav button").count() == 4
  358. page.get_by_role("button", name="执行计划").click()
  359. page.screenshot(path=SCREENSHOTS["mobile"], full_page=True)
  360. results["checks"]["mobile390"] = layout_metrics(page)
  361. context.close()
  362. browser.close()
  363. results["consoleErrors"] = console_errors
  364. print(json.dumps(results, ensure_ascii=False, indent=2))
  365. if __name__ == "__main__":
  366. run()