重开仓库内容:改推五个技能(browser-harness / humanizer / humanizer-zh / product-planning / session-mechanism)

按授权清空原有内容后重新提交(原 oil-ui-pro 一并移出,可从历史恢复)。
browser-harness 剔除 .venv 等运行环境;根 .gitignore 补记 .venv/ 与 node_modules/。
This commit is contained in:
admin committed 2026-10-07 16:06:02 +08:00
1 parent 9a152a1952
commit 777f7fe5d0
240 files changed
+52518 -4409

No files matched your search

-365
View File
@@ -1,365 +0,0 @@
"""Observable builder contracts. Run with Python's unittest discovery."""
import base64
from contextlib import redirect_stderr
import importlib.util
from html.parser import HTMLParser
import io
import json
from pathlib import Path
import shutil
import subprocess
import sys
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[1]
spec = importlib.util.spec_from_file_location("build_explorer", ROOT / "scripts" / "build_explorer.py")
builder = importlib.util.module_from_spec(spec)
spec.loader.exec_module(builder)
class ExplorerBuildTests(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.folder = Path(self.tmp.name)
self.manifest = self.folder / "manifest.json"
self.output = self.folder / "output" / "explore.html"
self.source = self.folder / "sample.html"
self.source.write_text('<!doctype html><html><head><style>body{color:#123}</style></head><body>同一内容<script>window.bad=true</script></body></html>', encoding="utf-8")
self.data = {"schemaVersion": 1, "project": "项目", "brief": "同一内容比较", "round": "01", "candidates": [{"id": "a", "name": "方向一", "concept": "编辑式", "typography": "宋体与黑体", "palette": ["#112233", "#fff"], "traits": ["大标题"], "kind": "html", "source": "sample.html"}]}
self.save()
def save(self):
self.manifest.write_text(json.dumps(self.data, ensure_ascii=False), encoding="utf-8")
def test_portable_single_file_and_html_payload(self):
result = builder.build(self.manifest, self.output)
page = self.output.read_text(encoding="utf-8")
self.assertEqual(result["candidates"], 1)
self.assertIn("同一内容", page)
self.assertNotIn(str(self.folder), page)
self.assertNotIn(builder.MARKER, page)
self.assertIn("script-src 'none'", page)
def test_metadata_cannot_close_script(self):
self.data["project"] = '</script><script>window.injected=true</script>'
self.save()
builder.build(self.manifest, self.output)
page = self.output.read_text(encoding="utf-8")
self.assertNotIn(self.data["project"], page)
self.assertIn('\\u003c/script>', page)
def test_csp_is_inserted_in_actual_head_not_a_comment(self):
self.source.write_text('''<!doctype html>
<!-- Skeleton: <head> \u2028 extra text -->
<html><head data-note="a > b"></head><body>content</body></html>''', encoding="utf-8")
content = builder.prepare_html(self.source)
observed = []
class Tags(HTMLParser):
def handle_starttag(self, tag, attrs):
observed.append((tag, dict(attrs)))
Tags().feed(content)
meta = [attrs for tag, attrs in observed if tag == "meta"]
self.assertEqual(meta, [{"http-equiv": "Content-Security-Policy", "content": builder.PREVIEW_CSP}])
self.assertEqual([tag for tag, attrs in observed][:3], ["html", "head", "meta"])
def test_nested_documents_are_rejected_before_replacing_output(self):
builder.build(self.manifest, self.output)
original = self.output.read_bytes()
self.source.write_text('''<html><head></head><body>
<iframe srcdoc="&lt;img src='https://example.invalid/a.png'&gt;"></iframe>
</body></html>''', encoding="utf-8")
with self.assertRaises(ValueError):
builder.build(self.manifest, self.output, force=True)
self.assertEqual(original, self.output.read_bytes())
def test_existing_output_is_preserved_and_force_is_explicit(self):
builder.build(self.manifest, self.output)
original = self.output.read_bytes()
self.data["project"] = "新项目"
self.save()
with self.assertRaises(FileExistsError):
builder.build(self.manifest, self.output)
self.assertEqual(original, self.output.read_bytes())
builder.build(self.manifest, self.output, force=True)
self.assertNotEqual(original, self.output.read_bytes())
def test_invalid_resource_keeps_prior_output_even_with_force(self):
builder.build(self.manifest, self.output)
original = self.output.read_bytes()
self.source.write_text('<html><head></head><body><img src="missing.png"></body></html>', encoding="utf-8")
with self.assertRaises(ValueError):
builder.build(self.manifest, self.output, force=True)
self.assertEqual(original, self.output.read_bytes())
def test_duplicate_identifier_and_invalid_colors(self):
self.data["candidates"].append(self.data["candidates"][0].copy())
self.save()
with self.assertRaises(ValueError):
builder.build(self.manifest, self.output)
self.data["candidates"].pop()
self.data["candidates"][0]["palette"] = ["url(example)"]
self.save()
with self.assertRaises(ValueError):
builder.build(self.manifest, self.output)
self.assertFalse(self.output.exists())
def test_text_and_preserved_script_are_not_css_resources(self):
self.source.write_text('''<html><head><style>
/* @import "unused.css"; */
p::after {content: "example url(example.png) @import"}
</style></head><body><p>Explain @import and url(example.png)</p>
<script>const example = "url(unused.png)";</script></body></html>''', encoding="utf-8")
builder.build(self.manifest, self.output)
self.assertTrue(self.output.is_file())
def test_css_sources_are_checked_in_styles_and_inline_attributes(self):
fragments = [
'<style>.hero{background:image-set("https://example.invalid/a.png" 1x)}</style>',
'<style>.hero{background:-webkit-image-set("missing.png" 1x)}</style>',
'<style>.hero{background:u\\72l(missing.png)}</style>',
'<style>@import "missing.css";</style>',
]
for fragment in fragments:
with self.subTest(fragment=fragment):
self.source.write_text(f'<html><head>{fragment}</head><body>content</body></html>', encoding="utf-8")
with self.assertRaises(ValueError):
builder.build(self.manifest, self.output)
self.source.write_text('<html><head></head><body style="background:url(missing.png)">content</body></html>', encoding="utf-8")
with self.assertRaises(ValueError):
builder.build(self.manifest, self.output)
def test_source_escape_and_overwriting_inputs_are_rejected(self):
self.data["candidates"][0]["source"] = "../outside.html"
self.save()
with self.assertRaises(ValueError):
builder.build(self.manifest, self.output)
self.data["candidates"][0]["source"] = "sample.html"
self.save()
with self.assertRaises(ValueError):
builder.build(self.manifest, self.source, force=True)
with self.assertRaises(ValueError):
builder.build(self.manifest, self.manifest, force=True)
def test_static_image_embedded_and_content_changes_identity(self):
image = self.folder / "preview.png"
image.write_bytes(base64.b64decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+j7ioAAAAASUVORK5CYII='))
self.data["candidates"][0].update(kind="image", source="preview.png")
self.save()
first = builder.build(self.manifest, self.output)
self.assertIn("data:image/png;base64,", self.output.read_text(encoding="utf-8"))
self.data["round"] = "02"
self.save()
second = builder.build(self.manifest, self.output, force=True)
self.assertNotEqual(first["fingerprint"], second["fingerprint"])
def test_relative_local_assets_are_embedded(self):
png = base64.b64decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=")
(self.folder / "pages" / "img").mkdir(parents=True)
(self.folder / "pages" / "img" / "dot.png").write_bytes(png)
(self.folder / "pages" / "a.html").write_text('<!doctype html><html><head><style>.x{background:url("img/dot.png")}</style></head><body><img src="img/dot.png" alt=""></body></html>', encoding="utf-8")
self.data["candidates"][0]["source"] = "pages/a.html"
self.save()
builder.build(self.manifest, self.output)
page = self.output.read_text(encoding="utf-8")
self.assertNotIn("img/dot.png", page)
self.assertIn("data:image/png;base64,", page)
def test_local_assets_outside_the_manifest_folder_stay_rejected(self):
outside = Path(self.tmp.name).parent / f"{Path(self.tmp.name).name}-outside.png"
outside.write_bytes(b"\x89PNG\r\n\x1a\n")
self.addCleanup(outside.unlink)
self.source.write_text(f'<!doctype html><html><head></head><body><img src="../{outside.name}" alt=""><img src="https://example.com/a.png" alt=""></body></html>', encoding="utf-8")
with self.assertRaises(ValueError):
builder.build(self.manifest, self.output)
def test_live_candidates_accept_only_local_dev_servers(self):
self.data["candidates"].append({"id": "live", "name": "现状", "concept": "当前版本", "typography": "系统字体", "palette": ["#fff"], "traits": ["现有页面"], "kind": "url", "url": "http://localhost:5173/orders", "baseline": True})
self.save()
builder.build(self.manifest, self.output)
page = self.output.read_text(encoding="utf-8")
payload = json.loads(page.split("const DATA = ", 1)[1].split(";\n", 1)[0])
self.assertEqual([c["id"] for c in payload["candidates"]], ["live", "a"])
for url in ("https://example.com/", "file:///etc/passwd", "http://user:pw@localhost:3000/"):
self.data["candidates"][1]["url"] = url
self.save()
with self.assertRaises(ValueError):
builder.build(self.manifest, self.output, force=True)
def test_serve_accepts_command_and_optional_absolute_cwd_and_local_url(self):
for serve in (
{"command": "pnpm dev"},
{"command": "pnpm dev", "cwd": str(self.folder)},
{"command": "pnpm dev", "cwd": str(self.folder), "url": "http://localhost:3456"},
{"command": "pnpm dev", "url": "https://127.0.0.1:3456/"},
{"command": "pnpm dev", "url": "http://[::1]:3456/"},
):
with self.subTest(serve=serve):
self.data["serve"] = serve
self.save()
builder.build(self.manifest, self.output, force=True)
page = self.output.read_text(encoding="utf-8")
payload = json.loads(page.split("const DATA = ", 1)[1].split(";\n", 1)[0])
self.assertEqual(payload["serve"], serve)
def test_serve_requires_a_nonempty_string_command(self):
for serve in ({}, {"command": ""}, {"command": " "}, {"command": 123}, {"command": None}):
with self.subTest(serve=serve):
self.data["serve"] = serve
self.save()
with self.assertRaisesRegex(ValueError, "serve.command 必须是非空字符串"):
builder.build(self.manifest, self.output)
for serve in (None, [], "pnpm dev"):
with self.subTest(serve=serve):
self.data["serve"] = serve
self.save()
with self.assertRaisesRegex(ValueError, "serve 必须是对象"):
builder.build(self.manifest, self.output)
self.assertFalse(self.output.exists())
def test_serve_cwd_must_be_an_absolute_path(self):
for cwd in ("project", "./project", "~/project", "", None, 123):
with self.subTest(cwd=cwd):
self.data["serve"] = {"command": "pnpm dev", "cwd": cwd}
self.save()
with self.assertRaisesRegex(ValueError, "serve.cwd 必须是绝对路径"):
builder.build(self.manifest, self.output)
def test_serve_url_must_be_local_http_or_https(self):
for url in ("https://example.com/", "file:///tmp/index.html", "ftp://localhost/", "http://user:pw@localhost:3456/", "http://localhost:bad/", "http://[::1", "", None, 123):
with self.subTest(url=url):
self.data["serve"] = {"command": "pnpm dev", "url": url}
self.save()
with self.assertRaisesRegex(ValueError, "serve.*url.*本机"):
builder.build(self.manifest, self.output)
def test_serve_is_embedded_unchanged_without_script_escape(self):
serve = {"command": " printf '</script><script>window.injected=true</script>'\u2028\u2029 ", "cwd": str(self.folder / 'a"$`\\b'), "url": "http://localhost:3456", "note": "保留额外元数据"}
self.data["serve"] = serve
self.save()
builder.build(self.manifest, self.output)
page = self.output.read_text(encoding="utf-8")
payload = json.loads(page.split("const DATA = ", 1)[1].split(";\n", 1)[0])
self.assertEqual(payload["serve"], serve)
self.assertNotIn(serve["command"], page)
self.assertIn('\\u003c/script>', page)
self.assertIn('\\u2028\\u2029', page)
def test_url_candidates_without_serve_warn_but_still_build(self):
self.data["candidates"][0].update(kind="url", url="http://localhost:3456/")
self.save()
messages = io.StringIO()
with redirect_stderr(messages):
builder.build(self.manifest, self.output)
self.assertTrue(self.output.is_file())
self.assertEqual(len(messages.getvalue().splitlines()), 1)
self.assertIn("建议在 manifest 顶层补上 serve", messages.getvalue())
self.data["serve"] = {"command": "pnpm dev"}
self.save()
messages = io.StringIO()
with redirect_stderr(messages):
builder.build(self.manifest, self.output, force=True)
self.assertEqual(messages.getvalue(), "")
def test_shell_connections_allow_only_this_rounds_candidate_origins(self):
self.data["serve"] = {"command": "pnpm dev", "url": "http://localhost:9999/"}
urls = ["http://localhost:3456/a?note=\"<script>", "http://localhost:3456/b", "https://127.0.0.1:4443/", "http://[::1]:5173/"]
self.data["candidates"] = [dict(self.data["candidates"][0], id=f"live{i}", kind="url", url=url) for i, url in enumerate(urls)]
self.save()
builder.build(self.manifest, self.output)
policies = []
class Policies(HTMLParser):
def handle_starttag(self, tag, attrs):
attrs = dict(attrs)
if tag == "meta" and attrs.get("http-equiv") == "Content-Security-Policy":
policies.append(attrs["content"])
Policies().feed(self.output.read_text(encoding="utf-8"))
self.assertEqual(policies, ["connect-src http://localhost:3456 http://localhost:5173 https://127.0.0.1:4443"])
self.data["candidates"] = [dict(self.data["candidates"][0], kind="html")]
self.save()
builder.build(self.manifest, self.output, force=True)
self.assertIn('content="connect-src \'none\'"', self.output.read_text(encoding="utf-8"))
def test_interactive_html_runs_inline_scripts_only_when_asked(self):
self.assertIn("script-src 'none'", builder.prepare_html(self.source))
csp = builder.prepare_html(self.source, interactive=True)
self.assertIn("script-src 'unsafe-inline'", csp)
self.assertIn("localStorage", csp)
self.assertNotIn("localStorage", builder.prepare_html(self.source))
self.assertIn("default-src 'none'", csp)
self.data["candidates"][0]["interactive"] = True
self.save()
builder.build(self.manifest, self.output)
self.source.write_text('<html><head><script src="https://example.invalid/a.js"></script></head><body></body></html>', encoding="utf-8")
with self.assertRaises(ValueError):
builder.build(self.manifest, self.output, force=True)
self.data["candidates"][0].update(kind="image", interactive=True)
self.save()
with self.assertRaises(ValueError):
builder.build(self.manifest, self.output, force=True)
def test_local_stylesheets_and_scripts_are_inlined(self):
(self.folder / "vendor").mkdir()
(self.folder / "vendor" / "lib.js").write_text("window.lib='</script>'", encoding="utf-8")
(self.folder / "app.css").write_text("body{color:#123}", encoding="utf-8")
self.source.write_text('<!doctype html><html><head><link rel="stylesheet" href="app.css">'
'<script defer src="vendor/lib.js"></script></head><body><p>内容</p></body></html>', encoding="utf-8")
static = builder.prepare_html(self.source, root=self.folder)
self.assertIn("<style>body{color:#123}</style>", static)
self.assertNotIn("lib.js", static)
self.assertNotIn("window.lib", static)
live = builder.prepare_html(self.source, interactive=True, root=self.folder)
self.assertIn("'unsafe-eval'", live)
self.assertLess(live.index("<p>内容</p>"), live.index("window.lib"))
self.assertIn("<\\/script>", live)
self.data["candidates"][0]["interactive"] = True
self.save()
builder.build(self.manifest, self.output)
def test_only_one_baseline(self):
second = dict(self.data["candidates"][0], id="b", baseline=True)
self.data["candidates"][0]["baseline"] = True
self.data["candidates"].append(second)
self.save()
with self.assertRaises(ValueError):
builder.build(self.manifest, self.output)
def test_template_keeps_documented_comparison_features(self):
builder.build(self.manifest, self.output)
page = self.output.read_text(encoding="utf-8")
hooks = {
"并排与单张": 'data-layout="loupe"',
"手机视口": 'data-viewport="mobile"',
"筛选": 'id="filter-list"',
"设计说明开关": 'id="notes-toggle"',
"实际尺寸": '实际尺寸 100%',
"选择": "st.chosen",
"备注": 'id="notes"',
"选择即复制": 'navigator.clipboard',
"本地地址候选": "c.kind==='url'",
"服务未运行提示": "开发服务器没有运行",
"复制启动命令": "button.dataset.copyServe",
"现状基线": "c.baseline",
"可操作小样": "c.interactive",
"按轮次保存": "DATA.fingerprint",
"存储不可用提示": "浏览器存储不可用",
"展示北极星": "c.concept",
"展示色板": "c.palette",
}
missing = [name for name, hook in hooks.items() if hook not in page]
self.assertEqual(missing, [], "模板缺少对比页承诺的功能,见 .github/EXPLORER.md")
def test_copied_skill_works_from_another_directory(self):
copy = self.folder / "relocated"
shutil.copytree(ROOT / "scripts", copy / "scripts", ignore=shutil.ignore_patterns("__pycache__"))
shutil.copytree(ROOT / "assets", copy / "assets")
run = subprocess.run([sys.executable, str(copy / "scripts" / "build_explorer.py"), str(self.manifest), "--output", str(self.output)], cwd=self.folder, capture_output=True, text=True)
self.assertEqual(run.returncode, 0, run.stderr)
self.assertTrue(self.output.is_file())
if __name__ == "__main__":
unittest.main()
-357
View File
@@ -1,357 +0,0 @@
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()
-215
View File
@@ -1,215 +0,0 @@
"""Exercise the zero-dependency screenshot CLI against a real local browser."""
import json
import os
from pathlib import Path
import shutil
import subprocess
import sys
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[1]
SCRIPT = ROOT / "scripts" / "shoot.mjs"
NODE = shutil.which("node")
def find_browser():
candidates = [os.environ.get("CHROME_PATH")]
if sys.platform == "darwin":
candidates += [
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"/Applications/Chromium.app/Contents/MacOS/Chromium",
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
]
elif sys.platform == "win32":
for variable in ("PROGRAMFILES", "PROGRAMFILES(X86)"):
root = os.environ.get(variable)
if root:
candidates += [
str(Path(root) / "Google/Chrome/Application/chrome.exe"),
str(Path(root) / "Microsoft/Edge/Application/msedge.exe"),
]
candidates += [shutil.which(name) for name in (
"google-chrome", "google-chrome-stable", "chromium", "chromium-browser", "microsoft-edge",
)]
return next((path for path in candidates if path and Path(path).is_file()), None)
@unittest.skipUnless(NODE, "Node is not installed")
class ShootCLITests(unittest.TestCase):
def test_help(self):
result = subprocess.run([NODE, str(SCRIPT), "--help"], cwd=ROOT,
capture_output=True, text=True, timeout=10)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn("用法", result.stdout)
def test_missing_target(self):
result = subprocess.run([NODE, str(SCRIPT)], cwd=ROOT,
capture_output=True, text=True, timeout=10)
self.assertNotEqual(result.returncode, 0)
def test_unknown_option(self):
help_result = subprocess.run([NODE, str(SCRIPT), "--help"], cwd=ROOT,
capture_output=True, text=True, timeout=10)
self.assertEqual(help_result.returncode, 0, help_result.stderr)
options = [line.split()[0] for line in help_result.stdout.splitlines()
if line.startswith(" --")]
for args in (("--xxx",), ("--xxx", "value")):
with self.subTest(args=args):
result = subprocess.run([NODE, str(SCRIPT), *args], cwd=ROOT,
capture_output=True, text=True, timeout=10)
self.assertEqual(result.returncode, 1)
self.assertEqual(result.stderr.splitlines(), [
"shoot:不认识的选项 --xxx",
"可用选项:" + " ".join(options),
])
self.assertEqual(result.stdout, "")
def test_missing_option_value(self):
for option in ("--out", "--size", "--states", "--param", "--zoom", "--steps", "--hold", "--wait"):
for following in ((), ("--force",)):
with self.subTest(option=option, following=following):
result = subprocess.run([NODE, str(SCRIPT), option, *following], cwd=ROOT,
capture_output=True, text=True, timeout=10)
self.assertEqual(result.returncode, 1)
self.assertEqual(result.stderr, f"shoot:{option} 需要一个值\n")
def test_force_is_ignored(self):
result = subprocess.run([NODE, str(SCRIPT), "--force"], cwd=ROOT,
capture_output=True, text=True, timeout=10)
self.assertEqual(result.returncode, 1)
self.assertEqual(result.stderr, "shoot:缺少页面地址或文件。\n")
class ShootBrowserTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
if not NODE:
raise unittest.SkipTest("Node 22+ is not installed")
version = subprocess.run([NODE, "--version"], capture_output=True, text=True, timeout=10)
if version.returncode or int(version.stdout.strip().lstrip("v").split(".")[0]) < 22:
raise unittest.SkipTest("Node 22+ is required")
cls.browser = find_browser()
if not cls.browser:
raise unittest.SkipTest("Chrome, Chromium or Edge is not installed")
def setUp(self):
self.tmp = tempfile.TemporaryDirectory(prefix="oil-shoot-test-")
self.addCleanup(self.tmp.cleanup)
self.folder = Path(self.tmp.name)
self.page = self.folder / "sample.html"
self.page.write_text('''<!doctype html><html><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" href="data:,">
<style>
body { margin: 0; padding: 24px; background: #fde68a; font: 20px sans-serif; }
body[data-state="b"] { background: #bfdbfe; }
button { padding: 16px; }
</style></head><body><h1 id="state"></h1><button id="go">切换</button>
<script>
const state = new URLSearchParams(location.search).get('state') || 'a';
function show(value) {
document.body.dataset.state = value;
document.querySelector('#state').textContent = value;
}
show(state);
document.querySelector('#go').onclick = () => show(document.body.dataset.state === 'a' ? 'b' : 'a');
</script></body></html>''', encoding="utf-8")
self.env = dict(os.environ, CHROME_PATH=self.browser)
self.profile_root = self.folder / "profiles"
self.profile_root.mkdir()
self.env.update(TMPDIR=str(self.profile_root), TMP=str(self.profile_root), TEMP=str(self.profile_root))
def shoot(self, output, *args):
result = subprocess.run([NODE, str(SCRIPT), str(self.page), "--out", str(output), *args],
cwd=ROOT, env=self.env, capture_output=True, text=True, timeout=90)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
return result
def assert_artifacts(self, output, names):
for name in names:
with self.subTest(file=name):
artifact = output / name
self.assertTrue(artifact.is_file(), name)
self.assertGreater(artifact.stat().st_size, 0, name)
if artifact.suffix == ".png":
self.assertTrue(artifact.read_bytes().startswith(b"\x89PNG\r\n\x1a\n"), name)
elif artifact.suffix == ".jpg":
self.assertTrue(artifact.read_bytes().startswith(b"\xff\xd8"), name)
def test_states_masks_and_sheets(self):
output = self.folder / "shots"
self.shoot(output, "--states", "a,b", "--mask", "--sheet")
self.assert_artifacts(output, (
"a.png", "b.png", "a-masked.png", "b-masked.png",
"sheet.png", "sheet-masked.png", "report.json",
))
report = json.loads((output / "report.json").read_text(encoding="utf-8"))
self.assertEqual([entry["state"] for entry in report], ["a", "b"])
self.assertEqual([entry["issues"] for entry in report], [[], []])
self.assertNotEqual((output / "a.png").read_bytes(), (output / "b.png").read_bytes())
self.assertNotEqual((output / "a.png").read_bytes(), (output / "a-masked.png").read_bytes())
self.assertEqual(list(self.profile_root.glob("oil-shoot-*")), [], "Temporary browser profiles leaked")
def test_force_overwrites_existing_output(self):
output = self.folder / "shots"
output.mkdir()
(output / "page.png").write_bytes(b"old screenshot")
self.shoot(output, "--force")
self.assert_artifacts(output, ("page.png", "report.json"))
def test_record_steps(self):
output = self.folder / "record's output"
result = self.shoot(output, "--record", "--steps", "click #go; wait 300", "--hold", "300")
self.assert_artifacts(output, ("motion-start.jpg", "motion-mid.jpg", "motion-end.jpg"))
self.assertNotEqual((output / "motion-start.jpg").read_bytes(), (output / "motion-end.jpg").read_bytes())
report = json.loads((output / "report.json").read_text(encoding="utf-8"))
self.assertEqual([entry["issues"] for entry in report], [[]])
self.assertEqual(list(self.profile_root.glob("oil-shoot-*")), [], "Temporary browser profiles leaked")
if shutil.which("ffmpeg"):
self.assertTrue((output / "record.mp4").is_file(), result.stdout + result.stderr)
self.assert_artifacts(output, ("record.mp4",))
def test_mask_preserves_current_color_icons(self):
self.page.write_text('''<!doctype html><html><head>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" href="data:,"></head><body>
<svg width="80" height="80" viewBox="0 0 80 80" style="color:#16a34a">
<circle cx="40" cy="40" r="32" fill="currentColor" /></svg>
</body></html>''', encoding="utf-8")
output = self.folder / "icons"
self.shoot(output, "--mask")
self.assertEqual((output / "page.png").read_bytes(), (output / "page-masked.png").read_bytes(),
"Masking text must preserve icons using currentColor")
def test_record_preserves_final_hold(self):
if not shutil.which("ffmpeg") or not shutil.which("ffprobe"):
self.skipTest("ffmpeg and ffprobe are required to check recording duration")
output = self.folder / "hold"
self.shoot(output, "--record", "--steps", "click #go; wait 300", "--hold", "2000")
result = subprocess.run([
"ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "json",
str(output / "record.mp4"),
], capture_output=True, text=True, timeout=10)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertGreaterEqual(float(json.loads(result.stdout)["format"]["duration"]), 2.0)
def test_reports_page_problems(self):
self.page.write_text(self.page.read_text(encoding="utf-8").replace("</body>", '''
<div style="width:2000px">溢出</div><img src="missing.png">
<script>console.error('shoot-test-error'); throw new Error('shoot-test-exception');</script>
</body>'''), encoding="utf-8")
for args in ((), ("--record", "--hold", "300")):
with self.subTest(record=bool(args)):
output = self.folder / ("problem-record" if args else "problem-shots")
self.shoot(output, "--size", "1280x900", *args)
report = json.loads((output / "report.json").read_text(encoding="utf-8"))
issues = "\n".join(report[0]["issues"])
for expected in ("shoot-test-error", "shoot-test-exception", "横向溢出", "图片没加载出来"):
self.assertIn(expected, issues)
if __name__ == "__main__":
unittest.main()