Files
ykxmz/_tests/test_admin_js_syntax.py
T
xmz7891 d907fdcf6e 🚨 修一个严重 bug:管理页所有按钮又失效了(同一个坑踩第二次)
== 症状 ==

在**真实中转**上实测管理页时,用 node --check 检查页面内嵌的 JS:

    SyntaxError: Invalid or unexpected token  (第 46 行)

整段 <script> 不执行 -> 改码 / 删除 / 批量删除 / 全选 全部变幽灵按键。

**这正是用户之前报过的那个 bug,加新功能时又踩了一遍。**

== 根因(讽刺到极点)==

admin.py 的 ADMIN_JS 是**普通三引号字符串**,里面写一个反斜杠+n
会被 Python 解释成**真实换行**。而 JS 的行注释 // 不能跨行 ——
一断行,后面就成了裸文本,整段脚本语法错误。

出事的是这段注释:

    // ⚠ 这里必须是 \\n(反斜杠+n),不能是 \n ——
                                      ^^^^
                       这个 \n 被 Python 变成真换行

**注释为了举例说明「不能写 \n」,自己写了一个 \n。**
于是注释被断成两行,把整段 JS 弄坏了。

== 修法 ==

注释改成**一个转义序列都不写**,只用文字描述:

    // ⚠ 下面 prompt 里的换行,必须写成「反斜杠 + n」两个字符。
    //   本文件是普通三引号字符串:只写反斜杠 + n,Python 会把它
    //   变成**真实换行** ... (一个转义都不出现)

并把"踩过两次"的历史写进注释,提醒后人别再来第三次。

== 新增两个测试(防止再犯)==

1. `_tests/test_admin_js_syntax.py`(已注册为回归阶段 3a6)
   **本地渲染管理页**,把 <script> 抠出来喂给 `node --check`,
   还检查注释行里有没有裸控制字符。

   为什么原来查不出来:已有的「管理页 JS 合法性」阶段只检查
   token 在不在,不查语法 —— 「23 项测试通过」和「功能能用」
   是两件事。

2. `_build/scan_admin_escapes.py`
   扫源文件里"注释下一行不像 JS 代码"的可疑断行。

3. `_tests/test_admin_live.py`
   在**真实中转**上走 HTTP 实测两个新功能(不能进回归套件,
   因为它依赖线上设备):
     一、管理页能打开(带 X-Admin 头绕开登录页)
     二、页面里真的有 allowNew 和改码按钮
     三、内嵌 JS 语法合法
     四、关掉接入开关 -> 新设备被拒 -> 打开 -> 能注册(实测通过)
     五、改配对码 -> 真的变了(实测通过)
     六、不带 X-Admin 访问管理接口返回 401(实测通过)

   实测结果:16/17 通过(唯一的失败就是上面那个 JS bug,
   已修,本地渲染 6/6 通过)。

== 顺带纠正一个我自己的误解 ==

我一开始只带 `?key=` 访问管理页,拿到的是**登录页**,
还以为是设备上部署了旧版本。实际是管理页改成了
**账号 + 密码登录 + Cookie 会话**(用户要求),
脚本通道用 `X-Admin` 头(见 relay.py 的 _admin_gate)。

== 部署时机 ==

**故意没有立刻部署到设备上** —— 部署要重启中转服务,
会打断 relay-stability 正在跑的隧道配比 A/B 对照。
等对照跑完(约 22:00)再部署并复测。
2026-10-07 20:33:27 +08:00

