补入 oil-ui-pro(上一提交按授权清空时移出,本轮按用户要求加回)
This commit is contained in:
1 parent
777f7fe5d0
commit
0abd33b3a0
26 files changed
+4313
No files matched your search
@@ -0,0 +1,357 @@
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPT = Path(__file__).resolve().parent.parent / "scripts" / "check_update.py"
|
||||
LAUNCHER = SCRIPT.with_suffix(".sh")
|
||||
SH = shutil.which("sh")
|
||||
|
||||
# 假的 npx:收到 update --path <目录> 时把那里的版本号改成 99.0.0,并记下调用
|
||||
FAKE_NPX = """#!/bin/sh
|
||||
echo "$@" >> "$FAKE_NPX_LOG"
|
||||
[ -n "$FAKE_NPX_STDERR" ] && printf '%s\\n' "$FAKE_NPX_STDERR" >&2
|
||||
[ -n "$FAKE_NPX_JSON" ] && printf '%s\\n' "$FAKE_NPX_JSON"
|
||||
[ -n "$FAKE_NPX_FAIL" ] && exit 1
|
||||
while [ $# -gt 0 ]; do
|
||||
if [ "$1" = "--path" ]; then sed -i.bak 's/version: "0.10.0"/version: "99.0.0"/' "$2/SKILL.md"; fi
|
||||
shift
|
||||
done
|
||||
"""
|
||||
FAKE_NODE = """#!/bin/sh
|
||||
printf '%s\\n' "${FAKE_NODE_VERSION:-v20.0.0}"
|
||||
"""
|
||||
|
||||
|
||||
class Versions(BaseHTTPRequestHandler):
|
||||
payload = {}
|
||||
hits = 0
|
||||
|
||||
def do_GET(self):
|
||||
Versions.hits += 1
|
||||
body = json.dumps(Versions.payload).encode()
|
||||
self.send_response(200 if self.path == "/api/store/versions" else 404)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def log_message(self, *args):
|
||||
pass
|
||||
|
||||
|
||||
@unittest.skipIf(sys.platform == "win32", "假 npx 是 shell 脚本")
|
||||
class CheckUpdateTest(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.server = HTTPServer(("127.0.0.1", 0), Versions)
|
||||
threading.Thread(target=cls.server.serve_forever, daemon=True).start()
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
cls.server.shutdown()
|
||||
|
||||
def setUp(self):
|
||||
self.tmp = Path(tempfile.mkdtemp()).resolve()
|
||||
self.skill = self.tmp / "skills" / "oil-ui-pro"
|
||||
(self.skill / "scripts").mkdir(parents=True)
|
||||
shutil.copy(SCRIPT, self.skill / "scripts" / "check_update.py")
|
||||
shutil.copy(LAUNCHER, self.skill / "scripts" / "check_update.sh")
|
||||
(self.skill / "SKILL.md").write_text('---\nname: oil-ui-pro\nmetadata:\n version: "0.10.0"\n---\n', encoding="utf-8")
|
||||
bin_dir = self.tmp / "bin"
|
||||
bin_dir.mkdir()
|
||||
(bin_dir / "npx").write_text(FAKE_NPX, encoding="utf-8")
|
||||
(bin_dir / "npx").chmod(0o755)
|
||||
(bin_dir / "node").write_text(FAKE_NODE, encoding="utf-8")
|
||||
(bin_dir / "node").chmod(0o755)
|
||||
self.bin = bin_dir
|
||||
self.npx_log = self.tmp / "npx.log"
|
||||
Versions.hits = 0
|
||||
self.set_latest("99.0.0", free=False)
|
||||
|
||||
def tearDown(self):
|
||||
shutil.rmtree(self.tmp)
|
||||
|
||||
def set_latest(self, version, free):
|
||||
entry = {"latest": version, "history": [{"version": version, "notes": "- 动效改成三处基本动效。\n- 其他修正"}]}
|
||||
if free:
|
||||
entry["download_url"] = "https://example.com/oil-ui-pro.tar.gz"
|
||||
Versions.payload = {"skills": {"oil-ui-pro": entry}}
|
||||
|
||||
def login(self):
|
||||
config = self.tmp / "config" / "oil"
|
||||
config.mkdir(parents=True, exist_ok=True)
|
||||
(config / "config.json").write_text(json.dumps({"token": "oil_test", "email": "[email protected]"}), encoding="utf-8")
|
||||
|
||||
def run_check(self, api=None, launcher=False, **extra):
|
||||
env = {k: v for k, v in os.environ.items() if not k.startswith("OIL_")}
|
||||
env.update(HOME=str(self.tmp / "home"), APPDATA=str(self.tmp / "config"), LOCALAPPDATA=str(self.tmp / "state"),
|
||||
XDG_STATE_HOME=str(self.tmp / "state"), XDG_CONFIG_HOME=str(self.tmp / "config"),
|
||||
PATH=f"{self.bin}{os.pathsep}{os.environ['PATH']}", FAKE_NPX_LOG=str(self.npx_log),
|
||||
LANG="zh_CN.UTF-8", LC_ALL="", LC_MESSAGES="",
|
||||
OIL_API=api or f"http://127.0.0.1:{self.server.server_port}")
|
||||
env.update(extra)
|
||||
script = self.skill / "scripts" / ("check_update.sh" if launcher else "check_update.py")
|
||||
result = subprocess.run([SH if launcher else sys.executable, str(script)],
|
||||
capture_output=True, text=True, env=env, timeout=30)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertEqual(result.stderr, "")
|
||||
return result.stdout
|
||||
|
||||
def install_state(self):
|
||||
paths = list((self.tmp / "state" / "oil" / "installations").glob("*.json"))
|
||||
self.assertEqual(len(paths), 1)
|
||||
return paths[0], json.loads(paths[0].read_text(encoding="utf-8"))
|
||||
|
||||
def assert_no_attempt(self):
|
||||
self.assertEqual(self.npx_calls(), [])
|
||||
_, state = self.install_state()
|
||||
self.assertNotIn("auto_failed_at", state)
|
||||
|
||||
def npx_calls(self):
|
||||
return self.npx_log.read_text(encoding="utf-8").splitlines() if self.npx_log.exists() else []
|
||||
|
||||
def test_logged_in_paid_skill_updates_itself(self):
|
||||
self.login()
|
||||
out = self.run_check()
|
||||
self.assertIn("Oil UI Pro 已自动更新到 99.0.0(原来是 0.10.0):动效改成三处基本动效。", out)
|
||||
self.assertIn(f"update oil-ui-pro --path {self.skill}", self.npx_calls()[0])
|
||||
self.assertIn('version: "99.0.0"', (self.skill / "SKILL.md").read_text(encoding="utf-8"))
|
||||
self.assertEqual(self.run_check(), "")
|
||||
|
||||
def test_free_skill_updates_without_login(self):
|
||||
self.set_latest("99.0.0", free=True)
|
||||
self.assertIn("已自动更新到 99.0.0", self.run_check())
|
||||
|
||||
def test_paid_skill_without_login_only_notifies_once_a_day(self):
|
||||
out = self.run_check()
|
||||
self.assertIn("Oil UI Pro 有新版本 99.0.0(当前 0.10.0):动效改成三处基本动效。", out)
|
||||
self.assertIn("npx github:oil-oil/oil-cli update oil-ui-pro", out)
|
||||
self.assertEqual(self.npx_calls(), [])
|
||||
self.assertEqual(self.run_check(), "")
|
||||
self.assertEqual(Versions.hits, 1)
|
||||
self.assert_no_attempt()
|
||||
|
||||
def test_failed_update_falls_back_to_notice(self):
|
||||
self.login()
|
||||
out = self.run_check(FAKE_NPX_FAIL="1")
|
||||
self.assertIn("有新版本 99.0.0", out)
|
||||
self.assertEqual(len(self.npx_calls()), 1)
|
||||
self.assertEqual(self.run_check(FAKE_NPX_FAIL="1"), "")
|
||||
self.assertEqual(len(self.npx_calls()), 1)
|
||||
|
||||
def test_auto_update_can_be_turned_off(self):
|
||||
self.login()
|
||||
self.assertIn("有新版本 99.0.0", self.run_check(OIL_NO_AUTO_UPDATE="1"))
|
||||
self.assertEqual(self.npx_calls(), [])
|
||||
self.assert_no_attempt()
|
||||
|
||||
def test_cli_errors_have_specific_recovery_commands_in_both_languages(self):
|
||||
self.login()
|
||||
for language in ("zh_CN.UTF-8", "en_US.UTF-8"):
|
||||
for error in ("unauthorized", "inactive"):
|
||||
with self.subTest(language=language, error=error):
|
||||
shutil.rmtree(self.tmp / "state", ignore_errors=True)
|
||||
out = self.run_check(LANG=language, FAKE_NPX_FAIL="1", FAKE_NPX_JSON=json.dumps({
|
||||
"ok": False, "command": "update", "error": error, "message": "DO NOT ECHO THIS",
|
||||
"new_field": "forward-compatible"}))
|
||||
self.assertIn(f'npx github:oil-oil/oil-cli update oil-ui-pro --path "{self.skill}"', out)
|
||||
self.assertNotIn("DO NOT ECHO THIS", out)
|
||||
if error == "unauthorized":
|
||||
self.assertIn("npx github:oil-oil/oil-cli login", out)
|
||||
self.assertIn("Authorization has expired" if language.startswith("en") else "授权已失效", out)
|
||||
else:
|
||||
self.assertIn("https://ui.oiloil.org/pro/", out)
|
||||
self.assertIn("no purchase record" if language.startswith("en") else "没有对应的购买记录", out)
|
||||
_, state = self.install_state()
|
||||
self.assertEqual(state["auto_failed_reason"], error)
|
||||
self.assertEqual(self.run_check(LANG=language, FAKE_NPX_FAIL="1", FAKE_NPX_JSON=json.dumps({"error": error})), "")
|
||||
|
||||
def test_cli_network_errors_are_silent_and_retry_after_an_hour(self):
|
||||
self.login()
|
||||
extra = {"FAKE_NPX_FAIL": "1", "FAKE_NPX_JSON": '{"error":"network"}'}
|
||||
self.assertEqual(self.run_check(**extra), "")
|
||||
self.assertEqual(self.run_check(**extra), "")
|
||||
self.assertEqual(len(self.npx_calls()), 1)
|
||||
path, state = self.install_state()
|
||||
state["auto_failed_at"] -= 3601
|
||||
path.write_text(json.dumps(state), encoding="utf-8")
|
||||
self.assertEqual(self.run_check(**extra), "")
|
||||
self.assertEqual(len(self.npx_calls()), 2)
|
||||
|
||||
def test_npm_network_errors_are_silent_without_json(self):
|
||||
self.login()
|
||||
self.assertEqual(self.run_check(FAKE_NPX_FAIL="1", FAKE_NPX_STDERR="npm error code ENOTFOUND"), "")
|
||||
|
||||
def test_missing_or_old_node_and_missing_npx_do_not_count_as_attempts(self):
|
||||
self.login()
|
||||
for executable in ("npx", "node"):
|
||||
with self.subTest(executable=executable):
|
||||
target = self.bin / executable
|
||||
hidden = self.bin / (executable + "-hidden")
|
||||
target.rename(hidden)
|
||||
out = self.run_check(PATH=str(self.bin))
|
||||
self.assertIn("自动更新需要 Node.js 18 以上", out)
|
||||
self.assertIn(f'--path "{self.skill}"', out)
|
||||
self.assert_no_attempt()
|
||||
hidden.rename(target)
|
||||
out = self.run_check(FAKE_NODE_VERSION="v16.20.0")
|
||||
self.assertIn("自动更新需要 Node.js 18 以上", out)
|
||||
self.assert_no_attempt()
|
||||
self.assertIn("已自动更新", self.run_check())
|
||||
|
||||
def test_login_or_config_change_allows_immediate_retry(self):
|
||||
self.assertIn("login", self.run_check())
|
||||
self.login()
|
||||
self.assertIn("已自动更新", self.run_check())
|
||||
|
||||
def test_reauthorization_and_environment_token_change_clear_failed_cooldown(self):
|
||||
self.login()
|
||||
self.run_check(FAKE_NPX_FAIL="1", FAKE_NPX_JSON='{"error":"unauthorized"}')
|
||||
config = self.tmp / "config" / "oil" / "config.json"
|
||||
config.write_text(json.dumps({"token": "oil_new_token", "email": "[email protected]"}), encoding="utf-8")
|
||||
self.assertIn("已自动更新", self.run_check())
|
||||
self.assertEqual(len(self.npx_calls()), 2)
|
||||
(self.skill / "SKILL.md").write_text('name: oil-ui-pro\nmetadata:\n version: "0.10.0"\n', encoding="utf-8")
|
||||
self.run_check(OIL_TOKEN="oil_first", FAKE_NPX_FAIL="1", FAKE_NPX_JSON='{"error":"unauthorized"}')
|
||||
self.assertIn("已自动更新", self.run_check(OIL_TOKEN="oil_second"))
|
||||
state_text = "".join(p.read_text(encoding="utf-8") for p in (self.tmp / "state").rglob("*.json"))
|
||||
self.assertNotIn("oil_first", state_text)
|
||||
self.assertNotIn("oil_second", state_text)
|
||||
|
||||
def test_repairing_dependency_after_a_failed_attempt_allows_immediate_retry(self):
|
||||
self.login()
|
||||
self.run_check(FAKE_NPX_FAIL="1")
|
||||
self.assertIn("已自动更新", self.run_check(FAKE_NODE_VERSION="v22.0.0"))
|
||||
self.assertEqual(len(self.npx_calls()), 2)
|
||||
|
||||
def test_installations_share_versions_but_not_cooldowns_or_notices(self):
|
||||
self.login()
|
||||
original = self.skill
|
||||
other = self.tmp / "another host" / "oil-ui-pro"
|
||||
shutil.copytree(original, other)
|
||||
self.assertIn("有新版本", self.run_check(FAKE_NPX_FAIL="1"))
|
||||
self.skill = other
|
||||
self.assertIn("已自动更新", self.run_check())
|
||||
self.assertEqual(Versions.hits, 1)
|
||||
self.assertIn('version: "0.10.0"', (original / "SKILL.md").read_text(encoding="utf-8"))
|
||||
self.assertEqual(len(list((self.tmp / "state" / "oil" / "installations").glob("*.json"))), 2)
|
||||
|
||||
def test_legacy_shared_failure_does_not_block_an_installation(self):
|
||||
self.login()
|
||||
cache = self.tmp / "state" / "oil" / "oil-ui-pro-update.json"
|
||||
cache.parent.mkdir(parents=True)
|
||||
cache.write_text(json.dumps({"latest": "99.0.0", "checked_at": 9999999999,
|
||||
"auto_failed_version": "99.0.0", "auto_failed_at": 9999999999,
|
||||
"notified_version": "99.0.0", "notified_at": 9999999999}), encoding="utf-8")
|
||||
self.assertIn("已自动更新", self.run_check())
|
||||
|
||||
def test_checks_versions_again_after_ten_minutes(self):
|
||||
self.run_check(OIL_NO_AUTO_UPDATE="1")
|
||||
self.run_check(OIL_NO_AUTO_UPDATE="1")
|
||||
self.assertEqual(Versions.hits, 1)
|
||||
cache = self.tmp / "state" / "oil" / "oil-ui-pro-update.json"
|
||||
state = json.loads(cache.read_text(encoding="utf-8"))
|
||||
state["checked_at"] -= 601
|
||||
cache.write_text(json.dumps(state), encoding="utf-8")
|
||||
self.run_check(OIL_NO_AUTO_UPDATE="1")
|
||||
self.assertEqual(Versions.hits, 2)
|
||||
|
||||
def test_offline_with_stale_cache_is_silent_and_fetch_retries_later(self):
|
||||
self.run_check(OIL_NO_AUTO_UPDATE="1")
|
||||
cache = self.tmp / "state" / "oil" / "oil-ui-pro-update.json"
|
||||
state = json.loads(cache.read_text(encoding="utf-8"))
|
||||
state["checked_at"] = 0
|
||||
cache.write_text(json.dumps(state), encoding="utf-8")
|
||||
self.assertEqual(self.run_check(api="http://127.0.0.1:9"), "")
|
||||
self.assertEqual(self.run_check(), "")
|
||||
self.assertEqual(Versions.hits, 1)
|
||||
state = json.loads(cache.read_text(encoding="utf-8"))
|
||||
state["fetch_failed_at"] -= 3601
|
||||
cache.write_text(json.dumps(state), encoding="utf-8")
|
||||
self.login()
|
||||
self.assertIn("已自动更新", self.run_check())
|
||||
self.assertEqual(Versions.hits, 2)
|
||||
|
||||
def test_manual_command_targets_absolute_path_and_handles_shell_characters(self):
|
||||
self.skill.rename(self.skill.parent / 'custom space $HOME `false` "quote"')
|
||||
self.skill = self.skill.parent / 'custom space $HOME `false` "quote"'
|
||||
out = self.run_check(OIL_NO_AUTO_UPDATE="1")
|
||||
command = out.split("在终端运行 ", 1)[1].rsplit(" 即可更新。", 1)[0]
|
||||
# 实际复制执行提示;参数只能指向该副本,不能展开变量或命令替换。
|
||||
result = subprocess.run([SH, "-c", command], env={**os.environ, "HOME": str(self.tmp / "home"),
|
||||
"PATH": f"{self.bin}{os.pathsep}{os.environ['PATH']}",
|
||||
"FAKE_NPX_LOG": str(self.npx_log)}, capture_output=True, text=True)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertIn(str(self.skill), self.npx_calls()[0])
|
||||
self.assertIn('version: "99.0.0"', (self.skill / "SKILL.md").read_text(encoding="utf-8"))
|
||||
|
||||
def test_english_success_and_manual_notice(self):
|
||||
out = self.run_check(LANG="en_US.UTF-8", OIL_NO_AUTO_UPDATE="1")
|
||||
self.assertIn("Oil UI Pro 99.0.0 is available (current: 0.10.0)", out)
|
||||
self.assertIn("To update, run npx github:oil-oil/oil-cli", out)
|
||||
self.login()
|
||||
self.assertIn("updated automatically to 99.0.0", self.run_check(LANG="en_US.UTF-8"))
|
||||
|
||||
def shell_path(self):
|
||||
# 不从真实 PATH 找 Python;只保留入口写状态所需的工具。
|
||||
for tool in ("mkdir", "rmdir"):
|
||||
target = self.bin / tool
|
||||
if not target.exists():
|
||||
target.symlink_to(shutil.which(tool))
|
||||
return str(self.bin)
|
||||
|
||||
def test_launcher_runs_when_only_python_is_available(self):
|
||||
(self.bin / "python").symlink_to(sys.executable)
|
||||
out = self.run_check(launcher=True, PATH=self.shell_path(), OIL_NO_AUTO_UPDATE="1")
|
||||
self.assertIn("有新版本", out)
|
||||
self.assertEqual(Versions.hits, 1)
|
||||
|
||||
def test_missing_python_warns_once_and_recovery_resets_the_reminder(self):
|
||||
path = self.shell_path()
|
||||
self.assertEqual(self.run_check(launcher=True, PATH=path), "自动更新需要 Python 3,本次没有检查更新。\n")
|
||||
self.assertEqual(self.run_check(launcher=True, PATH=path), "OIL_UPDATE_CHECK_SKIPPED: missing_python\n")
|
||||
self.assertEqual(Versions.hits, 0)
|
||||
(self.bin / "python").symlink_to(sys.executable)
|
||||
self.assertIn("有新版本", self.run_check(launcher=True, PATH=path, OIL_NO_AUTO_UPDATE="1"))
|
||||
(self.bin / "python").unlink()
|
||||
self.assertIn("Automatic updates need Python 3", self.run_check(launcher=True, PATH=path, LANG="en_US.UTF-8"))
|
||||
|
||||
def test_python2_is_not_used_and_disabled_or_development_launcher_is_silent(self):
|
||||
(self.bin / "python").write_text("#!/bin/sh\nexit 1\n", encoding="utf-8")
|
||||
(self.bin / "python").chmod(0o755)
|
||||
path = self.shell_path()
|
||||
self.assertEqual(self.run_check(launcher=True, PATH=path, OIL_NO_UPDATE_CHECK="1"), "")
|
||||
(self.skill / ".git").mkdir()
|
||||
self.assertEqual(self.run_check(launcher=True, PATH=path), "")
|
||||
(self.skill / ".git").rmdir()
|
||||
self.assertIn("自动更新需要 Python 3", self.run_check(launcher=True, PATH=path))
|
||||
|
||||
def test_silent_when_up_to_date_offline_disabled_or_in_a_checkout(self):
|
||||
self.set_latest("0.0.1", free=False)
|
||||
self.assertEqual(self.run_check(), "")
|
||||
self.set_latest("99.0.0", free=False)
|
||||
self.assertEqual(self.run_check(api="http://127.0.0.1:9", XDG_STATE_HOME=str(self.tmp / "s2")), "")
|
||||
self.assertEqual(self.run_check(OIL_NO_UPDATE_CHECK="1", XDG_STATE_HOME=str(self.tmp / "s3")), "")
|
||||
(self.skill / ".git").mkdir()
|
||||
self.assertEqual(self.run_check(XDG_STATE_HOME=str(self.tmp / "s4")), "")
|
||||
|
||||
|
||||
|
||||
class ChineseLabelTest(unittest.TestCase):
|
||||
def test_no_space_after_chinese_label(self):
|
||||
import importlib.util
|
||||
spec = importlib.util.spec_from_file_location("check_update", SCRIPT)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
self.assertEqual(module.zh_label("Oil UI 开源版") + "有新版本", "Oil UI 开源版有新版本")
|
||||
self.assertEqual(module.zh_label("Oil UI Pro") + "有新版本", "Oil UI Pro 有新版本")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user