|
| 1 | +#!/usr/bin/env python3 |
| 2 | +# -*- coding: utf-8 -*- |
| 3 | +"""1294.py: 复现与验证 LaTeX 自递归宏粘贴崩溃的自动化测试脚本。 |
| 4 | +
|
| 5 | +支持用例: |
| 6 | + - 1294_1.tex: 无参自递归宏 (用例 249) |
| 7 | + - 1294_2.tex: 带参自递归宏 (用例 289) |
| 8 | + - 1294_3.tex: 相互递归宏 (crash_pattern_c) |
| 9 | +
|
| 10 | +启动命令: xmake r stem -d |
| 11 | +用法: |
| 12 | + python3 TeXmacs/tests/python/1294.py # 默认依序测试 1294_1.tex, 1294_2.tex, 1294_3.tex |
| 13 | + python3 TeXmacs/tests/python/1294.py [path/to.tex] # 测试指定文件 |
| 14 | +""" |
| 15 | + |
| 16 | +import os |
| 17 | +import sys |
| 18 | +import glob |
| 19 | +import time |
| 20 | +import subprocess |
| 21 | +import threading |
| 22 | + |
| 23 | +from Xlib import X, display, Xatom |
| 24 | +from Xlib.protocol import event |
| 25 | +from pynput.keyboard import Controller as Kbd, Key |
| 26 | +from pynput.mouse import Controller as Mouse, Button |
| 27 | + |
| 28 | +# 定位工程根目录 |
| 29 | +_cur = os.path.dirname(os.path.abspath(__file__)) |
| 30 | +while _cur and _cur != "/" and not os.path.isdir(os.path.join(_cur, "TeXmacs")): |
| 31 | + _cur = os.path.dirname(_cur) |
| 32 | +ROOT_DIR = _cur if _cur else os.path.dirname(os.path.abspath(__file__)) |
| 33 | +HERE = ROOT_DIR |
| 34 | + |
| 35 | +# 4K (3840x2312) 实测坐标 |
| 36 | +POS_DOC = (960, 900) |
| 37 | +POS_EDIT_MENU = (220, 140) |
| 38 | +POS_PASTE_FROM = (227, 1008) |
| 39 | +POS_LATEX_ITEM = (618, 1135) |
| 40 | + |
| 41 | +DRAFT_DIRS = [ |
| 42 | + os.path.expanduser("~/文档/LiiiSTEM/no_name"), |
| 43 | + os.path.expanduser("~/Documents/LiiiSTEM/no_name"), |
| 44 | +] |
| 45 | + |
| 46 | +DEFAULT_CASES = [ |
| 47 | + os.path.join(ROOT_DIR, "TeXmacs", "tests", "tex", "1294_1.tex"), |
| 48 | + os.path.join(ROOT_DIR, "TeXmacs", "tests", "tex", "1294_2.tex"), |
| 49 | + os.path.join(ROOT_DIR, "TeXmacs", "tests", "tex", "1294_3.tex"), |
| 50 | +] |
| 51 | + |
| 52 | + |
| 53 | +class X11Clipboard: |
| 54 | + """X11 剪贴板持有者,在后台响应 SelectionRequest。""" |
| 55 | + |
| 56 | + def __init__(self): |
| 57 | + self.disp = display.Display() |
| 58 | + self.screen = self.disp.screen() |
| 59 | + self.window = self.screen.root.create_window( |
| 60 | + 0, 0, 1, 1, 0, self.screen.root_depth |
| 61 | + ) |
| 62 | + self.CLIPBOARD = self.disp.intern_atom('CLIPBOARD') |
| 63 | + self.PRIMARY = self.disp.intern_atom('PRIMARY') |
| 64 | + self.TARGETS = self.disp.intern_atom('TARGETS') |
| 65 | + self.UTF8 = self.disp.intern_atom('UTF8_STRING') |
| 66 | + self.TEXT = self.disp.intern_atom('TEXT') |
| 67 | + self.STRING = Xatom.STRING |
| 68 | + self.text = "" |
| 69 | + self.running = True |
| 70 | + self.thread = threading.Thread(target=self._loop, daemon=True) |
| 71 | + self.thread.start() |
| 72 | + |
| 73 | + def set_text(self, text): |
| 74 | + self.text = text |
| 75 | + self.window.set_selection_owner(self.CLIPBOARD, X.CurrentTime) |
| 76 | + self.window.set_selection_owner(self.PRIMARY, X.CurrentTime) |
| 77 | + self.disp.flush() |
| 78 | + |
| 79 | + def _loop(self): |
| 80 | + while self.running: |
| 81 | + try: |
| 82 | + if self.disp.pending_events() == 0: |
| 83 | + time.sleep(0.01) |
| 84 | + continue |
| 85 | + e = self.disp.next_event() |
| 86 | + if e.type == X.SelectionRequest: |
| 87 | + req_win = self.disp.create_resource_object('window', e.requestor) |
| 88 | + prop = e.property |
| 89 | + if prop == X.NONE: |
| 90 | + prop = e.target |
| 91 | + if e.target == self.TARGETS: |
| 92 | + req_win.change_property( |
| 93 | + prop, Xatom.ATOM, 32, |
| 94 | + [self.TARGETS, self.UTF8, self.STRING, self.TEXT] |
| 95 | + ) |
| 96 | + ev = event.SelectionNotify( |
| 97 | + time=e.time, requestor=e.requestor, |
| 98 | + selection=e.selection, target=e.target, |
| 99 | + property=prop |
| 100 | + ) |
| 101 | + elif e.target in (self.UTF8, self.STRING, self.TEXT): |
| 102 | + req_win.change_property( |
| 103 | + prop, e.target, 8, self.text.encode('utf-8') |
| 104 | + ) |
| 105 | + ev = event.SelectionNotify( |
| 106 | + time=e.time, requestor=e.requestor, |
| 107 | + selection=e.selection, target=e.target, |
| 108 | + property=prop |
| 109 | + ) |
| 110 | + else: |
| 111 | + ev = event.SelectionNotify( |
| 112 | + time=e.time, requestor=e.requestor, |
| 113 | + selection=e.selection, target=e.target, |
| 114 | + property=X.NONE |
| 115 | + ) |
| 116 | + req_win.send_event(ev) |
| 117 | + self.disp.flush() |
| 118 | + except Exception: |
| 119 | + pass |
| 120 | + |
| 121 | + def stop(self): |
| 122 | + self.running = False |
| 123 | + |
| 124 | + |
| 125 | +def clean_drafts(): |
| 126 | + """清理自动保存草稿,防止弹窗阻碍启动。""" |
| 127 | + for d in DRAFT_DIRS: |
| 128 | + if os.path.isdir(d): |
| 129 | + for f in glob.glob(os.path.join(d, "draft_*.tmu")): |
| 130 | + try: |
| 131 | + os.remove(f) |
| 132 | + except OSError: |
| 133 | + pass |
| 134 | + |
| 135 | + |
| 136 | +def activate_window(): |
| 137 | + """激活 Mogan STEM 窗口。""" |
| 138 | + js_code = ( |
| 139 | + 'const wins = workspace.windowList ? workspace.windowList() : workspace.windows();\n' |
| 140 | + 'for (let i = 0; i < wins.length; i++) {\n' |
| 141 | + ' const w = wins[i];\n' |
| 142 | + ' const cls = (w.resourceClass || "").toLowerCase();\n' |
| 143 | + ' const name = (w.resourceName || "").toLowerCase();\n' |
| 144 | + ' const title = (w.caption || "");\n' |
| 145 | + ' if (cls.includes("stem") || name.includes("stem") || title.includes("Mogan")) {\n' |
| 146 | + ' workspace.activeWindow = w;\n' |
| 147 | + ' if (w.activate) w.activate();\n' |
| 148 | + ' }\n' |
| 149 | + '}\n' |
| 150 | + ) |
| 151 | + tmp_js = "/tmp/activate_moganstem.js" |
| 152 | + with open(tmp_js, "w", encoding="utf-8") as f: |
| 153 | + f.write(js_code) |
| 154 | + |
| 155 | + try: |
| 156 | + import dbus |
| 157 | + bus = dbus.SessionBus() |
| 158 | + kwin = bus.get_object("org.kde.KWin", "/Scripting") |
| 159 | + iface = dbus.Interface(kwin, "org.kde.kwin.Scripting") |
| 160 | + script_id = iface.loadScript(tmp_js) |
| 161 | + script_obj = bus.get_object("org.kde.KWin", f"/Scripting/Script{script_id}") |
| 162 | + script_iface = dbus.Interface(script_obj, "org.kde.kwin.Script") |
| 163 | + script_iface.run() |
| 164 | + iface.unloadScript(str(script_id)) |
| 165 | + except Exception: |
| 166 | + pass |
| 167 | + |
| 168 | + try: |
| 169 | + d = display.Display() |
| 170 | + root = d.screen().root |
| 171 | + NET_ACTIVE_WINDOW = d.intern_atom('_NET_ACTIVE_WINDOW') |
| 172 | + NET_CLIENT_LIST = d.intern_atom('_NET_CLIENT_LIST') |
| 173 | + prop = root.get_full_property(NET_CLIENT_LIST, Xatom.WINDOW) |
| 174 | + if prop: |
| 175 | + for wid in prop.value: |
| 176 | + win = d.create_resource_object('window', wid) |
| 177 | + c = str(win.get_wm_class() or "").lower() |
| 178 | + if "stem" in c or "mogan" in c: |
| 179 | + data = [2, X.CurrentTime, 0, 0, 0] |
| 180 | + ev = event.ClientMessage(window=wid, client_type=NET_ACTIVE_WINDOW, data=(32, data)) |
| 181 | + root.send_event(ev, event_mask=X.SubstructureRedirectMask | X.SubstructureNotifyMask) |
| 182 | + d.flush() |
| 183 | + break |
| 184 | + except Exception: |
| 185 | + pass |
| 186 | + |
| 187 | + |
| 188 | +def is_window_ready(): |
| 189 | + try: |
| 190 | + out = subprocess.check_output( |
| 191 | + ["xprop", "-root", "_NET_CLIENT_LIST"], |
| 192 | + text=True, stderr=subprocess.DEVNULL |
| 193 | + ) |
| 194 | + for wid in out.split("#")[-1].replace(",", " ").split(): |
| 195 | + c = subprocess.check_output( |
| 196 | + ["xprop", "-id", wid, "WM_CLASS"], |
| 197 | + text=True, stderr=subprocess.DEVNULL |
| 198 | + ) |
| 199 | + if "moganstem" in c.lower() or "liiistem" in c.lower(): |
| 200 | + return True |
| 201 | + except Exception: |
| 202 | + pass |
| 203 | + return False |
| 204 | + |
| 205 | + |
| 206 | +class TestRunner: |
| 207 | + def __init__(self): |
| 208 | + self.proc = None |
| 209 | + self.clip = X11Clipboard() |
| 210 | + self.kbd = Kbd() |
| 211 | + self.mouse = Mouse() |
| 212 | + |
| 213 | + def start_app(self): |
| 214 | + self.stop_app() |
| 215 | + clean_drafts() |
| 216 | + subprocess.run(["pkill", "-9", "-f", "moganstem"], stderr=subprocess.DEVNULL) |
| 217 | + |
| 218 | + print("[1294] Starting Mogan STEM via: xmake r stem -d") |
| 219 | + self.proc = subprocess.Popen(["xmake", "r", "stem", "-d"], cwd=HERE) |
| 220 | + |
| 221 | + t0 = time.time() |
| 222 | + while time.time() - t0 < 15: |
| 223 | + if is_window_ready(): |
| 224 | + break |
| 225 | + time.sleep(0.3) |
| 226 | + |
| 227 | + time.sleep(1.0) |
| 228 | + activate_window() |
| 229 | + time.sleep(0.5) |
| 230 | + |
| 231 | + def is_alive(self): |
| 232 | + if self.proc is None: |
| 233 | + return False |
| 234 | + return self.proc.poll() is None |
| 235 | + |
| 236 | + def stop_app(self): |
| 237 | + if self.proc is not None: |
| 238 | + if self.proc.poll() is None: |
| 239 | + self.proc.terminate() |
| 240 | + try: |
| 241 | + self.proc.wait(timeout=2) |
| 242 | + except Exception: |
| 243 | + self.proc.kill() |
| 244 | + self.proc = None |
| 245 | + subprocess.run(["pkill", "-9", "-f", "moganstem"], stderr=subprocess.DEVNULL) |
| 246 | + clean_drafts() |
| 247 | + |
| 248 | + def run_case(self, case_name, tex_content): |
| 249 | + """运行单个用例的粘贴测试。""" |
| 250 | + if not self.is_alive(): |
| 251 | + self.start_app() |
| 252 | + |
| 253 | + print(f"\n--- Running: {case_name} ---") |
| 254 | + print(f"Content: {tex_content.strip()}") |
| 255 | + |
| 256 | + # 新建标签页并聚焦 |
| 257 | + activate_window() |
| 258 | + time.sleep(0.3) |
| 259 | + self.kbd.press(Key.ctrl) |
| 260 | + self.kbd.tap('t') |
| 261 | + self.kbd.release(Key.ctrl) |
| 262 | + time.sleep(0.8) |
| 263 | + |
| 264 | + self.mouse.position = POS_DOC |
| 265 | + time.sleep(0.2) |
| 266 | + self.mouse.click(Button.left) |
| 267 | + time.sleep(0.4) |
| 268 | + |
| 269 | + # 设置剪贴板并粘贴 |
| 270 | + self.clip.set_text(tex_content) |
| 271 | + activate_window() |
| 272 | + time.sleep(0.2) |
| 273 | + |
| 274 | + print(" -> Clicking: Edit -> Paste from -> LaTeX") |
| 275 | + self.mouse.position = POS_EDIT_MENU |
| 276 | + time.sleep(0.3) |
| 277 | + self.mouse.click(Button.left) |
| 278 | + time.sleep(0.4) |
| 279 | + |
| 280 | + self.mouse.position = POS_PASTE_FROM |
| 281 | + time.sleep(0.4) |
| 282 | + |
| 283 | + self.mouse.position = POS_LATEX_ITEM |
| 284 | + time.sleep(0.3) |
| 285 | + self.mouse.click(Button.left) |
| 286 | + |
| 287 | + # 检查粘贴阶段是否崩溃 |
| 288 | + t_wait = 0.0 |
| 289 | + crashed = False |
| 290 | + while t_wait < 3.5: |
| 291 | + time.sleep(0.2) |
| 292 | + t_wait += 0.2 |
| 293 | + if not self.is_alive(): |
| 294 | + crashed = True |
| 295 | + print(" [!] Crashed during paste!") |
| 296 | + break |
| 297 | + |
| 298 | + # 处理可能的错误弹窗并关闭 |
| 299 | + if not crashed: |
| 300 | + time.sleep(0.5) |
| 301 | + print(" -> Closing possible error popup (Enter)...") |
| 302 | + self.kbd.tap(Key.enter) |
| 303 | + time.sleep(0.5) |
| 304 | + if not self.is_alive(): |
| 305 | + crashed = True |
| 306 | + print(" [!] Crashed after dismissing popup!") |
| 307 | + |
| 308 | + # 关闭弹窗后,继续编辑测试(防止损坏状态下继续操作引起崩溃) |
| 309 | + if not crashed: |
| 310 | + print(" -> Testing continue editing after paste...") |
| 311 | + activate_window() |
| 312 | + time.sleep(0.3) |
| 313 | + self.mouse.position = POS_DOC |
| 314 | + time.sleep(0.2) |
| 315 | + self.mouse.click(Button.left) |
| 316 | + time.sleep(0.3) |
| 317 | + |
| 318 | + # 键入文本 |
| 319 | + self.kbd.type("testing edit after paste ") |
| 320 | + time.sleep(0.3) |
| 321 | + # 回车触发分段排版 |
| 322 | + self.kbd.tap(Key.enter) |
| 323 | + time.sleep(0.3) |
| 324 | + # 键入更多内容 |
| 325 | + self.kbd.type("continue typing 12345") |
| 326 | + time.sleep(0.3) |
| 327 | + # 回车 |
| 328 | + self.kbd.tap(Key.enter) |
| 329 | + time.sleep(0.3) |
| 330 | + # 退格删除 |
| 331 | + for _ in range(8): |
| 332 | + self.kbd.tap(Key.backspace) |
| 333 | + time.sleep(0.05) |
| 334 | + # 光标移动 |
| 335 | + self.kbd.tap(Key.up) |
| 336 | + time.sleep(0.1) |
| 337 | + self.kbd.tap(Key.down) |
| 338 | + time.sleep(0.1) |
| 339 | + self.kbd.type(" finished") |
| 340 | + time.sleep(0.5) |
| 341 | + |
| 342 | + # 等待排版和可能的后台处理 |
| 343 | + t_wait = 0.0 |
| 344 | + while t_wait < 2.5: |
| 345 | + time.sleep(0.2) |
| 346 | + t_wait += 0.2 |
| 347 | + if not self.is_alive(): |
| 348 | + crashed = True |
| 349 | + print(" [!] Crashed during continue editing!") |
| 350 | + break |
| 351 | + |
| 352 | + status = "CRASH" if crashed else "PASS" |
| 353 | + print(f" Result for {case_name}: {status}") |
| 354 | + return not crashed |
| 355 | + |
| 356 | + def close(self): |
| 357 | + self.clip.stop() |
| 358 | + self.stop_app() |
| 359 | + |
| 360 | + |
| 361 | +def main(): |
| 362 | + if len(sys.argv) > 1: |
| 363 | + test_files = sys.argv[1:] |
| 364 | + else: |
| 365 | + test_files = [f for f in DEFAULT_CASES if os.path.exists(f)] |
| 366 | + if not test_files: |
| 367 | + print("[!] Default test cases not found in TeXmacs/tests/tex/!") |
| 368 | + sys.exit(1) |
| 369 | + |
| 370 | + runner = TestRunner() |
| 371 | + results = {} |
| 372 | + |
| 373 | + try: |
| 374 | + for tf in test_files: |
| 375 | + name = os.path.basename(tf) |
| 376 | + with open(tf, "r", encoding="utf-8") as f: |
| 377 | + content = f.read().strip() |
| 378 | + passed = runner.run_case(name, content) |
| 379 | + results[name] = "PASS" if passed else "CRASH" |
| 380 | + |
| 381 | + print("\n" + "=" * 50) |
| 382 | + print("TEST SUMMARY:") |
| 383 | + print("=" * 50) |
| 384 | + all_passed = True |
| 385 | + for name, status in results.items(): |
| 386 | + print(f" {name:20s}: {status}") |
| 387 | + if status != "PASS": |
| 388 | + all_passed = False |
| 389 | + |
| 390 | + sys.exit(0 if all_passed else 1) |
| 391 | + |
| 392 | + finally: |
| 393 | + runner.close() |
| 394 | + |
| 395 | + |
| 396 | +if __name__ == "__main__": |
| 397 | + main() |
0 commit comments