161 lines
5.5 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.
# -*- coding: utf-8 -*-
r'''渲染修好的管理页,检查 JS 语法。
这是"按钮全是死的"那个 bug 的**最快复现方式**:
不连中转、不起服务器,直接调 render_admin() 拿 HTML,
把 <script> 抠出来喂给 node --check。
之前那个坑之所以能进仓库,就是因为测试只查"token 在不在",
不查语法。这个脚本补上。
'''
from __future__ import annotations
import re
import subprocess
import sys
import tempfile
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "relay"))
import admin as admin_mod # noqa: E402
passed = 0
failed = 0
def check(name, ok, detail=""):
global passed, failed
if ok:
passed += 1
print(" [PASS] " + name)
else:
failed += 1
print(" [FAIL] " + name + ((" —— " + str(detail)) if detail else ""))
def main() -> int:
print("=" * 58)
print(" 管理页 JS 语法检查(本地渲染)")
print("=" * 58)
# 用 relay 自己的 snapshot() 造一份**真实结构**的快照 ——
# 手写 dict 会缺字段(比如 queuedToClient),一改就崩。
snap = None
try:
import relay as relay_mod # noqa: E402
snap = relay_mod.RELAY.snapshot()
print(" (用 relay.RELAY.snapshot() 拿到的真实快照)")
except Exception as exc: # noqa: BLE001
print(" (拿不到真实快照:%s,退回手写)" % type(exc).__name__)
if snap is None:
snap = {
"devices": [],
"sessions": 0,
"uptimeSeconds": 100,
"memoryKB": 20480,
"allowNew": True,
"codeCount": 0,
}
# 塞一台假设备,把设备行那一块也渲染出来(不然改码按钮不会出现)
# 字段要和 admin._device_rows 里用到的完全对齐
snap["devices"] = [{
"deviceId": "TESTDEVICE-1234567890ab",
"name": "测试电脑",
"code": "123456",
"online": True,
"idleSeconds": 3,
"lastSeen": "刚刚",
"ip": "192.168.1.5",
"version": "1.8.2",
"os": "Windows",
"lanIps": ["192.168.1.5"],
"queuedToClient": 0,
"queuedToAgent": 0,
"bytesIn": 1024,
"bytesOut": 2048,
"tokens": 0,
"sessions": 0,
}]
snap.setdefault("sessions", 0)
snap.setdefault("uptimeSeconds", 100)
snap.setdefault("memoryKB", 20480)
snap.setdefault("allowNew", True)
snap.setdefault("codeCount", 1)
try:
html = admin_mod.render_admin(
snap, "https://relay.example.com",
"/zhongzhuanzhan", ["123456"],
lambda: [])
except TypeError:
# 签名不一样就退回位置参数最少的调法
html = admin_mod.render_admin(snap, "https://relay.example.com",
"/zhongzhuanzhan", ["123456"])
if isinstance(html, bytes):
html = html.decode("utf-8")
check("渲染出了 HTML", len(html) > 500, "只有 %d 字节" % len(html))
check("页面里有 allowNew", 'id="allowNew"' in html, "找不到")
check("页面里有改码按钮", "data-recode" in html, "找不到")
scripts = re.findall(r"<script[^>]*>(.*?)</script>", html, re.S)
inline = [s for s in scripts if s.strip()]
check("有内嵌脚本", bool(inline), "一个都没有")
for i, js in enumerate(inline, 1):
with tempfile.NamedTemporaryFile("w", suffix=".js", delete=False,
encoding="utf-8") as fh:
fh.write(js)
path = fh.name
try:
proc = subprocess.run(["node", "--check", path],
capture_output=True, text=True,
encoding="utf-8", errors="replace",
timeout=30)
ok = proc.returncode == 0
detail = ""
if not ok:
# 把报错那一行附近打出来,方便定位
err = (proc.stderr or "")
m = re.search(r":(\d+)\n", err)
if m:
ln = int(m.group(1))
lines = js.splitlines()
lo = max(0, ln - 3)
hi = min(len(lines), ln + 2)
detail = "第 %d 行:\n" % ln + "\n".join(
" %3d| %s" % (k + 1, lines[k])
for k in range(lo, hi))
else:
detail = err[:300]
check("第 %d 段内嵌 JS 语法合法" % i, ok, detail)
except FileNotFoundError:
print(" [SKIP] 没装 node")
finally:
Path(path).unlink(missing_ok=True)
# 再检查一遍:注释行里有没有被 Python 吃掉转义的痕迹
for i, js in enumerate(inline, 1):
lines = js.splitlines()
bad = []
for k, line in enumerate(lines):
s = line.strip()
if s.startswith("//"):
# 注释行里出现裸控制字符
for ch in line:
if ord(ch) < 32 and ch != "\t":
bad.append(k + 1)
break
check("第 %d 段没有裸控制字符" % i, not bad, "行号 %s" % bad)
print()
print("=" * 58)
print(" 通过 %d 项,失败 %d 项" % (passed, failed))
return 0 if failed == 0 else 1
if __name__ == "__main__":
sys.exit(main())