release: public alpha 0.6.0 baseline
This commit is contained in:
@@ -0,0 +1,345 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tarfile
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
CONFIG_SRC = PROJECT_ROOT / "packages" / "jwebot-config" / "src"
|
||||
sys.path.insert(0, str(CONFIG_SRC))
|
||||
|
||||
from jwebot_config import ( # noqa: E402
|
||||
DEFAULT_PROFILE_FILES,
|
||||
ConfigError,
|
||||
ConfigLayout,
|
||||
activate_profile,
|
||||
archive_legacy_material,
|
||||
compile_profile,
|
||||
export_profile,
|
||||
import_profile,
|
||||
load_active_environment,
|
||||
load_private_context,
|
||||
migrate_legacy_context,
|
||||
validate_profile,
|
||||
write_default_profile,
|
||||
)
|
||||
|
||||
|
||||
class ConfigProfileTests(unittest.TestCase):
|
||||
def layout(self, root: Path) -> ConfigLayout:
|
||||
return ConfigLayout(
|
||||
config_root=root / "config",
|
||||
state_root=root / "state",
|
||||
)
|
||||
|
||||
def test_repository_template_matches_generated_defaults(self):
|
||||
template = PROJECT_ROOT / "config" / "templates" / "default"
|
||||
for relative, expected in DEFAULT_PROFILE_FILES.items():
|
||||
with self.subTest(path=relative):
|
||||
self.assertEqual(
|
||||
(template / relative).read_text(encoding="utf-8"),
|
||||
expected,
|
||||
)
|
||||
|
||||
def test_default_profile_compiles_and_activates_atomically(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
layout = self.layout(root)
|
||||
profile = write_default_profile("workstation", layout)
|
||||
|
||||
self.assertEqual(validate_profile(profile), "workstation")
|
||||
generated = compile_profile(profile, layout)
|
||||
activate_profile("workstation", layout)
|
||||
|
||||
self.assertTrue(layout.current_link.is_symlink())
|
||||
self.assertEqual(layout.active_profile(), profile.resolve())
|
||||
self.assertEqual(
|
||||
os.stat(generated / "runtime.env").st_mode & 0o777,
|
||||
0o600,
|
||||
)
|
||||
for directory_path in (
|
||||
layout.config_root,
|
||||
layout.profiles_root,
|
||||
layout.state_root,
|
||||
layout.state_root / "profiles",
|
||||
layout.profile_state("workstation"),
|
||||
):
|
||||
self.assertEqual(
|
||||
os.stat(directory_path).st_mode & 0o777,
|
||||
0o700,
|
||||
)
|
||||
environment = load_active_environment(layout)
|
||||
self.assertEqual(environment["JWEBOT_CONFIG_PROFILE"], "workstation")
|
||||
self.assertEqual(environment["JWEBOT_EDGE_ENABLED"], "1")
|
||||
self.assertEqual(environment["JWEBOT_EDGE_STEAM_API_KEY"], "")
|
||||
self.assertEqual(
|
||||
load_private_context(layout)["bot"]["mention"],
|
||||
"JWeBot",
|
||||
)
|
||||
|
||||
def test_private_context_and_component_values_are_compiled_separately(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
layout = self.layout(root)
|
||||
profile = write_default_profile("custom", layout)
|
||||
(profile / "context" / "people.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"people": [
|
||||
{
|
||||
"canonical_name": "Alice",
|
||||
"aliases": ["alice-ocr"],
|
||||
"prefixes": [],
|
||||
"identity_fact": "",
|
||||
"display_id": "alice",
|
||||
"steam_id64": "10000000000000001",
|
||||
"steam_aliases": ["alice-steam"],
|
||||
}
|
||||
],
|
||||
"clock_scan_targets": [],
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(profile / "secrets" / "runtime.env").write_text(
|
||||
"JWEBOT_EDGE_DEEPSEEK_API_KEY=test-secret\n"
|
||||
"JWEBOT_EDGE_STEAM_API_KEY=\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
generated = compile_profile(profile, layout)
|
||||
environment = (generated / "runtime.env").read_text(encoding="utf-8")
|
||||
context = json.loads(
|
||||
(generated / "private-context.json").read_text(encoding="utf-8")
|
||||
)
|
||||
|
||||
self.assertIn("JWEBOT_EDGE_DEEPSEEK_API_KEY=test-secret", environment)
|
||||
self.assertNotIn("alice-ocr", environment)
|
||||
self.assertEqual(context["people"][0]["display_id"], "alice")
|
||||
self.assertEqual(
|
||||
context["people"][0]["steam_id64"],
|
||||
"10000000000000001",
|
||||
)
|
||||
self.assertEqual(
|
||||
context["people"][0]["steam_aliases"],
|
||||
["alice-steam"],
|
||||
)
|
||||
self.assertNotIn("test-secret", json.dumps(context))
|
||||
|
||||
def test_redacted_export_removes_secrets_and_people(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
layout = self.layout(root)
|
||||
profile = write_default_profile("shareable", layout)
|
||||
(profile / "secrets" / "runtime.env").write_text(
|
||||
"JWEBOT_EDGE_DEEPSEEK_API_KEY=test-secret\n"
|
||||
"JWEBOT_EDGE_STEAM_API_KEY=another-secret\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(profile / "context" / "people.json").write_text(
|
||||
'{"people":[{"canonical_name":"Alice","aliases":["a"],'
|
||||
'"prefixes":[],"identity_fact":"","display_id":"a"}],'
|
||||
'"clock_scan_targets":[]}\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
output = root / "shareable.tar.gz"
|
||||
|
||||
export_profile("shareable", output, layout)
|
||||
|
||||
with tarfile.open(output, "r:gz") as archive:
|
||||
secret_text = archive.extractfile(
|
||||
"shareable/secrets/runtime.env"
|
||||
).read().decode()
|
||||
context_text = archive.extractfile(
|
||||
"shareable/context/people.json"
|
||||
).read().decode()
|
||||
self.assertNotIn("test-secret", secret_text)
|
||||
self.assertNotIn("another-secret", secret_text)
|
||||
self.assertEqual(json.loads(context_text)["people"], [])
|
||||
|
||||
def test_private_context_rejects_duplicate_steam_ids(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
layout = self.layout(root)
|
||||
profile = write_default_profile("duplicate-steam", layout)
|
||||
people = []
|
||||
for name in ("Alice", "Bob"):
|
||||
people.append(
|
||||
{
|
||||
"canonical_name": name,
|
||||
"aliases": [],
|
||||
"prefixes": [],
|
||||
"identity_fact": "",
|
||||
"display_id": name.casefold(),
|
||||
"steam_id64": "10000000000000001",
|
||||
"steam_aliases": [],
|
||||
}
|
||||
)
|
||||
(profile / "context" / "people.json").write_text(
|
||||
json.dumps(
|
||||
{"people": people, "clock_scan_targets": []}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
with self.assertRaisesRegex(ConfigError, "must be unique"):
|
||||
validate_profile(profile)
|
||||
|
||||
def test_import_rejects_archive_path_traversal(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
layout = self.layout(root)
|
||||
archive_path = root / "unsafe.tar"
|
||||
payload = root / "payload"
|
||||
payload.write_text("value", encoding="utf-8")
|
||||
with tarfile.open(archive_path, "w") as archive:
|
||||
archive.add(payload, arcname="../outside")
|
||||
|
||||
with self.assertRaisesRegex(ConfigError, "unsafe path"):
|
||||
import_profile(archive_path, layout)
|
||||
|
||||
def test_active_profile_rejects_symlink_outside_profile_root(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
layout = self.layout(root)
|
||||
layout.config_root.mkdir(parents=True)
|
||||
outside = root / "outside"
|
||||
outside.mkdir()
|
||||
layout.current_link.symlink_to(outside)
|
||||
|
||||
with self.assertRaisesRegex(ConfigError, "outside"):
|
||||
layout.active_profile()
|
||||
|
||||
def test_legacy_debug_material_is_archived_outside_the_repository(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
repository = root / "repository"
|
||||
repository.mkdir()
|
||||
(repository / ".dbg").mkdir()
|
||||
(repository / ".dbg" / "session.env").write_text(
|
||||
"DEBUG_VALUE=local\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(repository / "debug-incident.md").write_text(
|
||||
"private incident",
|
||||
encoding="utf-8",
|
||||
)
|
||||
layout = self.layout(root)
|
||||
write_default_profile("migrated", layout)
|
||||
|
||||
archive = archive_legacy_material(
|
||||
repository,
|
||||
"migrated",
|
||||
layout,
|
||||
)
|
||||
|
||||
self.assertTrue((archive / ".dbg" / "session.env").is_file())
|
||||
self.assertTrue((archive / "debug-incident.md").is_file())
|
||||
self.assertEqual(
|
||||
os.stat(archive / ".dbg" / "session.env").st_mode & 0o777,
|
||||
0o600,
|
||||
)
|
||||
|
||||
def test_legacy_context_is_migrated_into_private_profile(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
repository = root / "repository"
|
||||
source = (
|
||||
repository
|
||||
/ "packages"
|
||||
/ "jwebot-kernel"
|
||||
/ "src"
|
||||
/ "jwebot_kernel"
|
||||
)
|
||||
source.mkdir(parents=True)
|
||||
(source / "edge_runtime.py").write_text(
|
||||
"\n".join(
|
||||
(
|
||||
'BOT_IDENTITY_NOTE = "ExampleBot identity"',
|
||||
"PERSON_ALIAS_TEMPLATES = (",
|
||||
" {",
|
||||
' "canonical_name": "Alice",',
|
||||
' "aliases": ("alice-ocr",),',
|
||||
' "prefixes": (),',
|
||||
' "identity_fact": "Alice is a test user",',
|
||||
" },",
|
||||
" {",
|
||||
' "canonical_name": "Bob",',
|
||||
' "aliases": ("bob-ocr",),',
|
||||
' "prefixes": (),',
|
||||
' "identity_fact": "Bob is a test user",',
|
||||
" },",
|
||||
")",
|
||||
'PERSON_DISPLAY_IDS = {"Alice": "alice", "Bob": "bob"}',
|
||||
'CLOCK_SCAN_TARGETS = (("Alice", "alice"), ("Bob", "bob"))',
|
||||
)
|
||||
)
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
steam_source = (
|
||||
repository
|
||||
/ "packages"
|
||||
/ "jwebot-official-extensions"
|
||||
/ "src"
|
||||
/ "jwebot_official_extensions"
|
||||
)
|
||||
steam_source.mkdir(parents=True)
|
||||
(steam_source / "steam.py").write_text(
|
||||
"\n".join(
|
||||
(
|
||||
'STEAM_GROUP_ROSTER = (("bob", "10000000000000002"),)',
|
||||
"STEAM_ALIAS_LABELS = {",
|
||||
' "alice-steam": "alice",',
|
||||
' "bob-steam": "bob",',
|
||||
"}",
|
||||
)
|
||||
)
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
environment = root / "environment"
|
||||
environment.write_text(
|
||||
"JWEBOT_EDGE_STEAM_ID64=10000000000000001\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
layout = self.layout(root)
|
||||
profile = write_default_profile("migrated", layout)
|
||||
|
||||
self.assertTrue(
|
||||
migrate_legacy_context(
|
||||
repository,
|
||||
"migrated",
|
||||
layout,
|
||||
environment=environment,
|
||||
)
|
||||
)
|
||||
context = json.loads(
|
||||
(profile / "context" / "people.json").read_text(encoding="utf-8")
|
||||
)
|
||||
bot = (profile / "components" / "bot.toml").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
|
||||
self.assertEqual(context["people"][0]["display_id"], "alice")
|
||||
self.assertEqual(context["clock_scan_targets"][0]["display_id"], "alice")
|
||||
self.assertEqual(
|
||||
[person["steam_id64"] for person in context["people"]],
|
||||
["10000000000000001", "10000000000000002"],
|
||||
)
|
||||
self.assertEqual(
|
||||
[person["steam_aliases"] for person in context["people"]],
|
||||
[["alice-steam"], ["bob-steam"]],
|
||||
)
|
||||
self.assertIn('mention = "ExampleBot"', bot)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,135 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import importlib.util
|
||||
import py_compile
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
SDK_SRC = PROJECT_ROOT / "packages" / "jwebot-sdk" / "src"
|
||||
KERNEL_SRC = PROJECT_ROOT / "packages" / "jwebot-kernel" / "src"
|
||||
sys.path[:0] = [str(SDK_SRC), str(KERNEL_SRC)]
|
||||
|
||||
SPEC = importlib.util.spec_from_file_location(
|
||||
"create_plugin",
|
||||
PROJECT_ROOT / "tools" / "create-plugin.py",
|
||||
)
|
||||
MODULE = importlib.util.module_from_spec(SPEC)
|
||||
assert SPEC.loader is not None
|
||||
SPEC.loader.exec_module(MODULE)
|
||||
|
||||
|
||||
class CreatePluginTests(unittest.TestCase):
|
||||
def test_generator_creates_a_bounded_command_extension(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
destination = MODULE.create_plugin(
|
||||
Path(directory),
|
||||
"weather-command",
|
||||
"/weather",
|
||||
"Friend",
|
||||
"查询天气",
|
||||
)
|
||||
self.assertTrue((destination / "plugin.toml").is_file())
|
||||
manifest = (destination / "plugin.toml").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
self.assertIn('description = "查询天气"', manifest)
|
||||
self.assertIn('help = ["/weather"]', manifest)
|
||||
module = destination / "src" / "weather_command.py"
|
||||
self.assertIn(
|
||||
'context.commands.register("/weather", handle)',
|
||||
module.read_text(encoding="utf-8"),
|
||||
)
|
||||
self.assertNotIn(
|
||||
"subprocess",
|
||||
module.read_text(encoding="utf-8"),
|
||||
)
|
||||
py_compile.compile(str(module), doraise=True)
|
||||
py_compile.compile(
|
||||
str(destination / "tests" / "test_plugin.py"),
|
||||
doraise=True,
|
||||
)
|
||||
|
||||
def test_generator_refuses_to_overwrite_existing_plugin(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
output = Path(directory)
|
||||
MODULE.create_plugin(
|
||||
output,
|
||||
"weather-command",
|
||||
"/weather",
|
||||
"Friend",
|
||||
"查询天气",
|
||||
)
|
||||
with self.assertRaises(FileExistsError):
|
||||
MODULE.create_plugin(
|
||||
output,
|
||||
"weather-command",
|
||||
"/weather",
|
||||
"Friend",
|
||||
"查询天气",
|
||||
)
|
||||
|
||||
def test_generator_rejects_path_traversal_plugin_id(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
with self.assertRaisesRegex(ValueError, "invalid format"):
|
||||
MODULE.create_plugin(
|
||||
Path(directory),
|
||||
"../outside",
|
||||
"/weather",
|
||||
"Friend",
|
||||
"查询天气",
|
||||
)
|
||||
|
||||
def test_generator_rejects_invalid_command(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
with self.assertRaisesRegex(ValueError, "invalid value"):
|
||||
MODULE.create_plugin(
|
||||
Path(directory),
|
||||
"weather-command",
|
||||
"weather",
|
||||
"Friend",
|
||||
"查询天气",
|
||||
"/weather",
|
||||
)
|
||||
|
||||
def test_generator_rejects_command_template_injection(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
with self.assertRaisesRegex(ValueError, "invalid value"):
|
||||
MODULE.create_plugin(
|
||||
Path(directory),
|
||||
"weather-command",
|
||||
'/"\\nentrypoint = "attacker:run',
|
||||
"Friend",
|
||||
"查询天气",
|
||||
"/weather",
|
||||
)
|
||||
|
||||
def test_generator_rejects_invalid_author(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
with self.assertRaisesRegex(ValueError, "author has an invalid format"):
|
||||
MODULE.create_plugin(
|
||||
Path(directory),
|
||||
"weather-command",
|
||||
"/weather",
|
||||
"../Friend",
|
||||
"查询天气",
|
||||
)
|
||||
|
||||
def test_generator_rejects_invalid_help_description(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
with self.assertRaisesRegex(ValueError, "invalid display"):
|
||||
MODULE.create_plugin(
|
||||
Path(directory),
|
||||
"weather-command",
|
||||
"/weather",
|
||||
"Friend",
|
||||
"查询天气",
|
||||
"plain text",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,205 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
USER_INSTALLER = (
|
||||
PROJECT_ROOT / "deploy" / "scripts" / "install-wechat-health-watch.sh"
|
||||
)
|
||||
PRODUCTION_INSTALLER = (
|
||||
PROJECT_ROOT / "deploy" / "scripts" / "deploy-jwebot-production.sh"
|
||||
)
|
||||
BOOTSTRAP = PROJECT_ROOT / "deploy" / "scripts" / "bootstrap-jwebot.sh"
|
||||
LEGACY_INSTALLER = (
|
||||
PROJECT_ROOT / "deploy" / "scripts" / "install-wechat-local-ocr.sh"
|
||||
)
|
||||
LEGACY_UPGRADER = (
|
||||
PROJECT_ROOT / "deploy" / "scripts" / "upgrade-wechat-local-ocr.sh"
|
||||
)
|
||||
ADAPTIVE_UNIT = (
|
||||
PROJECT_ROOT
|
||||
/ "deploy"
|
||||
/ "systemd"
|
||||
/ "user"
|
||||
/ "wechat-ocr-adaptive-recorder.service"
|
||||
)
|
||||
PACKAGE_GATE = PROJECT_ROOT / "tools" / "check-packages.sh"
|
||||
LOCAL_GATE = PROJECT_ROOT / "tools" / "check-local.sh"
|
||||
|
||||
|
||||
class DeploymentScriptTests(unittest.TestCase):
|
||||
def test_installers_are_executable_and_parse_with_their_shells(self):
|
||||
for path, shell in (
|
||||
(BOOTSTRAP, "bash"),
|
||||
(USER_INSTALLER, "sh"),
|
||||
(PRODUCTION_INSTALLER, "bash"),
|
||||
):
|
||||
self.assertTrue(os.access(path, os.X_OK), path)
|
||||
result = subprocess.run(
|
||||
[shell, "-n", str(path)],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
|
||||
def test_user_installer_owns_the_complete_adaptive_runtime(self):
|
||||
source = USER_INSTALLER.read_text(encoding="utf-8")
|
||||
for required in (
|
||||
"wechat_local_ocr.py",
|
||||
"wechat_health_watch.py",
|
||||
"wechat_adaptive_recorder.py",
|
||||
"wechat_adaptive_gui_bridge.py",
|
||||
"jwebot_edge.py",
|
||||
"jwebot_edge_delivery.py",
|
||||
"jwebot_config",
|
||||
"jwebot_sdk",
|
||||
"jwebot_kernel",
|
||||
"jwebot_official_extensions",
|
||||
):
|
||||
self.assertIn(required, source)
|
||||
|
||||
def test_user_installer_has_rollback_and_stability_gates(self):
|
||||
source = USER_INSTALLER.read_text(encoding="utf-8")
|
||||
for required in (
|
||||
"systemd-user-state.tsv",
|
||||
"restore_unit_states",
|
||||
"legacy system OCR is active",
|
||||
'{"op":"health"}',
|
||||
"EDGE_HEALTH_TIMEOUT_SECONDS:-45",
|
||||
"wait_for_edge_health",
|
||||
"ADAPTIVE_READY_TIMEOUT_SECONDS:-90",
|
||||
"wait_for_adaptive_ready",
|
||||
"adaptive-start.marker",
|
||||
'value.get("initialized") is True',
|
||||
'captured_at.timestamp() >= int(sys.argv[2])',
|
||||
"NRestarts",
|
||||
'test "$restarts_before" = "$restarts_after"',
|
||||
"[JWeBot] %s",
|
||||
'jwebot_log "edge starting"',
|
||||
'jwebot_log "edge active health=ok"',
|
||||
'jwebot_log "adaptive starting"',
|
||||
'jwebot_log "adaptive active"',
|
||||
'jwebot_log "adaptive ready baseline=captured"',
|
||||
'jwebot_log "user runtime ready"',
|
||||
):
|
||||
self.assertIn(required, source)
|
||||
self.assertLess(
|
||||
source.index('jwebot_log "adaptive active"'),
|
||||
source.index('jwebot_log "adaptive ready baseline=captured"'),
|
||||
)
|
||||
self.assertLess(
|
||||
source.index('jwebot_log "adaptive ready baseline=captured"'),
|
||||
source.index('jwebot_log "user runtime ready"'),
|
||||
)
|
||||
|
||||
def test_root_installer_enforces_the_production_cutover_contract(self):
|
||||
source = PRODUCTION_INSTALLER.read_text(encoding="utf-8")
|
||||
for required in (
|
||||
"production deploy must run as root",
|
||||
"assert_queues_empty",
|
||||
"assert_wechat_ready",
|
||||
'[[ "${title}" == "Weixin" ]]',
|
||||
"WECHAT_READY_TIMEOUT_SECONDS:-30",
|
||||
"/usr/sbin/runuser",
|
||||
"/usr/bin/xdotool search --onlyvisible --class wechat",
|
||||
"/usr/bin/xdotool search --onlyvisible --name '^Weixin$'",
|
||||
'jwebot_log "WeChat readiness starting timeout=',
|
||||
'source.backup(target)',
|
||||
"restore_enable_state",
|
||||
"restore_active_state",
|
||||
"install-wechat-health-watch.sh",
|
||||
"systemctl disable --now",
|
||||
"wechat-local-ocr-recorder.service",
|
||||
"wechat-local-ocr-responder.service",
|
||||
"sha256sum -c SHA256SUMS",
|
||||
"[JWeBot] %s",
|
||||
'jwebot_log "preflight starting"',
|
||||
'jwebot_log "runtime install ready"',
|
||||
'jwebot_log "edge active"',
|
||||
'jwebot_log "adaptive active"',
|
||||
'jwebot_log "deployment ready version=',
|
||||
'cd "${target_home}"',
|
||||
):
|
||||
self.assertIn(required, source)
|
||||
self.assertEqual(
|
||||
source.count("runuser"),
|
||||
source.count("/usr/sbin/runuser"),
|
||||
)
|
||||
self.assertEqual(
|
||||
source.count("xdotool"),
|
||||
source.count("/usr/bin/xdotool"),
|
||||
)
|
||||
|
||||
def test_production_reports_bounded_progress(self):
|
||||
source = PRODUCTION_INSTALLER.read_text(encoding="utf-8")
|
||||
self.assertIn("jwebot_progress()", source)
|
||||
self.assertIn('jwebot_progress 1 8 "preflight"', source)
|
||||
self.assertIn('jwebot_progress 8 8 "deployment ready"', source)
|
||||
self.assertNotIn("pixel_art", source)
|
||||
|
||||
def test_production_preflight_verifies_rendered_runtime_units(self):
|
||||
source = PRODUCTION_INSTALLER.read_text(encoding="utf-8")
|
||||
render = 'render_root_configuration "${preflight_rendered_dir}"'
|
||||
verify = (
|
||||
'systemd-analyze verify \\\n'
|
||||
' "${preflight_rendered_dir}/wechat-local-ocr-recorder.service"'
|
||||
)
|
||||
self.assertIn("render_root_configuration()", source)
|
||||
self.assertIn("preflight_rendered_dir=$(mktemp -d)", source)
|
||||
self.assertIn(render, source)
|
||||
self.assertIn(verify, source)
|
||||
self.assertLess(source.index(render), source.index(verify))
|
||||
self.assertNotIn(
|
||||
'systemd-analyze verify \\\n'
|
||||
' "${source_dir}/deploy/systemd/system/'
|
||||
'wechat-local-ocr-recorder.service"',
|
||||
source,
|
||||
)
|
||||
|
||||
def test_bootstrap_has_prepare_verify_and_deploy_stages(self):
|
||||
source = BOOTSTRAP.read_text(encoding="utf-8")
|
||||
for required in (
|
||||
"prepare()",
|
||||
"verify()",
|
||||
"deploy()",
|
||||
"requirements-runtime.txt",
|
||||
"jwebot_config",
|
||||
"tools/check-secrets.py",
|
||||
"xdotool search --onlyvisible --class wechat",
|
||||
"deploy-jwebot-production.sh",
|
||||
"progress 6 6",
|
||||
):
|
||||
self.assertIn(required, source)
|
||||
|
||||
def test_legacy_maintenance_never_enables_services_implicitly(self):
|
||||
installer = LEGACY_INSTALLER.read_text(encoding="utf-8")
|
||||
upgrader = LEGACY_UPGRADER.read_text(encoding="utf-8")
|
||||
self.assertIn("ENABLE_LEGACY_OCR:-0", installer)
|
||||
self.assertIn("restore_service_state", installer)
|
||||
self.assertIn("restore_service_state", upgrader)
|
||||
self.assertNotIn(
|
||||
'systemctl restart "$recorder_service" "$responder_service"',
|
||||
upgrader,
|
||||
)
|
||||
|
||||
def test_adaptive_unit_bounds_real_crash_restarts(self):
|
||||
unit = ADAPTIVE_UNIT.read_text(encoding="utf-8")
|
||||
self.assertIn("Restart=on-failure", unit)
|
||||
self.assertIn("StartLimitIntervalSec=300", unit)
|
||||
self.assertIn("StartLimitBurst=6", unit)
|
||||
|
||||
def test_release_gates_are_repeatable_without_ripgrep(self):
|
||||
package_gate = PACKAGE_GATE.read_text(encoding="utf-8")
|
||||
local_gate = LOCAL_GATE.read_text(encoding="utf-8")
|
||||
self.assertIn("PYTHONPYCACHEPREFIX", package_gate)
|
||||
self.assertIn("command -v rg", package_gate)
|
||||
self.assertIn("command -v rg", local_gate)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,46 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
import jwebot_edge # noqa: E402
|
||||
from jwebot_kernel import edge_runtime # noqa: E402
|
||||
|
||||
|
||||
class EdgeCompatibilityTests(unittest.TestCase):
|
||||
def test_import_alias_is_the_packaged_runtime_module(self):
|
||||
self.assertIs(jwebot_edge, edge_runtime)
|
||||
|
||||
def test_legacy_module_mutation_affects_runtime_globals(self):
|
||||
original = edge_runtime.CLOUD_HOST if hasattr(edge_runtime, "CLOUD_HOST") else None
|
||||
jwebot_edge.COMPATIBILITY_TEST_VALUE = "value"
|
||||
try:
|
||||
self.assertEqual(edge_runtime.COMPATIBILITY_TEST_VALUE, "value")
|
||||
finally:
|
||||
del edge_runtime.COMPATIBILITY_TEST_VALUE
|
||||
self.assertEqual(
|
||||
edge_runtime.CLOUD_HOST if hasattr(edge_runtime, "CLOUD_HOST") else None,
|
||||
original,
|
||||
)
|
||||
|
||||
def test_root_cli_still_exposes_help(self):
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(PROJECT_ROOT / "jwebot_edge.py"), "--help"],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
timeout=10,
|
||||
check=False,
|
||||
)
|
||||
self.assertEqual(result.returncode, 0)
|
||||
self.assertIn("Local, low-latency router", result.stdout)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
SDK_SRC = PROJECT_ROOT / "packages" / "jwebot-sdk" / "src"
|
||||
KERNEL_SRC = PROJECT_ROOT / "packages" / "jwebot-kernel" / "src"
|
||||
try:
|
||||
import tomli
|
||||
|
||||
TOML_EXTRA = [str(Path(tomli.__file__).resolve().parent.parent)]
|
||||
except ModuleNotFoundError:
|
||||
TOML_EXTRA = []
|
||||
sys.path[:0] = [
|
||||
str(SDK_SRC),
|
||||
str(PROJECT_ROOT / "packages" / "jwebot-official-extensions" / "src"),
|
||||
str(KERNEL_SRC),
|
||||
]
|
||||
|
||||
from jwebot_kernel import ( # noqa: E402
|
||||
ExtensionHostClient,
|
||||
build_release,
|
||||
install_release,
|
||||
sign_release_record,
|
||||
)
|
||||
from jwebot_sdk import ExtensionResult, ReplyPlan # noqa: E402
|
||||
from tests.test_jwebot_edge import MODULE, config, event # noqa: E402
|
||||
|
||||
|
||||
PUBLISHER = "test-publisher"
|
||||
|
||||
|
||||
def publisher_key(root: Path):
|
||||
private_key = root / "publisher"
|
||||
result = subprocess.run(
|
||||
[
|
||||
"/usr/bin/ssh-keygen",
|
||||
"-q",
|
||||
"-t",
|
||||
"ed25519",
|
||||
"-N",
|
||||
"",
|
||||
"-f",
|
||||
str(private_key),
|
||||
],
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError("cannot generate test publisher key")
|
||||
allowed_signers = root / "allowed_signers"
|
||||
allowed_signers.write_text(
|
||||
f"{PUBLISHER} "
|
||||
+ private_key.with_suffix(".pub").read_text(encoding="utf-8"),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return private_key, allowed_signers
|
||||
|
||||
|
||||
def install_coin(
|
||||
root: Path,
|
||||
private_key: Path,
|
||||
allowed_signers: Path,
|
||||
) -> Path:
|
||||
source = PROJECT_ROOT / "extensions" / "jwebot" / "coin"
|
||||
artifact, record_path, _ = build_release(source, root / "coin-dist")
|
||||
signature_path, _ = sign_release_record(
|
||||
record_path,
|
||||
private_key,
|
||||
PUBLISHER,
|
||||
)
|
||||
state_root = root / "extensions"
|
||||
install_release(
|
||||
artifact,
|
||||
record_path,
|
||||
state_root / "coin",
|
||||
signature=signature_path,
|
||||
allowed_signers=allowed_signers,
|
||||
expected_publisher=PUBLISHER,
|
||||
health_check=lambda release: None,
|
||||
)
|
||||
return state_root
|
||||
|
||||
|
||||
@unittest.skipUnless(
|
||||
Path("/usr/bin/ssh-keygen").is_file(),
|
||||
"/usr/bin/ssh-keygen is unavailable",
|
||||
)
|
||||
class EdgeHostedExtensionTests(unittest.TestCase):
|
||||
def setup_signed_coin(self):
|
||||
temporary = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(temporary.cleanup)
|
||||
root = Path(temporary.name)
|
||||
private_key, allowed_signers = publisher_key(root)
|
||||
state_root = install_coin(root, private_key, allowed_signers)
|
||||
return root, state_root, allowed_signers
|
||||
|
||||
@staticmethod
|
||||
def healthy_coin_factory(calls, *, fail_invoke=False):
|
||||
def factory(plugin_id, release_dir):
|
||||
del release_dir
|
||||
|
||||
def invoke(request, service_handler):
|
||||
calls.append((plugin_id, dict(request)))
|
||||
if request["op"] == "health":
|
||||
return {
|
||||
"status": "ok",
|
||||
"api_version": "1",
|
||||
"plugin_id": "coin",
|
||||
"author": "jwebot",
|
||||
"description": "抛一枚硬币",
|
||||
"help": ["/翻硬币"],
|
||||
"commands": ["/翻硬币"],
|
||||
"service_profiles": ["coin"],
|
||||
}
|
||||
if fail_invoke:
|
||||
raise RuntimeError("host failed")
|
||||
response = service_handler(
|
||||
"coin",
|
||||
"execute",
|
||||
{
|
||||
"command": request["command"],
|
||||
"arguments": request["arguments"],
|
||||
"event": request["event"],
|
||||
},
|
||||
)
|
||||
return {
|
||||
"status": "ok",
|
||||
"api_version": "1",
|
||||
"plugin_id": "coin",
|
||||
"result": ExtensionResult(
|
||||
reply=ReplyPlan.text(response["text"])
|
||||
).to_mapping(),
|
||||
}
|
||||
|
||||
return invoke
|
||||
|
||||
return factory
|
||||
|
||||
def test_preferred_host_executes_signed_coin_through_service_broker(self):
|
||||
root, state_root, allowed_signers = self.setup_signed_coin()
|
||||
calls = []
|
||||
application = MODULE.EdgeApplication(
|
||||
config(
|
||||
root,
|
||||
extension_mode="prefer-host",
|
||||
extension_state_root=state_root,
|
||||
extension_allowed_signers=allowed_signers,
|
||||
extension_publisher=PUBLISHER,
|
||||
),
|
||||
host_invoker_factory=self.healthy_coin_factory(calls),
|
||||
)
|
||||
self.addCleanup(application.close)
|
||||
|
||||
response = application.accept(event("host-coin", "@JWeBot /翻硬币"))
|
||||
|
||||
self.assertEqual(response["route"], "local")
|
||||
self.assertIn("硬币落地", response["text"])
|
||||
self.assertEqual(
|
||||
[request["op"] for _, request in calls],
|
||||
["health", "invoke_command"],
|
||||
)
|
||||
self.assertEqual(application.health()["hosted_extensions"], 1)
|
||||
|
||||
def test_preferred_host_uses_in_process_for_missing_release(self):
|
||||
root, state_root, allowed_signers = self.setup_signed_coin()
|
||||
application = MODULE.EdgeApplication(
|
||||
config(
|
||||
root,
|
||||
extension_mode="prefer-host",
|
||||
extension_state_root=state_root,
|
||||
extension_allowed_signers=allowed_signers,
|
||||
extension_publisher=PUBLISHER,
|
||||
),
|
||||
host_invoker_factory=self.healthy_coin_factory([]),
|
||||
)
|
||||
self.addCleanup(application.close)
|
||||
|
||||
response = application.accept(
|
||||
event("fallback-dice", "@JWeBot /丢骰子 1d6")
|
||||
)
|
||||
|
||||
self.assertEqual(response["route"], "local")
|
||||
self.assertIn("掷出了", response["text"])
|
||||
|
||||
def test_unhealthy_host_is_excluded_before_runtime_fallback(self):
|
||||
root, state_root, allowed_signers = self.setup_signed_coin()
|
||||
|
||||
def factory(plugin_id, release_dir):
|
||||
del plugin_id, release_dir
|
||||
return lambda request, service: {"status": "error"}
|
||||
|
||||
application = MODULE.EdgeApplication(
|
||||
config(
|
||||
root,
|
||||
extension_mode="prefer-host",
|
||||
extension_state_root=state_root,
|
||||
extension_allowed_signers=allowed_signers,
|
||||
extension_publisher=PUBLISHER,
|
||||
),
|
||||
host_invoker_factory=factory,
|
||||
)
|
||||
self.addCleanup(application.close)
|
||||
|
||||
response = application.accept(
|
||||
event("fallback-coin", "@JWeBot /翻硬币")
|
||||
)
|
||||
|
||||
self.assertIn("硬币落地", response["text"])
|
||||
self.assertEqual(application.health()["hosted_extension_failures"], 1)
|
||||
|
||||
def test_active_host_failure_does_not_replay_stateful_command(self):
|
||||
root, state_root, allowed_signers = self.setup_signed_coin()
|
||||
application = MODULE.EdgeApplication(
|
||||
config(
|
||||
root,
|
||||
extension_mode="prefer-host",
|
||||
extension_state_root=state_root,
|
||||
extension_allowed_signers=allowed_signers,
|
||||
extension_publisher=PUBLISHER,
|
||||
),
|
||||
host_invoker_factory=self.healthy_coin_factory(
|
||||
[],
|
||||
fail_invoke=True,
|
||||
),
|
||||
)
|
||||
self.addCleanup(application.close)
|
||||
application.extensions.handle = mock.Mock(side_effect=AssertionError)
|
||||
|
||||
response = application.accept(
|
||||
event("host-failed", "@JWeBot /翻硬币")
|
||||
)
|
||||
|
||||
self.assertIn("隔离插件暂时不可用", response["text"])
|
||||
application.extensions.handle.assert_not_called()
|
||||
self.assertEqual(
|
||||
application.ledger.row("host-failed")["status"],
|
||||
"local_rejected",
|
||||
)
|
||||
|
||||
def test_host_only_requires_every_signed_plugin(self):
|
||||
root, state_root, allowed_signers = self.setup_signed_coin()
|
||||
with self.assertRaisesRegex(MODULE.EdgeError, "startup failed"):
|
||||
MODULE.EdgeApplication(
|
||||
config(
|
||||
root,
|
||||
extension_mode="host-only",
|
||||
extension_state_root=state_root,
|
||||
extension_allowed_signers=allowed_signers,
|
||||
extension_publisher=PUBLISHER,
|
||||
),
|
||||
host_invoker_factory=self.healthy_coin_factory([]),
|
||||
)
|
||||
|
||||
def test_preferred_mode_rejects_tampered_installed_release(self):
|
||||
root, state_root, allowed_signers = self.setup_signed_coin()
|
||||
release_dir = (state_root / "coin" / "current").resolve()
|
||||
(release_dir / "README.md").write_text(
|
||||
"tampered\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
with self.assertRaisesRegex(MODULE.EdgeError, "startup failed"):
|
||||
MODULE.EdgeApplication(
|
||||
config(
|
||||
root,
|
||||
extension_mode="prefer-host",
|
||||
extension_state_root=state_root,
|
||||
extension_allowed_signers=allowed_signers,
|
||||
extension_publisher=PUBLISHER,
|
||||
extension_fallback_enabled=True,
|
||||
),
|
||||
host_invoker_factory=self.healthy_coin_factory([]),
|
||||
)
|
||||
|
||||
def test_edge_to_real_host_process_to_service_broker_round_trip(self):
|
||||
root, state_root, allowed_signers = self.setup_signed_coin()
|
||||
wrapper = root / "host-wrapper.py"
|
||||
wrapper.write_text(
|
||||
"\n".join(
|
||||
(
|
||||
"import runpy, sys",
|
||||
"sys.path[:0] = [",
|
||||
f" {str(SDK_SRC)!r},",
|
||||
f" {str(KERNEL_SRC)!r},",
|
||||
*(
|
||||
f" {path!r},"
|
||||
for path in TOML_EXTRA
|
||||
),
|
||||
" "
|
||||
+ repr(
|
||||
str(
|
||||
PROJECT_ROOT
|
||||
/ "packages"
|
||||
/ "jwebot-extension-host"
|
||||
/ "src"
|
||||
)
|
||||
)
|
||||
+ ",",
|
||||
"]",
|
||||
"runpy.run_module('jwebot_extension_host', run_name='__main__')",
|
||||
)
|
||||
)
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
def factory(plugin_id, release_dir):
|
||||
del plugin_id
|
||||
client = ExtensionHostClient(
|
||||
command=(sys.executable, str(wrapper)),
|
||||
timeout_seconds=5,
|
||||
allowed_signers=allowed_signers,
|
||||
expected_publisher=PUBLISHER,
|
||||
)
|
||||
return lambda request, service: client.invoke(
|
||||
release_dir,
|
||||
request,
|
||||
service_handler=service,
|
||||
)
|
||||
|
||||
application = MODULE.EdgeApplication(
|
||||
config(
|
||||
root,
|
||||
extension_mode="prefer-host",
|
||||
extension_transport="process",
|
||||
extension_state_root=state_root,
|
||||
extension_allowed_signers=allowed_signers,
|
||||
extension_publisher=PUBLISHER,
|
||||
),
|
||||
host_invoker_factory=factory,
|
||||
)
|
||||
self.addCleanup(application.close)
|
||||
|
||||
response = application.accept(
|
||||
event("real-host-coin", "@JWeBot /翻硬币")
|
||||
)
|
||||
|
||||
self.assertEqual(response["route"], "local")
|
||||
self.assertIn("硬币落地", response["text"])
|
||||
self.assertEqual(application.health()["hosted_extensions"], 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,54 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import importlib.util
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
SDK_SRC = PROJECT_ROOT / "packages" / "jwebot-sdk" / "src"
|
||||
EXAMPLE_SRC = PROJECT_ROOT / "examples" / "hello-command" / "src"
|
||||
sys.path[:0] = [str(SDK_SRC), str(EXAMPLE_SRC)]
|
||||
|
||||
from jwebot_sdk import Event, PlanKind # noqa: E402
|
||||
|
||||
|
||||
SPEC = importlib.util.spec_from_file_location(
|
||||
"hello_command",
|
||||
EXAMPLE_SRC / "hello_command.py",
|
||||
)
|
||||
EXAMPLE = importlib.util.module_from_spec(SPEC)
|
||||
assert SPEC.loader is not None
|
||||
SPEC.loader.exec_module(EXAMPLE)
|
||||
|
||||
|
||||
class HelloCommandTests(unittest.TestCase):
|
||||
def event(self):
|
||||
return Event(
|
||||
event_id="event-1",
|
||||
observed_at="2026-10-14T10:00:00+08:00",
|
||||
sender="Alice",
|
||||
text="@JWeBot /hello",
|
||||
group_fingerprint="group-a",
|
||||
mention_bot=True,
|
||||
)
|
||||
|
||||
def test_handler_returns_a_structured_reply(self):
|
||||
result = EXAMPLE.handle_hello(self.event(), "world")
|
||||
self.assertEqual(result.reply.kind, PlanKind.TEXT)
|
||||
self.assertEqual(result.reply.message, "hello Alice world")
|
||||
|
||||
def test_plugin_registers_only_its_declared_command(self):
|
||||
context = mock.Mock()
|
||||
EXAMPLE.plugin(context)
|
||||
context.commands.register.assert_called_once_with(
|
||||
"/hello",
|
||||
EXAMPLE.handle_hello,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
SDK_SRC = PROJECT_ROOT / "packages" / "jwebot-sdk" / "src"
|
||||
KERNEL_SRC = PROJECT_ROOT / "packages" / "jwebot-kernel" / "src"
|
||||
HOST_SRC = PROJECT_ROOT / "packages" / "jwebot-extension-host" / "src"
|
||||
sys.path[:0] = [str(SDK_SRC), str(KERNEL_SRC), str(HOST_SRC)]
|
||||
|
||||
from jwebot_extension_host import ( # noqa: E402
|
||||
ExtensionExecutionError,
|
||||
ExtensionRuntime,
|
||||
HostError,
|
||||
process_request,
|
||||
)
|
||||
from jwebot_kernel import build_release, install_release, parse_manifest # noqa: E402
|
||||
from jwebot_sdk import Event, PlanKind # noqa: E402
|
||||
|
||||
|
||||
def manifest(commands=None, service_profiles=None):
|
||||
return parse_manifest(
|
||||
{
|
||||
"id": "hello-command",
|
||||
"author": "tester",
|
||||
"description": "Say hello",
|
||||
"help": ["/hello [name]"],
|
||||
"version": "1.0.0",
|
||||
"api_version": "1",
|
||||
"entrypoint": "hello_command:plugin",
|
||||
"type": "command",
|
||||
"capabilities": {
|
||||
"commands": commands or ["/hello"],
|
||||
"storage": False,
|
||||
"network_profiles": [],
|
||||
"service_profiles": service_profiles or [],
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def normal_source() -> str:
|
||||
return "\n".join(
|
||||
(
|
||||
"from jwebot_sdk import ExtensionResult, Fact, ReplyPlan",
|
||||
"",
|
||||
"def handle(event, arguments):",
|
||||
" return ExtensionResult(",
|
||||
" reply=ReplyPlan.text(f'hello {event.sender} {arguments}'.strip()),",
|
||||
" facts=(Fact(kind='topic', text='used hello command'),),",
|
||||
" )",
|
||||
"",
|
||||
"def plugin(context):",
|
||||
" context.commands.register('/hello', handle)",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class ExtensionHostTests(unittest.TestCase):
|
||||
def runtime(
|
||||
self,
|
||||
source: str,
|
||||
*,
|
||||
plugin_manifest=None,
|
||||
allow_unsigned=True,
|
||||
service_broker=None,
|
||||
):
|
||||
temporary = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(temporary.cleanup)
|
||||
base = Path(temporary.name)
|
||||
extension = base / "extension"
|
||||
extension.mkdir()
|
||||
(extension / "src").mkdir()
|
||||
(extension / "plugin.toml").write_text(
|
||||
'id = "hello-command"\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
(extension / "src" / "hello_command.py").write_text(
|
||||
source,
|
||||
encoding="utf-8",
|
||||
)
|
||||
plugin_manifest = plugin_manifest or manifest()
|
||||
artifact, record_path, _ = build_release(
|
||||
extension,
|
||||
base / "dist",
|
||||
manifest=plugin_manifest,
|
||||
)
|
||||
release_dir = install_release(
|
||||
artifact,
|
||||
record_path,
|
||||
base / "state",
|
||||
allow_unsigned=True,
|
||||
)
|
||||
return ExtensionRuntime.load(
|
||||
release_dir,
|
||||
manifest=plugin_manifest,
|
||||
allow_unsigned=allow_unsigned,
|
||||
service_broker=service_broker,
|
||||
)
|
||||
|
||||
def event(self):
|
||||
return Event(
|
||||
event_id="event-1",
|
||||
observed_at="2026-10-14T10:00:00+08:00",
|
||||
sender="Alice",
|
||||
text="@JWeBot /hello",
|
||||
group_fingerprint="group-a",
|
||||
mention_bot=True,
|
||||
)
|
||||
|
||||
def test_verified_extension_returns_structured_result(self):
|
||||
runtime = self.runtime(normal_source())
|
||||
result = runtime.invoke_command("/hello", self.event(), "world")
|
||||
self.assertEqual(result.reply.kind, PlanKind.TEXT)
|
||||
self.assertEqual(result.reply.message, "hello Alice world")
|
||||
self.assertEqual(result.facts[0].kind, "topic")
|
||||
self.assertEqual(list(runtime.release_dir.rglob("*.pyc")), [])
|
||||
|
||||
def test_host_rejects_unsigned_release_by_default(self):
|
||||
with self.assertRaisesRegex(HostError, "signed extension release"):
|
||||
self.runtime(normal_source(), allow_unsigned=False)
|
||||
|
||||
def test_request_protocol_serializes_sdk_result(self):
|
||||
runtime = self.runtime(normal_source())
|
||||
response = process_request(
|
||||
runtime,
|
||||
{
|
||||
"op": "invoke_command",
|
||||
"command": "/hello",
|
||||
"arguments": "",
|
||||
"event": self.event().to_mapping(),
|
||||
},
|
||||
)
|
||||
self.assertEqual(response["status"], "ok")
|
||||
self.assertEqual(response["plugin_id"], "hello-command")
|
||||
self.assertEqual(
|
||||
response["result"]["reply"]["text"],
|
||||
"hello Alice",
|
||||
)
|
||||
|
||||
def test_health_reports_frozen_manifest_inventory(self):
|
||||
runtime = self.runtime(
|
||||
normal_source(),
|
||||
plugin_manifest=manifest(service_profiles=["hello-service"]),
|
||||
)
|
||||
response = process_request(runtime, {"op": "health"})
|
||||
self.assertEqual(response["status"], "ok")
|
||||
self.assertEqual(response["plugin_id"], "hello-command")
|
||||
self.assertEqual(response["description"], "Say hello")
|
||||
self.assertEqual(response["help"], ["/hello [name]"])
|
||||
self.assertEqual(response["commands"], ["/hello"])
|
||||
self.assertEqual(response["service_profiles"], ["hello-service"])
|
||||
|
||||
def test_extension_cannot_register_undeclared_command(self):
|
||||
source = normal_source().replace(
|
||||
"context.commands.register('/hello', handle)",
|
||||
"context.commands.register('/other', handle)",
|
||||
)
|
||||
with self.assertRaisesRegex(HostError, "undeclared command"):
|
||||
self.runtime(source)
|
||||
|
||||
def test_extension_must_register_every_declared_command(self):
|
||||
with self.assertRaisesRegex(HostError, "did not register"):
|
||||
self.runtime(normal_source(), plugin_manifest=manifest(["/hello", "/other"]))
|
||||
|
||||
def test_invalid_handler_result_fails_closed(self):
|
||||
source = normal_source().replace(
|
||||
"return ExtensionResult(",
|
||||
"return 'invalid' #",
|
||||
1,
|
||||
)
|
||||
# Replace the remaining multi-line expression with a valid unreachable value.
|
||||
source = source.replace(
|
||||
" reply=ReplyPlan.text(f'hello {event.sender} {arguments}'.strip()),\n"
|
||||
" facts=(Fact(kind='topic', text='used hello command'),),\n"
|
||||
" )",
|
||||
"",
|
||||
)
|
||||
runtime = self.runtime(source)
|
||||
with self.assertRaisesRegex(ExtensionExecutionError, "invalid result"):
|
||||
runtime.invoke_command("/hello", self.event(), "")
|
||||
|
||||
def test_unconfigured_http_capability_fails_closed(self):
|
||||
source = "\n".join(
|
||||
(
|
||||
"from jwebot_sdk import ExtensionResult, ReplyPlan",
|
||||
"",
|
||||
"def plugin(context):",
|
||||
" def handle(event, arguments):",
|
||||
" context.http.request('missing', method='GET', path='/')",
|
||||
" return ExtensionResult(reply=ReplyPlan.no_reply())",
|
||||
" context.commands.register('/hello', handle)",
|
||||
)
|
||||
)
|
||||
runtime = self.runtime(source)
|
||||
with self.assertRaisesRegex(ExtensionExecutionError, "command failed"):
|
||||
runtime.invoke_command("/hello", self.event(), "")
|
||||
|
||||
def test_unconfigured_service_capability_fails_closed(self):
|
||||
source = "\n".join(
|
||||
(
|
||||
"from jwebot_sdk import ExtensionResult, ReplyPlan",
|
||||
"",
|
||||
"def plugin(context):",
|
||||
" def handle(event, arguments):",
|
||||
" context.services.call(",
|
||||
" 'missing', operation='execute', payload={}",
|
||||
" )",
|
||||
" return ExtensionResult(reply=ReplyPlan.no_reply())",
|
||||
" context.commands.register('/hello', handle)",
|
||||
)
|
||||
)
|
||||
runtime = self.runtime(source)
|
||||
with self.assertRaisesRegex(ExtensionExecutionError, "command failed"):
|
||||
runtime.invoke_command("/hello", self.event(), "")
|
||||
|
||||
def test_declared_service_profile_uses_kernel_broker(self):
|
||||
source = "\n".join(
|
||||
(
|
||||
"from jwebot_sdk import ExtensionResult, ReplyPlan",
|
||||
"",
|
||||
"def plugin(context):",
|
||||
" def handle(event, arguments):",
|
||||
" response = context.services.call(",
|
||||
" 'hello-service',",
|
||||
" operation='execute',",
|
||||
" payload={'sender': event.sender, 'value': arguments},",
|
||||
" )",
|
||||
" return ExtensionResult(",
|
||||
" reply=ReplyPlan.text(response['text'])",
|
||||
" )",
|
||||
" context.commands.register('/hello', handle)",
|
||||
)
|
||||
)
|
||||
calls = []
|
||||
|
||||
def broker(profile, operation, payload):
|
||||
calls.append((profile, operation, dict(payload)))
|
||||
return {"text": "brokered reply"}
|
||||
|
||||
runtime = self.runtime(
|
||||
source,
|
||||
plugin_manifest=manifest(service_profiles=["hello-service"]),
|
||||
service_broker=broker,
|
||||
)
|
||||
result = runtime.invoke_command("/hello", self.event(), "value")
|
||||
|
||||
self.assertEqual(result.reply.message, "brokered reply")
|
||||
self.assertEqual(
|
||||
calls,
|
||||
[
|
||||
(
|
||||
"hello-service",
|
||||
"execute",
|
||||
{"sender": "Alice", "value": "value"},
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
def test_logger_does_not_emit_plugin_text_or_field_names(self):
|
||||
runtime = self.runtime(normal_source())
|
||||
with self.assertLogs(level="INFO") as captured:
|
||||
runtime.context.logger.info(
|
||||
"private message body",
|
||||
**{"private-field-name": "private value"},
|
||||
)
|
||||
output = "\n".join(captured.output)
|
||||
self.assertNotIn("private message body", output)
|
||||
self.assertNotIn("private-field-name", output)
|
||||
self.assertNotIn("private value", output)
|
||||
|
||||
def test_outgoing_event_is_rejected_by_request_protocol(self):
|
||||
runtime = self.runtime(normal_source())
|
||||
event = self.event().to_mapping()
|
||||
event["side"] = "outgoing"
|
||||
with self.assertRaisesRegex(HostError, "SDK contract"):
|
||||
process_request(
|
||||
runtime,
|
||||
{
|
||||
"op": "invoke_command",
|
||||
"command": "/hello",
|
||||
"event": event,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,58 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
SERVICE = (
|
||||
PROJECT_ROOT
|
||||
/ "deploy"
|
||||
/ "systemd"
|
||||
/ "system"
|
||||
/ "jwebot-extension-host@.service"
|
||||
)
|
||||
SOCKET = (
|
||||
PROJECT_ROOT
|
||||
/ "deploy"
|
||||
/ "systemd"
|
||||
/ "system"
|
||||
/ "jwebot-extension-host@.socket"
|
||||
)
|
||||
|
||||
|
||||
class ExtensionSandboxUnitTests(unittest.TestCase):
|
||||
def test_service_has_required_isolation_controls(self):
|
||||
text = SERVICE.read_text(encoding="utf-8")
|
||||
for setting in (
|
||||
"User=jwebot-ext",
|
||||
"NoNewPrivileges=true",
|
||||
"CapabilityBoundingSet=",
|
||||
"PrivateNetwork=true",
|
||||
"ProtectSystem=strict",
|
||||
"ProtectHome=true",
|
||||
"MemoryDenyWriteExecute=true",
|
||||
"RestrictAddressFamilies=AF_UNIX",
|
||||
"RuntimeMaxSec=12s",
|
||||
"MemoryMax=128M",
|
||||
"TasksMax=16",
|
||||
"LimitCORE=0",
|
||||
):
|
||||
with self.subTest(setting=setting):
|
||||
self.assertIn(setting, text)
|
||||
|
||||
def test_socket_is_private_to_the_edge_user(self):
|
||||
text = SOCKET.read_text(encoding="utf-8")
|
||||
self.assertIn("SocketUser=jwebot", text)
|
||||
self.assertIn("SocketMode=0600", text)
|
||||
self.assertIn("RemoveOnStop=true", text)
|
||||
|
||||
def test_service_has_no_write_allowlist(self):
|
||||
text = SERVICE.read_text(encoding="utf-8")
|
||||
self.assertNotIn("ReadWritePaths=", text)
|
||||
self.assertIn("ReadOnlyPaths=/var/lib/jwebot-extensions/%i", text)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
SDK_SRC = PROJECT_ROOT / "packages" / "jwebot-sdk" / "src"
|
||||
KERNEL_SRC = PROJECT_ROOT / "packages" / "jwebot-kernel" / "src"
|
||||
HOST_SRC = PROJECT_ROOT / "packages" / "jwebot-extension-host" / "src"
|
||||
OFFICIAL_SRC = (
|
||||
PROJECT_ROOT
|
||||
/ "packages"
|
||||
/ "jwebot-official-extensions"
|
||||
/ "src"
|
||||
)
|
||||
try:
|
||||
import tomli
|
||||
|
||||
TOML_EXTRA = [str(Path(tomli.__file__).resolve().parent.parent)]
|
||||
except ModuleNotFoundError:
|
||||
TOML_EXTRA = []
|
||||
sys.path[:0] = [
|
||||
str(SDK_SRC),
|
||||
str(OFFICIAL_SRC),
|
||||
str(KERNEL_SRC),
|
||||
]
|
||||
|
||||
from jwebot_kernel import ( # noqa: E402
|
||||
ExtensionHostClient,
|
||||
build_release,
|
||||
install_release,
|
||||
sign_release_record,
|
||||
)
|
||||
from jwebot_official_extensions import LOCAL_EXTENSIONS # noqa: E402
|
||||
from tests.test_jwebot_edge import MODULE, config, event # noqa: E402
|
||||
|
||||
|
||||
PUBLISHER = "test-publisher"
|
||||
|
||||
|
||||
@unittest.skipUnless(
|
||||
Path("/usr/bin/ssh-keygen").is_file(),
|
||||
"/usr/bin/ssh-keygen is unavailable",
|
||||
)
|
||||
class HostedCommandParityTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.workspace = tempfile.TemporaryDirectory()
|
||||
cls.root = Path(cls.workspace.name)
|
||||
cls.private_key = cls.root / "publisher"
|
||||
result = subprocess.run(
|
||||
[
|
||||
"/usr/bin/ssh-keygen",
|
||||
"-q",
|
||||
"-t",
|
||||
"ed25519",
|
||||
"-N",
|
||||
"",
|
||||
"-f",
|
||||
str(cls.private_key),
|
||||
],
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError("cannot generate test publisher key")
|
||||
cls.allowed_signers = cls.root / "allowed_signers"
|
||||
cls.allowed_signers.write_text(
|
||||
f"{PUBLISHER} "
|
||||
+ cls.private_key.with_suffix(".pub").read_text(
|
||||
encoding="utf-8"
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
cls.wrapper = cls.root / "host-wrapper.py"
|
||||
cls.wrapper.write_text(
|
||||
"\n".join(
|
||||
(
|
||||
"import runpy, sys",
|
||||
"sys.path[:0] = [",
|
||||
f" {str(SDK_SRC)!r},",
|
||||
f" {str(KERNEL_SRC)!r},",
|
||||
f" {str(HOST_SRC)!r},",
|
||||
*(
|
||||
f" {path!r},"
|
||||
for path in TOML_EXTRA
|
||||
),
|
||||
"]",
|
||||
"runpy.run_module('jwebot_extension_host', run_name='__main__')",
|
||||
)
|
||||
)
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
cls.state_root = cls.root / "extensions"
|
||||
health_client = cls.client()
|
||||
for descriptor in LOCAL_EXTENSIONS:
|
||||
source = (
|
||||
PROJECT_ROOT
|
||||
/ "extensions"
|
||||
/ descriptor.directory
|
||||
/ descriptor.extension_id
|
||||
)
|
||||
artifact, record_path, _ = build_release(
|
||||
source,
|
||||
cls.root / "dist" / descriptor.extension_id,
|
||||
)
|
||||
signature_path, _ = sign_release_record(
|
||||
record_path,
|
||||
cls.private_key,
|
||||
PUBLISHER,
|
||||
)
|
||||
install_release(
|
||||
artifact,
|
||||
record_path,
|
||||
cls.state_root / descriptor.extension_id,
|
||||
signature=signature_path,
|
||||
allowed_signers=cls.allowed_signers,
|
||||
expected_publisher=PUBLISHER,
|
||||
health_check=lambda release, client=health_client: client.invoke(
|
||||
release,
|
||||
{"op": "health"},
|
||||
),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
cls.workspace.cleanup()
|
||||
|
||||
@classmethod
|
||||
def client(cls):
|
||||
return ExtensionHostClient(
|
||||
command=(sys.executable, str(cls.wrapper)),
|
||||
timeout_seconds=5,
|
||||
allowed_signers=cls.allowed_signers,
|
||||
expected_publisher=PUBLISHER,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def factory(cls, plugin_id, release_dir):
|
||||
del plugin_id
|
||||
client = cls.client()
|
||||
return lambda request, service: client.invoke(
|
||||
release_dir,
|
||||
request,
|
||||
service_handler=service,
|
||||
)
|
||||
|
||||
def setUp(self):
|
||||
self.hosted_temp = tempfile.TemporaryDirectory()
|
||||
self.compat_temp = tempfile.TemporaryDirectory()
|
||||
self.hosted = MODULE.EdgeApplication(
|
||||
config(
|
||||
Path(self.hosted_temp.name),
|
||||
extension_mode="host-only",
|
||||
extension_transport="process",
|
||||
extension_state_root=self.state_root,
|
||||
extension_allowed_signers=self.allowed_signers,
|
||||
extension_publisher=PUBLISHER,
|
||||
),
|
||||
host_invoker_factory=self.factory,
|
||||
)
|
||||
self.compat = MODULE.EdgeApplication(
|
||||
config(
|
||||
Path(self.compat_temp.name),
|
||||
local_extensions_enabled=False,
|
||||
)
|
||||
)
|
||||
|
||||
def tearDown(self):
|
||||
self.hosted.close()
|
||||
self.compat.close()
|
||||
self.hosted_temp.cleanup()
|
||||
self.compat_temp.cleanup()
|
||||
|
||||
def assert_command_equal(
|
||||
self,
|
||||
event_id,
|
||||
text,
|
||||
*,
|
||||
sender="Alice",
|
||||
):
|
||||
incoming = event(event_id, text, sender=sender)
|
||||
hosted = self.hosted.accept(dict(incoming))
|
||||
compat = self.compat.accept(dict(incoming))
|
||||
self.assertEqual(hosted["route"], compat["route"])
|
||||
self.assertEqual(hosted["text"], compat["text"])
|
||||
return hosted
|
||||
|
||||
def test_all_plugin_families_match_direct_compatibility_path(self):
|
||||
stateless = (
|
||||
("dice", "@JWeBot /丢骰子5d20"),
|
||||
("coin", "@JWeBot /翻硬币"),
|
||||
("random", "@JWeBot /随机数 -10 100"),
|
||||
("lot", "@JWeBot /抽签"),
|
||||
("fortune", "@JWeBot /今日运势"),
|
||||
("memory-help", "@JWeBot /记忆帮助"),
|
||||
("scan-empty", "@JWeBot /扫码排行"),
|
||||
("repeat-empty", "@JWeBot /复读榜"),
|
||||
("history-empty", "@JWeBot /查记录 测试"),
|
||||
)
|
||||
for event_id, text in stateless:
|
||||
with self.subTest(command=text):
|
||||
self.assert_command_equal(event_id, text)
|
||||
|
||||
memory = (
|
||||
("remember", "@JWeBot /记住 我喜欢家庭服务器"),
|
||||
("my-memory", "@JWeBot /我的记忆"),
|
||||
("person-memory", "@JWeBot /人物记忆 Alice"),
|
||||
("forget-memory", "@JWeBot /忘记 全部"),
|
||||
)
|
||||
for event_id, text in memory:
|
||||
with self.subTest(command=text):
|
||||
self.assert_command_equal(event_id, text)
|
||||
|
||||
challenge = (
|
||||
("challenge-start", "@JWeBot /丢骰子挑战 开始 5d20", "Alice"),
|
||||
("challenge-alice", "@JWeBot /丢骰子挑战", "Alice"),
|
||||
("challenge-gb", "@JWeBot /丢骰子挑战", "Bob"),
|
||||
("challenge-state", "@JWeBot /丢骰子挑战 状态", "Alice"),
|
||||
("challenge-settle", "@JWeBot /丢骰子挑战 结算", "Alice"),
|
||||
)
|
||||
for event_id, text, sender in challenge:
|
||||
with self.subTest(command=text):
|
||||
self.assert_command_equal(event_id, text, sender=sender)
|
||||
|
||||
self.assert_command_equal(
|
||||
"guess-start",
|
||||
"@JWeBot /猜数字 开始 50",
|
||||
)
|
||||
target = self.hosted.ledger.local_game(
|
||||
"group-a",
|
||||
"guess_number",
|
||||
)["target"]
|
||||
self.assertEqual(
|
||||
target,
|
||||
self.compat.ledger.local_game(
|
||||
"group-a",
|
||||
"guess_number",
|
||||
)["target"],
|
||||
)
|
||||
self.assert_command_equal("guess-status", "@JWeBot /猜数字 状态")
|
||||
self.assert_command_equal("guess-win", f"@JWeBot /猜 {target}")
|
||||
|
||||
rps = (
|
||||
("rps-start", "@JWeBot /石头剪刀布 开始", "Alice"),
|
||||
("rps-join", "@JWeBot /石头剪刀布 加入", "Bob"),
|
||||
("rps-first", "@JWeBot /石头剪刀布 出 石头", "Alice"),
|
||||
("rps-second", "@JWeBot /石头剪刀布 出 剪刀", "Bob"),
|
||||
("rps-end", "@JWeBot /石头剪刀布 结束", "Alice"),
|
||||
)
|
||||
for event_id, text, sender in rps:
|
||||
with self.subTest(command=text):
|
||||
self.assert_command_equal(event_id, text, sender=sender)
|
||||
|
||||
for application in (self.hosted, self.compat):
|
||||
application.local.steam = mock.Mock()
|
||||
application.local.steam.render.return_value = (
|
||||
"Steam:yr 正在玩 Dota 2"
|
||||
)
|
||||
self.assert_command_equal("steam", "@JWeBot /steam")
|
||||
hosted_row = self.hosted.ledger.row("steam")
|
||||
compat_row = self.compat.ledger.row("steam")
|
||||
self.assertEqual(
|
||||
self.hosted.generate_steam_reply(hosted_row),
|
||||
self.compat.generate_steam_reply(compat_row),
|
||||
)
|
||||
|
||||
self.assert_command_equal(
|
||||
"restart-guess",
|
||||
"@JWeBot /猜数字 开始 30",
|
||||
)
|
||||
expected_target = self.hosted.ledger.local_game(
|
||||
"group-a",
|
||||
"guess_number",
|
||||
)["target"]
|
||||
self.hosted.close()
|
||||
self.hosted = MODULE.EdgeApplication(
|
||||
config(
|
||||
Path(self.hosted_temp.name),
|
||||
extension_mode="host-only",
|
||||
extension_transport="process",
|
||||
extension_state_root=self.state_root,
|
||||
extension_allowed_signers=self.allowed_signers,
|
||||
extension_publisher=PUBLISHER,
|
||||
),
|
||||
host_invoker_factory=self.factory,
|
||||
)
|
||||
restored = self.hosted.ledger.local_game(
|
||||
"group-a",
|
||||
"guess_number",
|
||||
)
|
||||
self.assertEqual(restored["target"], expected_target)
|
||||
self.assertEqual(restored["upper"], 30)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,62 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import socket
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
MODULE_PATH = PROJECT_ROOT / "jwebot_edge_delivery.py"
|
||||
SPEC = importlib.util.spec_from_file_location("jwebot_edge_delivery", MODULE_PATH)
|
||||
MODULE = importlib.util.module_from_spec(SPEC)
|
||||
assert SPEC.loader is not None
|
||||
sys.modules[SPEC.name] = MODULE
|
||||
SPEC.loader.exec_module(MODULE)
|
||||
|
||||
|
||||
class DeliveryHelperTests(unittest.TestCase):
|
||||
def test_submit_gui_sender_uses_unix_socket_protocol(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
socket_path = Path(temporary) / "gui.sock"
|
||||
received = {}
|
||||
|
||||
def server():
|
||||
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as listener:
|
||||
listener.bind(str(socket_path))
|
||||
listener.listen(1)
|
||||
connection, _ = listener.accept()
|
||||
with connection:
|
||||
raw = connection.recv(4096).split(b"\n", 1)[0]
|
||||
received.update(json.loads(raw.decode("utf-8")))
|
||||
connection.sendall(b'{"ok":true,"state":"sent"}\n')
|
||||
|
||||
thread = threading.Thread(target=server, daemon=True)
|
||||
thread.start()
|
||||
for _ in range(200):
|
||||
if socket_path.exists():
|
||||
break
|
||||
MODULE.time.sleep(0.01)
|
||||
|
||||
with mock.patch.object(MODULE, "GUI_SENDER_SOCKET", socket_path):
|
||||
response = MODULE.submit_gui_sender(
|
||||
{
|
||||
"op": "send",
|
||||
"event_id": "event-1",
|
||||
"group_fingerprint": "group-a",
|
||||
"reply": "reply",
|
||||
"source": "delivery-helper",
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual(response["state"], "sent")
|
||||
self.assertEqual(received["op"], "send")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,229 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
SDK_SRC = PROJECT_ROOT / "packages" / "jwebot-sdk" / "src"
|
||||
KERNEL_SRC = PROJECT_ROOT / "packages" / "jwebot-kernel" / "src"
|
||||
sys.path[:0] = [str(SDK_SRC), str(KERNEL_SRC)]
|
||||
|
||||
from jwebot_kernel import ( # noqa: E402
|
||||
ActivationError,
|
||||
ReleaseError,
|
||||
build_release,
|
||||
install_release,
|
||||
parse_manifest,
|
||||
rollback_release,
|
||||
)
|
||||
|
||||
|
||||
def manifest():
|
||||
return parse_manifest(
|
||||
{
|
||||
"id": "hello-command",
|
||||
"author": "tester",
|
||||
"description": "Say hello",
|
||||
"help": ["/hello"],
|
||||
"version": "1.0.0",
|
||||
"api_version": "1",
|
||||
"entrypoint": "hello_command:plugin",
|
||||
"type": "command",
|
||||
"capabilities": {
|
||||
"commands": ["/hello"],
|
||||
"storage": False,
|
||||
"network_profiles": [],
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def extension(root: Path, reply: str) -> Path:
|
||||
root.mkdir()
|
||||
(root / "src").mkdir()
|
||||
(root / "plugin.toml").write_text(
|
||||
'id = "hello-command"\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
(root / "src" / "hello_command.py").write_text(
|
||||
"\n".join(
|
||||
(
|
||||
"from jwebot_sdk import ExtensionResult, ReplyPlan",
|
||||
"",
|
||||
"def handle(event, arguments):",
|
||||
f" return ExtensionResult(reply=ReplyPlan.text({reply!r}))",
|
||||
)
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return root
|
||||
|
||||
|
||||
class ActivationTests(unittest.TestCase):
|
||||
def build(self, base: Path, name: str, reply: str):
|
||||
return build_release(
|
||||
extension(base / name, reply),
|
||||
base / f"{name}-dist",
|
||||
manifest=manifest(),
|
||||
)
|
||||
|
||||
def test_install_creates_verified_release_and_current_pointer(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
base = Path(directory)
|
||||
artifact, record_path, record = self.build(base, "first", "one")
|
||||
release_dir = install_release(
|
||||
artifact,
|
||||
record_path,
|
||||
base / "state",
|
||||
allow_unsigned=True,
|
||||
)
|
||||
self.assertEqual(release_dir.name, record.source_digest)
|
||||
self.assertEqual(
|
||||
os.readlink(base / "state" / "current"),
|
||||
f"releases/{record.source_digest}",
|
||||
)
|
||||
self.assertTrue((release_dir / ".release.json").is_file())
|
||||
self.assertEqual(
|
||||
(release_dir / "src" / "hello_command.py").stat().st_mode & 0o777,
|
||||
0o600,
|
||||
)
|
||||
|
||||
def test_install_rejects_unsigned_release_by_default(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
base = Path(directory)
|
||||
artifact, record_path, _ = self.build(base, "first", "one")
|
||||
state = base / "state"
|
||||
with self.assertRaisesRegex(
|
||||
ActivationError,
|
||||
"signed release activation is required",
|
||||
):
|
||||
install_release(artifact, record_path, state)
|
||||
self.assertFalse(state.exists())
|
||||
|
||||
def test_unsigned_local_install_rejects_publisher_policy(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
base = Path(directory)
|
||||
artifact, record_path, _ = self.build(base, "first", "one")
|
||||
with self.assertRaisesRegex(
|
||||
ActivationError,
|
||||
"cannot include signature inputs",
|
||||
):
|
||||
install_release(
|
||||
artifact,
|
||||
record_path,
|
||||
base / "state",
|
||||
expected_publisher="test-publisher",
|
||||
allow_unsigned=True,
|
||||
)
|
||||
|
||||
def test_second_install_preserves_previous_and_rollback_swaps(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
base = Path(directory)
|
||||
first_artifact, first_record_path, first_record = self.build(
|
||||
base,
|
||||
"first",
|
||||
"one",
|
||||
)
|
||||
second_artifact, second_record_path, second_record = self.build(
|
||||
base,
|
||||
"second",
|
||||
"two",
|
||||
)
|
||||
state = base / "state"
|
||||
install_release(
|
||||
first_artifact,
|
||||
first_record_path,
|
||||
state,
|
||||
allow_unsigned=True,
|
||||
)
|
||||
install_release(
|
||||
second_artifact,
|
||||
second_record_path,
|
||||
state,
|
||||
allow_unsigned=True,
|
||||
)
|
||||
self.assertEqual(
|
||||
os.readlink(state / "previous"),
|
||||
f"releases/{first_record.source_digest}",
|
||||
)
|
||||
with self.assertRaisesRegex(
|
||||
ActivationError,
|
||||
"signed release rollback is required",
|
||||
):
|
||||
rollback_release(state)
|
||||
rollback_release(state, allow_unsigned=True)
|
||||
self.assertEqual(
|
||||
os.readlink(state / "current"),
|
||||
f"releases/{first_record.source_digest}",
|
||||
)
|
||||
self.assertEqual(
|
||||
os.readlink(state / "previous"),
|
||||
f"releases/{second_record.source_digest}",
|
||||
)
|
||||
|
||||
def test_tampered_artifact_does_not_create_activation_state(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
base = Path(directory)
|
||||
artifact, record_path, _ = self.build(base, "first", "one")
|
||||
artifact.write_bytes(artifact.read_bytes() + b"tampered")
|
||||
state = base / "state"
|
||||
with self.assertRaises(ReleaseError):
|
||||
install_release(
|
||||
artifact,
|
||||
record_path,
|
||||
state,
|
||||
allow_unsigned=True,
|
||||
)
|
||||
self.assertFalse((state / "current").exists())
|
||||
|
||||
def test_existing_release_content_is_reverified(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
base = Path(directory)
|
||||
artifact, record_path, _ = self.build(base, "first", "one")
|
||||
state = base / "state"
|
||||
release_dir = install_release(
|
||||
artifact,
|
||||
record_path,
|
||||
state,
|
||||
allow_unsigned=True,
|
||||
)
|
||||
(release_dir / "src" / "hello_command.py").write_text(
|
||||
"tampered\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
with self.assertRaisesRegex(ActivationError, "source digest mismatch"):
|
||||
install_release(
|
||||
artifact,
|
||||
record_path,
|
||||
state,
|
||||
allow_unsigned=True,
|
||||
)
|
||||
|
||||
def test_rollback_requires_a_previous_verified_release(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
state = Path(directory) / "state"
|
||||
with self.assertRaisesRegex(ActivationError, "no verified previous"):
|
||||
rollback_release(state)
|
||||
|
||||
def test_unsafe_current_pointer_is_rejected(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
base = Path(directory)
|
||||
artifact, record_path, _ = self.build(base, "first", "one")
|
||||
state = base / "state"
|
||||
state.mkdir()
|
||||
(state / "current").symlink_to("/tmp")
|
||||
with self.assertRaisesRegex(ActivationError, "unsafe target"):
|
||||
install_release(
|
||||
artifact,
|
||||
record_path,
|
||||
state,
|
||||
allow_unsigned=True,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,93 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
SDK_SRC = PROJECT_ROOT / "packages" / "jwebot-sdk" / "src"
|
||||
KERNEL_SRC = PROJECT_ROOT / "packages" / "jwebot-kernel" / "src"
|
||||
sys.path[:0] = [str(SDK_SRC), str(KERNEL_SRC)]
|
||||
|
||||
from jwebot_kernel import ( # noqa: E402
|
||||
CatalogError,
|
||||
ExtensionCatalog,
|
||||
parse_manifest,
|
||||
)
|
||||
|
||||
|
||||
def manifest(plugin_id: str, command: str):
|
||||
module_name = plugin_id.replace("-", "_")
|
||||
return parse_manifest(
|
||||
{
|
||||
"id": plugin_id,
|
||||
"author": "tester",
|
||||
"description": f"Handle {command}",
|
||||
"help": [command],
|
||||
"version": "1.0.0",
|
||||
"api_version": "1",
|
||||
"entrypoint": f"{module_name}:plugin",
|
||||
"type": "command",
|
||||
"capabilities": {
|
||||
"commands": [command],
|
||||
"storage": False,
|
||||
"network_profiles": [],
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class ExtensionCatalogTests(unittest.TestCase):
|
||||
def test_registration_is_open_then_frozen(self):
|
||||
catalog = ExtensionCatalog()
|
||||
catalog.register(manifest("hello-command", "/hello"))
|
||||
self.assertFalse(catalog.frozen)
|
||||
catalog.freeze()
|
||||
self.assertTrue(catalog.frozen)
|
||||
with self.assertRaisesRegex(CatalogError, "frozen"):
|
||||
catalog.register(manifest("weather-command", "/weather"))
|
||||
|
||||
def test_duplicate_plugin_id_is_rejected(self):
|
||||
catalog = ExtensionCatalog()
|
||||
catalog.register(manifest("hello-command", "/hello"))
|
||||
with self.assertRaisesRegex(CatalogError, "duplicate plugin id"):
|
||||
catalog.register(manifest("hello-command", "/hello2"))
|
||||
|
||||
def test_command_collision_is_rejected_case_insensitively(self):
|
||||
catalog = ExtensionCatalog()
|
||||
catalog.register(manifest("hello-command", "/Hello"))
|
||||
with self.assertRaisesRegex(CatalogError, "already belongs"):
|
||||
catalog.register(manifest("other-command", "/hello"))
|
||||
|
||||
def test_routing_requires_a_frozen_catalog(self):
|
||||
catalog = ExtensionCatalog()
|
||||
with self.assertRaisesRegex(CatalogError, "must be frozen"):
|
||||
catalog.resolve_command("/hello")
|
||||
|
||||
def test_frozen_catalog_resolves_arguments(self):
|
||||
catalog = ExtensionCatalog()
|
||||
catalog.register(manifest("hello-command", "/hello"))
|
||||
catalog.freeze()
|
||||
match = catalog.resolve_command("/hello world")
|
||||
self.assertIsNotNone(match)
|
||||
assert match is not None
|
||||
self.assertEqual(match.plugin_id, "hello-command")
|
||||
self.assertEqual(match.command, "/hello")
|
||||
self.assertEqual(match.arguments, "world")
|
||||
|
||||
def test_command_name_does_not_match_a_longer_token(self):
|
||||
catalog = ExtensionCatalog()
|
||||
catalog.register(manifest("hello-command", "/hello"))
|
||||
catalog.freeze()
|
||||
self.assertIsNone(catalog.resolve_command("/hello-world"))
|
||||
|
||||
def test_catalog_views_are_read_only(self):
|
||||
catalog = ExtensionCatalog()
|
||||
catalog.register(manifest("hello-command", "/hello"))
|
||||
with self.assertRaises(TypeError):
|
||||
catalog.commands["/other"] = "other-command" # type: ignore[index]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,246 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
SDK_SRC = PROJECT_ROOT / "packages" / "jwebot-sdk" / "src"
|
||||
KERNEL_SRC = PROJECT_ROOT / "packages" / "jwebot-kernel" / "src"
|
||||
sys.path[:0] = [str(SDK_SRC), str(KERNEL_SRC)]
|
||||
|
||||
from jwebot_kernel import ( # noqa: E402
|
||||
ExpectedExtension,
|
||||
HostedExtensionError,
|
||||
HostedExtensionRuntime,
|
||||
SignedExtensionInventory,
|
||||
build_release,
|
||||
install_release,
|
||||
sign_release_record,
|
||||
)
|
||||
from jwebot_sdk import ExtensionResult, ReplyPlan # noqa: E402
|
||||
|
||||
|
||||
PUBLISHER = "test-publisher"
|
||||
|
||||
|
||||
def publisher_key(root: Path):
|
||||
private_key = root / "publisher"
|
||||
result = subprocess.run(
|
||||
[
|
||||
"/usr/bin/ssh-keygen",
|
||||
"-q",
|
||||
"-t",
|
||||
"ed25519",
|
||||
"-N",
|
||||
"",
|
||||
"-f",
|
||||
str(private_key),
|
||||
],
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError("cannot generate test publisher key")
|
||||
allowed_signers = root / "allowed_signers"
|
||||
allowed_signers.write_text(
|
||||
f"{PUBLISHER} "
|
||||
+ private_key.with_suffix(".pub").read_text(encoding="utf-8"),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return private_key, allowed_signers
|
||||
|
||||
|
||||
def install_example(base: Path, private_key: Path, allowed_signers: Path):
|
||||
source = base / "source"
|
||||
shutil.copytree(PROJECT_ROOT / "examples" / "hello-command", source)
|
||||
artifact, record_path, _ = build_release(source, base / "dist")
|
||||
signature_path, _ = sign_release_record(
|
||||
record_path,
|
||||
private_key,
|
||||
PUBLISHER,
|
||||
)
|
||||
state_root = base / "state"
|
||||
release_dir = install_release(
|
||||
artifact,
|
||||
record_path,
|
||||
state_root / "hello-command",
|
||||
signature=signature_path,
|
||||
allowed_signers=allowed_signers,
|
||||
expected_publisher=PUBLISHER,
|
||||
health_check=lambda release: None,
|
||||
)
|
||||
return state_root, release_dir
|
||||
|
||||
|
||||
def expected(author: str = "example"):
|
||||
return ExpectedExtension(
|
||||
plugin_id="hello-command",
|
||||
author=author,
|
||||
commands=("/hello",),
|
||||
service_profiles=(),
|
||||
)
|
||||
|
||||
|
||||
@unittest.skipUnless(
|
||||
Path("/usr/bin/ssh-keygen").is_file(),
|
||||
"/usr/bin/ssh-keygen is unavailable",
|
||||
)
|
||||
class SignedInventoryTests(unittest.TestCase):
|
||||
def test_signed_inventory_loads_and_freezes_current_release(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
base = Path(directory)
|
||||
private_key, allowed_signers = publisher_key(base)
|
||||
state_root, release_dir = install_example(
|
||||
base,
|
||||
private_key,
|
||||
allowed_signers,
|
||||
)
|
||||
|
||||
inventory = SignedExtensionInventory.load(
|
||||
state_root,
|
||||
allowed_signers,
|
||||
[expected()],
|
||||
expected_publisher=PUBLISHER,
|
||||
require_all=True,
|
||||
)
|
||||
|
||||
self.assertTrue(inventory.catalog.frozen)
|
||||
self.assertEqual(
|
||||
inventory.releases["hello-command"].release_dir,
|
||||
release_dir,
|
||||
)
|
||||
match = inventory.catalog.resolve_command("/hello world")
|
||||
self.assertEqual(match.plugin_id, "hello-command")
|
||||
self.assertEqual(match.arguments, "world")
|
||||
|
||||
def test_signed_inventory_rejects_expected_author_mismatch(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
base = Path(directory)
|
||||
private_key, allowed_signers = publisher_key(base)
|
||||
state_root, _ = install_example(
|
||||
base,
|
||||
private_key,
|
||||
allowed_signers,
|
||||
)
|
||||
|
||||
with self.assertRaisesRegex(
|
||||
HostedExtensionError,
|
||||
"inventory mismatch",
|
||||
):
|
||||
SignedExtensionInventory.load(
|
||||
state_root,
|
||||
allowed_signers,
|
||||
[expected(author="wrong")],
|
||||
expected_publisher=PUBLISHER,
|
||||
)
|
||||
|
||||
def test_signed_inventory_can_require_every_release(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
base = Path(directory)
|
||||
_, allowed_signers = publisher_key(base)
|
||||
with self.assertRaisesRegex(
|
||||
HostedExtensionError,
|
||||
"are missing",
|
||||
):
|
||||
SignedExtensionInventory.load(
|
||||
base / "state",
|
||||
allowed_signers,
|
||||
[expected()],
|
||||
require_all=True,
|
||||
)
|
||||
|
||||
def test_hosted_runtime_health_gates_and_invokes_result(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
base = Path(directory)
|
||||
private_key, allowed_signers = publisher_key(base)
|
||||
state_root, _ = install_example(
|
||||
base,
|
||||
private_key,
|
||||
allowed_signers,
|
||||
)
|
||||
inventory = SignedExtensionInventory.load(
|
||||
state_root,
|
||||
allowed_signers,
|
||||
[expected()],
|
||||
expected_publisher=PUBLISHER,
|
||||
require_all=True,
|
||||
)
|
||||
calls = []
|
||||
|
||||
def invoke(request, service_handler):
|
||||
calls.append((dict(request), service_handler))
|
||||
if request["op"] == "health":
|
||||
return {
|
||||
"status": "ok",
|
||||
"api_version": "1",
|
||||
"plugin_id": "hello-command",
|
||||
"author": "example",
|
||||
"description": "Return a local greeting",
|
||||
"help": ["/hello [name]"],
|
||||
"commands": ["/hello"],
|
||||
"service_profiles": [],
|
||||
}
|
||||
return {
|
||||
"status": "ok",
|
||||
"api_version": "1",
|
||||
"plugin_id": "hello-command",
|
||||
"result": ExtensionResult(
|
||||
reply=ReplyPlan.text("hosted")
|
||||
).to_mapping(),
|
||||
}
|
||||
|
||||
runtime = HostedExtensionRuntime(
|
||||
inventory,
|
||||
{"hello-command": invoke},
|
||||
lambda profile, operation, payload: {},
|
||||
require_all_healthy=True,
|
||||
)
|
||||
result = runtime.invoke(
|
||||
"hello-command",
|
||||
"/hello",
|
||||
"world",
|
||||
{
|
||||
"event_id": "event-1",
|
||||
"observed_at": "2026-10-14T10:00:00+08:00",
|
||||
"sender": "Alice",
|
||||
"text": "@JWeBot /hello world",
|
||||
"group_fingerprint": "group-a",
|
||||
"mention_bot": True,
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(result.reply.message, "hosted")
|
||||
self.assertEqual(calls[1][0]["arguments"], "world")
|
||||
|
||||
def test_unhealthy_host_is_not_active_in_preferred_mode(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
base = Path(directory)
|
||||
private_key, allowed_signers = publisher_key(base)
|
||||
state_root, _ = install_example(
|
||||
base,
|
||||
private_key,
|
||||
allowed_signers,
|
||||
)
|
||||
inventory = SignedExtensionInventory.load(
|
||||
state_root,
|
||||
allowed_signers,
|
||||
[expected()],
|
||||
expected_publisher=PUBLISHER,
|
||||
)
|
||||
runtime = HostedExtensionRuntime(
|
||||
inventory,
|
||||
{"hello-command": lambda request, service: {"status": "error"}},
|
||||
lambda profile, operation, payload: {},
|
||||
)
|
||||
|
||||
self.assertFalse(runtime.available("hello-command"))
|
||||
self.assertIn("hello-command", runtime.failures)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,326 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
SDK_SRC = PROJECT_ROOT / "packages" / "jwebot-sdk" / "src"
|
||||
KERNEL_SRC = PROJECT_ROOT / "packages" / "jwebot-kernel" / "src"
|
||||
sys.path[:0] = [str(SDK_SRC), str(KERNEL_SRC)]
|
||||
|
||||
from jwebot_kernel import ( # noqa: E402
|
||||
ExtensionHostClient,
|
||||
HostClientError,
|
||||
HostTimeout,
|
||||
RuntimeTelemetry,
|
||||
UnixExtensionHostClient,
|
||||
)
|
||||
|
||||
|
||||
class HostClientTests(unittest.TestCase):
|
||||
def helper(self, source: str):
|
||||
temporary = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(temporary.cleanup)
|
||||
root = Path(temporary.name)
|
||||
release = root / "release"
|
||||
release.mkdir()
|
||||
script = root / "host.py"
|
||||
script.write_text(source, encoding="utf-8")
|
||||
return root, release, script
|
||||
|
||||
def test_client_uses_minimal_environment_and_parses_response(self):
|
||||
root, release, script = self.helper(
|
||||
"\n".join(
|
||||
(
|
||||
"import json, os, sys",
|
||||
"request = json.loads(sys.stdin.readline())",
|
||||
"print(json.dumps({",
|
||||
" 'status': 'ok',",
|
||||
" 'event_id': request['event_id'],",
|
||||
" 'secret_present': 'JWEBOT_TEST_SECRET' in os.environ,",
|
||||
" 'dont_write_bytecode': sys.dont_write_bytecode,",
|
||||
"}), flush=True)",
|
||||
)
|
||||
)
|
||||
)
|
||||
del root
|
||||
os.environ["JWEBOT_TEST_SECRET"] = "must-not-cross"
|
||||
self.addCleanup(os.environ.pop, "JWEBOT_TEST_SECRET", None)
|
||||
response = ExtensionHostClient(
|
||||
command=(sys.executable, str(script)),
|
||||
).invoke(release, {"event_id": "event-1"})
|
||||
self.assertEqual(response["event_id"], "event-1")
|
||||
self.assertFalse(response["secret_present"])
|
||||
self.assertTrue(response["dont_write_bytecode"])
|
||||
|
||||
def test_client_rejects_invalid_json(self):
|
||||
_, release, script = self.helper("print('not-json')\n")
|
||||
with self.assertRaisesRegex(HostClientError, "invalid JSON"):
|
||||
ExtensionHostClient(
|
||||
command=(sys.executable, str(script)),
|
||||
).invoke(release, {"event_id": "event-1"})
|
||||
|
||||
def test_client_rejects_host_error_status(self):
|
||||
_, release, script = self.helper(
|
||||
"print('{\"status\":\"error\",\"error\":\"ExtensionFailed\"}')\n"
|
||||
)
|
||||
with self.assertRaisesRegex(HostClientError, "returned an error"):
|
||||
ExtensionHostClient(
|
||||
command=(sys.executable, str(script)),
|
||||
).invoke(release, {"event_id": "event-1"})
|
||||
|
||||
def test_client_enforces_request_limit(self):
|
||||
_, release, script = self.helper("print('{\"status\":\"ok\"}')\n")
|
||||
with self.assertRaisesRegex(HostClientError, "size limit"):
|
||||
ExtensionHostClient(
|
||||
command=(sys.executable, str(script)),
|
||||
).invoke(release, {"value": "x" * (256 * 1024)})
|
||||
|
||||
def test_timeout_kills_the_host_process_group(self):
|
||||
root, release, script = self.helper(
|
||||
"\n".join(
|
||||
(
|
||||
"import subprocess, sys, time",
|
||||
"marker = sys.argv[1]",
|
||||
"subprocess.Popen([",
|
||||
" sys.executable, '-c',",
|
||||
" 'import pathlib,time,sys; time.sleep(0.3); '",
|
||||
" 'pathlib.Path(sys.argv[1]).write_text(\"escaped\")',",
|
||||
" marker,",
|
||||
"])",
|
||||
"time.sleep(10)",
|
||||
)
|
||||
)
|
||||
)
|
||||
marker = root / "escaped.txt"
|
||||
client = ExtensionHostClient(
|
||||
command=(sys.executable, str(script), str(marker)),
|
||||
timeout_seconds=0.05,
|
||||
)
|
||||
with self.assertRaises(HostTimeout):
|
||||
client.invoke(release, {"event_id": "event-1"})
|
||||
time.sleep(0.5)
|
||||
self.assertFalse(marker.exists())
|
||||
|
||||
def test_client_services_one_bounded_broker_request(self):
|
||||
_, release, script = self.helper(
|
||||
"\n".join(
|
||||
(
|
||||
"import json, sys",
|
||||
"request = json.loads(sys.stdin.readline())",
|
||||
"print(json.dumps({",
|
||||
" 'status': 'service_request',",
|
||||
" 'call_id': '1',",
|
||||
" 'profile': 'coin',",
|
||||
" 'operation': 'execute',",
|
||||
" 'payload': {'command': '/翻硬币'},",
|
||||
"}), flush=True)",
|
||||
"service = json.loads(sys.stdin.readline())",
|
||||
"print(json.dumps({",
|
||||
" 'status': 'ok',",
|
||||
" 'plugin_id': 'coin',",
|
||||
" 'request_op': request['op'],",
|
||||
" 'text': service['result']['text'],",
|
||||
"}), flush=True)",
|
||||
)
|
||||
)
|
||||
)
|
||||
calls = []
|
||||
observed = []
|
||||
|
||||
def service(profile, operation, payload):
|
||||
calls.append((profile, operation, payload))
|
||||
return {"text": "硬币落地:正面喵"}
|
||||
|
||||
response = ExtensionHostClient(
|
||||
command=(sys.executable, str(script)),
|
||||
plugin_id="coin",
|
||||
telemetry=RuntimeTelemetry(
|
||||
component="host-client",
|
||||
observer=observed.append,
|
||||
logger=mock.Mock(),
|
||||
),
|
||||
).invoke(
|
||||
release,
|
||||
{"op": "invoke_command"},
|
||||
service_handler=service,
|
||||
)
|
||||
|
||||
self.assertEqual(response["text"], "硬币落地:正面喵")
|
||||
self.assertEqual(
|
||||
calls,
|
||||
[
|
||||
(
|
||||
"coin",
|
||||
"execute",
|
||||
{"command": "/翻硬币"},
|
||||
)
|
||||
],
|
||||
)
|
||||
names = [value["name"] for value in observed]
|
||||
self.assertIn("host.process.spawn", names)
|
||||
self.assertIn("host.service.request", names)
|
||||
self.assertIn("host.service.complete", names)
|
||||
self.assertIn("host.invoke", names)
|
||||
|
||||
def test_service_handler_cannot_exceed_host_deadline(self):
|
||||
_, release, script = self.helper(
|
||||
"\n".join(
|
||||
(
|
||||
"import json, sys",
|
||||
"json.loads(sys.stdin.readline())",
|
||||
"print(json.dumps({",
|
||||
" 'status': 'service_request',",
|
||||
" 'call_id': '1',",
|
||||
" 'profile': 'coin',",
|
||||
" 'operation': 'execute',",
|
||||
" 'payload': {},",
|
||||
"}), flush=True)",
|
||||
"sys.stdin.readline()",
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
def slow_service(profile, operation, payload):
|
||||
del profile, operation, payload
|
||||
time.sleep(0.65)
|
||||
return {"text": "late"}
|
||||
|
||||
with self.assertRaisesRegex(HostTimeout, "service exceeded"):
|
||||
ExtensionHostClient(
|
||||
command=(sys.executable, str(script)),
|
||||
timeout_seconds=0.5,
|
||||
).invoke(
|
||||
release,
|
||||
{"op": "invoke_command"},
|
||||
service_handler=slow_service,
|
||||
)
|
||||
|
||||
|
||||
class UnixHostClientTests(unittest.TestCase):
|
||||
def server(self, response: bytes, *, delay: float = 0.0):
|
||||
temporary = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(temporary.cleanup)
|
||||
path = Path(temporary.name) / "host.sock"
|
||||
listener = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
listener.bind(str(path))
|
||||
listener.listen(1)
|
||||
received: list[bytes] = []
|
||||
|
||||
def serve():
|
||||
try:
|
||||
connection, _ = listener.accept()
|
||||
with connection:
|
||||
stream = connection.makefile("rwb", buffering=0)
|
||||
received.append(stream.readline(256 * 1024 + 1))
|
||||
if delay:
|
||||
time.sleep(delay)
|
||||
try:
|
||||
stream.write(response)
|
||||
except OSError:
|
||||
pass
|
||||
stream.close()
|
||||
finally:
|
||||
listener.close()
|
||||
|
||||
thread = threading.Thread(target=serve, daemon=True)
|
||||
thread.start()
|
||||
self.addCleanup(thread.join, 1)
|
||||
return path, received
|
||||
|
||||
def test_unix_client_round_trip(self):
|
||||
path, received = self.server(
|
||||
b'{"status":"ok","plugin_id":"hello-command"}\n'
|
||||
)
|
||||
response = UnixExtensionHostClient(path).invoke(
|
||||
{"op": "invoke_command", "command": "/hello"}
|
||||
)
|
||||
self.assertEqual(response["plugin_id"], "hello-command")
|
||||
self.assertEqual(
|
||||
json.loads(received[0]),
|
||||
{"command": "/hello", "op": "invoke_command"},
|
||||
)
|
||||
|
||||
def test_unix_client_timeout_is_bounded(self):
|
||||
path, _ = self.server(
|
||||
b'{"status":"ok"}\n',
|
||||
delay=0.3,
|
||||
)
|
||||
with self.assertRaises(HostTimeout):
|
||||
UnixExtensionHostClient(
|
||||
path,
|
||||
timeout_seconds=0.05,
|
||||
).invoke({"op": "invoke_command"})
|
||||
|
||||
def test_unix_client_rejects_error_response(self):
|
||||
path, _ = self.server(
|
||||
b'{"status":"error","error":"ExtensionExecutionError"}\n'
|
||||
)
|
||||
with self.assertRaisesRegex(HostClientError, "returned an error"):
|
||||
UnixExtensionHostClient(path).invoke({"op": "invoke_command"})
|
||||
|
||||
def test_unix_client_fails_closed_when_socket_is_missing(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory) / "missing.sock"
|
||||
with self.assertRaisesRegex(HostClientError, "unavailable"):
|
||||
UnixExtensionHostClient(path).invoke(
|
||||
{"op": "invoke_command"}
|
||||
)
|
||||
|
||||
def test_unix_client_services_broker_request(self):
|
||||
temporary = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(temporary.cleanup)
|
||||
path = Path(temporary.name) / "host.sock"
|
||||
listener = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
listener.bind(str(path))
|
||||
listener.listen(1)
|
||||
received = []
|
||||
|
||||
def serve():
|
||||
try:
|
||||
connection, _ = listener.accept()
|
||||
with connection:
|
||||
stream = connection.makefile("rwb", buffering=0)
|
||||
received.append(json.loads(stream.readline()))
|
||||
stream.write(
|
||||
b'{"status":"service_request","call_id":"1",'
|
||||
b'"profile":"coin","operation":"execute",'
|
||||
b'"payload":{"command":"/\\u7ffb\\u786c\\u5e01"}}\n'
|
||||
)
|
||||
received.append(json.loads(stream.readline()))
|
||||
stream.write(
|
||||
b'{"status":"ok","plugin_id":"coin"}\n'
|
||||
)
|
||||
stream.close()
|
||||
finally:
|
||||
listener.close()
|
||||
|
||||
thread = threading.Thread(target=serve, daemon=True)
|
||||
thread.start()
|
||||
self.addCleanup(thread.join, 1)
|
||||
calls = []
|
||||
|
||||
response = UnixExtensionHostClient(path).invoke(
|
||||
{"op": "invoke_command", "command": "/翻硬币"},
|
||||
service_handler=lambda profile, operation, payload: (
|
||||
calls.append((profile, operation, payload))
|
||||
or {"text": "ok"}
|
||||
),
|
||||
)
|
||||
|
||||
self.assertEqual(response["plugin_id"], "coin")
|
||||
self.assertEqual(received[1]["op"], "service_result")
|
||||
self.assertEqual(calls[0][0:2], ("coin", "execute"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,173 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
SDK_SRC = PROJECT_ROOT / "packages" / "jwebot-sdk" / "src"
|
||||
KERNEL_SRC = PROJECT_ROOT / "packages" / "jwebot-kernel" / "src"
|
||||
sys.path[:0] = [str(SDK_SRC), str(KERNEL_SRC)]
|
||||
|
||||
from jwebot_kernel import ( # noqa: E402
|
||||
ManifestError,
|
||||
PluginType,
|
||||
load_manifest,
|
||||
parse_manifest,
|
||||
)
|
||||
from jwebot_kernel import manifest as manifest_module # noqa: E402
|
||||
|
||||
|
||||
class ManifestTests(unittest.TestCase):
|
||||
def manifest(self, **overrides):
|
||||
value = {
|
||||
"id": "hello-command",
|
||||
"author": "jwebot",
|
||||
"description": "Say hello",
|
||||
"help": ["/hello [name]"],
|
||||
"version": "1.0.0",
|
||||
"api_version": "1",
|
||||
"entrypoint": "hello_command:plugin",
|
||||
"type": "command",
|
||||
"capabilities": {
|
||||
"commands": ["/hello"],
|
||||
"storage": False,
|
||||
"network_profiles": [],
|
||||
"service_profiles": [],
|
||||
},
|
||||
}
|
||||
value.update(overrides)
|
||||
return value
|
||||
|
||||
def test_command_manifest_is_parsed(self):
|
||||
manifest = parse_manifest(self.manifest())
|
||||
self.assertEqual(manifest.plugin_id, "hello-command")
|
||||
self.assertEqual(manifest.author, "jwebot")
|
||||
self.assertEqual(manifest.description, "Say hello")
|
||||
self.assertEqual(manifest.help_entries, ("/hello [name]",))
|
||||
self.assertEqual(manifest.plugin_type, PluginType.COMMAND)
|
||||
self.assertEqual(manifest.capabilities.commands, ("/hello",))
|
||||
|
||||
def test_author_is_required_and_bounded(self):
|
||||
value = self.manifest()
|
||||
del value["author"]
|
||||
with self.assertRaisesRegex(ManifestError, "author must"):
|
||||
parse_manifest(value)
|
||||
with self.assertRaisesRegex(ManifestError, "author has an invalid format"):
|
||||
parse_manifest(self.manifest(author="../Alice"))
|
||||
|
||||
def test_unknown_capability_is_rejected(self):
|
||||
value = self.manifest()
|
||||
value["capabilities"]["shell"] = True
|
||||
with self.assertRaisesRegex(ManifestError, "unknown capabilities"):
|
||||
parse_manifest(value)
|
||||
|
||||
def test_description_and_help_are_declared_together(self):
|
||||
value = self.manifest()
|
||||
del value["help"]
|
||||
with self.assertRaisesRegex(
|
||||
ManifestError,
|
||||
"declared together",
|
||||
):
|
||||
parse_manifest(value)
|
||||
|
||||
def test_command_manifest_requires_visible_help_metadata(self):
|
||||
value = self.manifest()
|
||||
del value["description"]
|
||||
del value["help"]
|
||||
with self.assertRaisesRegex(
|
||||
ManifestError,
|
||||
"must declare description",
|
||||
):
|
||||
parse_manifest(value)
|
||||
|
||||
def test_help_entries_are_bounded_command_usage(self):
|
||||
with self.assertRaisesRegex(ManifestError, "invalid display"):
|
||||
parse_manifest(self.manifest(help=["plain prose"]))
|
||||
with self.assertRaisesRegex(ManifestError, "display boundary"):
|
||||
parse_manifest(self.manifest(description="x" * 121))
|
||||
|
||||
def test_command_extension_requires_a_namespace(self):
|
||||
value = self.manifest()
|
||||
value["capabilities"]["commands"] = []
|
||||
with self.assertRaisesRegex(ManifestError, "at least one command"):
|
||||
parse_manifest(value)
|
||||
|
||||
def test_non_command_extension_cannot_claim_a_command(self):
|
||||
with self.assertRaisesRegex(ManifestError, "only command extensions"):
|
||||
parse_manifest(self.manifest(type="enrichment"))
|
||||
|
||||
def test_unsupported_api_version_is_rejected(self):
|
||||
with self.assertRaisesRegex(ManifestError, "unsupported api_version"):
|
||||
parse_manifest(self.manifest(api_version="2"))
|
||||
|
||||
def test_entrypoint_must_use_plugin_namespace(self):
|
||||
with self.assertRaisesRegex(ManifestError, "plugin module namespace"):
|
||||
parse_manifest(self.manifest(entrypoint="json:plugin"))
|
||||
|
||||
def test_service_profiles_are_bounded_and_parsed(self):
|
||||
value = self.manifest()
|
||||
value["capabilities"]["service_profiles"] = ["coin"]
|
||||
self.assertEqual(
|
||||
parse_manifest(value).capabilities.service_profiles,
|
||||
("coin",),
|
||||
)
|
||||
|
||||
def test_duplicate_commands_are_rejected(self):
|
||||
value = self.manifest()
|
||||
value["capabilities"]["commands"] = ["/hello", "/hello"]
|
||||
with self.assertRaisesRegex(ManifestError, "duplicates"):
|
||||
parse_manifest(value)
|
||||
|
||||
def test_case_insensitive_duplicate_commands_are_rejected(self):
|
||||
value = self.manifest()
|
||||
value["capabilities"]["commands"] = ["/Hello", "/hello"]
|
||||
with self.assertRaisesRegex(ManifestError, "case-insensitive duplicates"):
|
||||
parse_manifest(value)
|
||||
|
||||
def test_command_rejects_manifest_breaking_characters(self):
|
||||
value = self.manifest()
|
||||
value["capabilities"]["commands"] = ['/"quoted']
|
||||
with self.assertRaisesRegex(ManifestError, "invalid value"):
|
||||
parse_manifest(value)
|
||||
|
||||
def test_command_accepts_chinese_names(self):
|
||||
value = self.manifest()
|
||||
value["capabilities"]["commands"] = ["/天气"]
|
||||
self.assertEqual(
|
||||
parse_manifest(value).capabilities.commands,
|
||||
("/天气",),
|
||||
)
|
||||
|
||||
def test_load_manifest_reads_toml(self):
|
||||
if manifest_module.tomllib is None:
|
||||
self.skipTest("tomli is not installed under local Python")
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory) / "plugin.toml"
|
||||
path.write_text(
|
||||
"\n".join(
|
||||
(
|
||||
'id = "hello-command"',
|
||||
'author = "Alice"',
|
||||
'description = "Say hello"',
|
||||
'help = ["/hello"]',
|
||||
'version = "1.0.0"',
|
||||
'api_version = "1"',
|
||||
'entrypoint = "hello_command:plugin"',
|
||||
'type = "command"',
|
||||
"",
|
||||
"[capabilities]",
|
||||
'commands = ["/hello"]',
|
||||
"storage = false",
|
||||
"network_profiles = []",
|
||||
)
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
self.assertEqual(load_manifest(path).plugin_id, "hello-command")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,215 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import gzip
|
||||
import hashlib
|
||||
import io
|
||||
import os
|
||||
import sys
|
||||
import tarfile
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
SDK_SRC = PROJECT_ROOT / "packages" / "jwebot-sdk" / "src"
|
||||
KERNEL_SRC = PROJECT_ROOT / "packages" / "jwebot-kernel" / "src"
|
||||
sys.path[:0] = [str(SDK_SRC), str(KERNEL_SRC)]
|
||||
|
||||
from jwebot_kernel import ( # noqa: E402
|
||||
ReleaseError,
|
||||
ReleaseRecord,
|
||||
build_release,
|
||||
parse_manifest,
|
||||
verify_release,
|
||||
)
|
||||
|
||||
|
||||
def manifest():
|
||||
return parse_manifest(
|
||||
{
|
||||
"id": "hello-command",
|
||||
"author": "tester",
|
||||
"description": "Say hello",
|
||||
"help": ["/hello"],
|
||||
"version": "1.0.0",
|
||||
"api_version": "1",
|
||||
"entrypoint": "hello_command:plugin",
|
||||
"type": "command",
|
||||
"capabilities": {
|
||||
"commands": ["/hello"],
|
||||
"storage": False,
|
||||
"network_profiles": [],
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def extension(root: Path) -> Path:
|
||||
root.mkdir()
|
||||
(root / "src").mkdir()
|
||||
(root / "plugin.toml").write_text(
|
||||
'id = "hello-command"\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
(root / "README.md").write_text("# hello\n", encoding="utf-8")
|
||||
(root / "src" / "hello_command.py").write_text(
|
||||
"\n".join(
|
||||
(
|
||||
"from jwebot_sdk import ExtensionResult, ReplyPlan",
|
||||
"",
|
||||
"def handle(event, arguments):",
|
||||
" return ExtensionResult(reply=ReplyPlan.text('hello'))",
|
||||
)
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return root
|
||||
|
||||
|
||||
class ReleaseTests(unittest.TestCase):
|
||||
def test_release_is_deterministic_across_mtime_changes(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
base = Path(directory)
|
||||
source = extension(base / "extension")
|
||||
first, _, first_record = build_release(
|
||||
source,
|
||||
base / "first",
|
||||
manifest=manifest(),
|
||||
)
|
||||
os.utime(source / "src" / "hello_command.py", (123456789, 123456789))
|
||||
second, _, second_record = build_release(
|
||||
source,
|
||||
base / "second",
|
||||
manifest=manifest(),
|
||||
)
|
||||
self.assertEqual(first.read_bytes(), second.read_bytes())
|
||||
self.assertEqual(first_record.source_digest, second_record.source_digest)
|
||||
self.assertEqual(
|
||||
first_record.artifact_sha256,
|
||||
second_record.artifact_sha256,
|
||||
)
|
||||
|
||||
def test_built_release_verifies_without_extraction(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
base = Path(directory)
|
||||
artifact, _, record = build_release(
|
||||
extension(base / "extension"),
|
||||
base / "output",
|
||||
manifest=manifest(),
|
||||
)
|
||||
verify_release(artifact, record)
|
||||
self.assertEqual(record.author, "tester")
|
||||
self.assertEqual(
|
||||
record.files,
|
||||
("README.md", "plugin.toml", "src/hello_command.py"),
|
||||
)
|
||||
|
||||
def test_artifact_tampering_is_rejected(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
base = Path(directory)
|
||||
artifact, _, record = build_release(
|
||||
extension(base / "extension"),
|
||||
base / "output",
|
||||
manifest=manifest(),
|
||||
)
|
||||
content = bytearray(artifact.read_bytes())
|
||||
content[-1] ^= 0x01
|
||||
artifact.write_bytes(content)
|
||||
with self.assertRaisesRegex(ReleaseError, "digest mismatch"):
|
||||
verify_release(artifact, record)
|
||||
|
||||
def test_release_record_rejects_invalid_author(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
base = Path(directory)
|
||||
_, _, record = build_release(
|
||||
extension(base / "extension"),
|
||||
base / "output",
|
||||
manifest=manifest(),
|
||||
)
|
||||
value = record.to_mapping()
|
||||
value["author"] = "../tester"
|
||||
with self.assertRaisesRegex(ReleaseError, "invalid release record"):
|
||||
ReleaseRecord.from_mapping(value)
|
||||
|
||||
def test_unsupported_source_file_is_rejected(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
base = Path(directory)
|
||||
source = extension(base / "extension")
|
||||
(source / "src" / "payload.bin").write_bytes(b"payload")
|
||||
with self.assertRaisesRegex(ReleaseError, "unsupported release file"):
|
||||
build_release(
|
||||
source,
|
||||
base / "output",
|
||||
manifest=manifest(),
|
||||
)
|
||||
|
||||
def test_interpreter_cache_is_excluded_from_release(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
base = Path(directory)
|
||||
source = extension(base / "extension")
|
||||
cache = source / "src" / "__pycache__"
|
||||
cache.mkdir()
|
||||
(cache / "hello_command.cpython-313.pyc").write_bytes(b"cache")
|
||||
|
||||
_, _, record = build_release(
|
||||
source,
|
||||
base / "output",
|
||||
manifest=manifest(),
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
record.files,
|
||||
("README.md", "plugin.toml", "src/hello_command.py"),
|
||||
)
|
||||
|
||||
def test_immutable_artifact_cannot_be_replaced(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
base = Path(directory)
|
||||
source = extension(base / "extension")
|
||||
artifact, _, _ = build_release(
|
||||
source,
|
||||
base / "output",
|
||||
manifest=manifest(),
|
||||
)
|
||||
artifact.write_bytes(b"tampered")
|
||||
with self.assertRaisesRegex(ReleaseError, "immutable release"):
|
||||
build_release(
|
||||
source,
|
||||
base / "output",
|
||||
manifest=manifest(),
|
||||
)
|
||||
|
||||
def test_unsafe_archive_member_is_rejected(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
artifact = Path(directory) / "unsafe.tar.gz"
|
||||
with artifact.open("wb") as raw:
|
||||
with gzip.GzipFile(
|
||||
filename="",
|
||||
mode="wb",
|
||||
fileobj=raw,
|
||||
mtime=0,
|
||||
) as compressed:
|
||||
with tarfile.open(fileobj=compressed, mode="w") as archive:
|
||||
content = b"unsafe"
|
||||
info = tarfile.TarInfo("plugin/../outside.py")
|
||||
info.size = len(content)
|
||||
archive.addfile(info, io.BytesIO(content))
|
||||
artifact_bytes = artifact.read_bytes()
|
||||
record = ReleaseRecord(
|
||||
plugin_id="hello-command",
|
||||
author="tester",
|
||||
plugin_version="1.0.0",
|
||||
api_version="1",
|
||||
source_digest="0" * 64,
|
||||
artifact_name=artifact.name,
|
||||
artifact_sha256=hashlib.sha256(artifact_bytes).hexdigest(),
|
||||
artifact_size=len(artifact_bytes),
|
||||
files=("outside.py",),
|
||||
)
|
||||
with self.assertRaisesRegex(ReleaseError, "unsafe member"):
|
||||
verify_release(artifact, record)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,64 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
SDK_SRC = PROJECT_ROOT / "packages" / "jwebot-sdk" / "src"
|
||||
KERNEL_SRC = PROJECT_ROOT / "packages" / "jwebot-kernel" / "src"
|
||||
sys.path[:0] = [str(SDK_SRC), str(KERNEL_SRC)]
|
||||
|
||||
from jwebot_kernel import ( # noqa: E402
|
||||
ExecutionPath,
|
||||
RouteKind,
|
||||
classify_question,
|
||||
parse_local_command,
|
||||
)
|
||||
|
||||
|
||||
ALIASES = {
|
||||
"功能列表": "feature_list",
|
||||
"丢骰子": "dice",
|
||||
"丢骰子挑战": "dice_challenge",
|
||||
"steam": "steam_friends",
|
||||
}
|
||||
|
||||
|
||||
class KernelRoutingTests(unittest.TestCase):
|
||||
def test_longest_command_prefix_wins(self):
|
||||
self.assertEqual(
|
||||
parse_local_command("/丢骰子挑战5d20", ALIASES),
|
||||
("dice_challenge", "5d20"),
|
||||
)
|
||||
|
||||
def test_unknown_slash_command_stays_local(self):
|
||||
decision = classify_question("/unknown", ALIASES)
|
||||
self.assertEqual(decision.route, RouteKind.LOCAL)
|
||||
self.assertEqual(
|
||||
decision.execution_path,
|
||||
ExecutionPath.LOCAL_COMMAND,
|
||||
)
|
||||
self.assertEqual(decision.reason, "local_unknown")
|
||||
|
||||
def test_hash_request_routes_to_cloud(self):
|
||||
decision = classify_question("#status", ALIASES)
|
||||
self.assertEqual(decision.route, RouteKind.CLOUD)
|
||||
self.assertEqual(decision.execution_path, ExecutionPath.OPENCLAW)
|
||||
self.assertEqual(decision.reason, "hash_command")
|
||||
|
||||
def test_ordinary_chat_routes_direct(self):
|
||||
decision = classify_question("hello", ALIASES)
|
||||
self.assertEqual(decision.route, RouteKind.CHAT)
|
||||
self.assertEqual(decision.execution_path, ExecutionPath.DEEPSEEK)
|
||||
self.assertEqual(decision.reason, "direct_chat")
|
||||
|
||||
def test_command_arguments_are_preserved(self):
|
||||
decision = classify_question("/steam 在线", ALIASES)
|
||||
self.assertEqual(decision.command_kind, "steam_friends")
|
||||
self.assertEqual(decision.arguments, "在线")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,510 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import base64
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
SDK_SRC = PROJECT_ROOT / "packages" / "jwebot-sdk" / "src"
|
||||
KERNEL_SRC = PROJECT_ROOT / "packages" / "jwebot-kernel" / "src"
|
||||
sys.path[:0] = [str(SDK_SRC), str(KERNEL_SRC)]
|
||||
|
||||
from jwebot_kernel import ( # noqa: E402
|
||||
ActivationError,
|
||||
ReleaseError,
|
||||
SignatureError,
|
||||
build_release,
|
||||
install_release,
|
||||
parse_manifest,
|
||||
rollback_release,
|
||||
sign_release_record,
|
||||
verify_installed_release_signature,
|
||||
verify_release_signature,
|
||||
)
|
||||
|
||||
|
||||
SSH_KEYGEN = Path("/usr/bin/ssh-keygen")
|
||||
PUBLISHER = "test-publisher"
|
||||
|
||||
|
||||
def manifest():
|
||||
return parse_manifest(
|
||||
{
|
||||
"id": "hello-command",
|
||||
"author": "tester",
|
||||
"description": "Say hello",
|
||||
"help": ["/hello"],
|
||||
"version": "1.0.0",
|
||||
"api_version": "1",
|
||||
"entrypoint": "hello_command:plugin",
|
||||
"type": "command",
|
||||
"capabilities": {
|
||||
"commands": ["/hello"],
|
||||
"storage": False,
|
||||
"network_profiles": [],
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def extension(root: Path, reply: str = "hello") -> Path:
|
||||
root.mkdir()
|
||||
(root / "src").mkdir()
|
||||
(root / "plugin.toml").write_text(
|
||||
'id = "hello-command"\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
(root / "src" / "hello_command.py").write_text(
|
||||
"\n".join(
|
||||
(
|
||||
"from jwebot_sdk import ExtensionResult, ReplyPlan",
|
||||
"",
|
||||
"def handle(event, arguments):",
|
||||
f" return ExtensionResult(reply=ReplyPlan.text({reply!r}))",
|
||||
)
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return root
|
||||
|
||||
|
||||
def publisher_key(root: Path, publisher: str = PUBLISHER):
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
private_key = root / "publisher"
|
||||
result = subprocess.run(
|
||||
[
|
||||
str(SSH_KEYGEN),
|
||||
"-q",
|
||||
"-t",
|
||||
"ed25519",
|
||||
"-N",
|
||||
"",
|
||||
"-f",
|
||||
str(private_key),
|
||||
],
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError("cannot generate test publisher key")
|
||||
allowed_signers = root / "allowed_signers"
|
||||
public_key = private_key.with_suffix(".pub").read_text(
|
||||
encoding="utf-8"
|
||||
).strip()
|
||||
allowed_signers.write_text(
|
||||
f"{publisher} {public_key}\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
return private_key, allowed_signers
|
||||
|
||||
|
||||
@unittest.skipUnless(
|
||||
SSH_KEYGEN.is_file(),
|
||||
"/usr/bin/ssh-keygen is unavailable",
|
||||
)
|
||||
class ReleaseSigningTests(unittest.TestCase):
|
||||
def build(
|
||||
self,
|
||||
base: Path,
|
||||
name: str = "extension",
|
||||
reply: str = "hello",
|
||||
):
|
||||
return build_release(
|
||||
extension(base / name, reply),
|
||||
base / f"{name}-dist",
|
||||
manifest=manifest(),
|
||||
)
|
||||
|
||||
def test_valid_signature_is_deterministic_and_verifies(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
base = Path(directory)
|
||||
_, record_path, _ = self.build(base)
|
||||
private_key, allowed_signers = publisher_key(base)
|
||||
|
||||
first_path, first = sign_release_record(
|
||||
record_path,
|
||||
private_key,
|
||||
PUBLISHER,
|
||||
)
|
||||
first_bytes = first_path.read_bytes()
|
||||
second_path, second = sign_release_record(
|
||||
record_path,
|
||||
private_key,
|
||||
PUBLISHER,
|
||||
)
|
||||
|
||||
self.assertEqual(first, second)
|
||||
self.assertEqual(first_bytes, second_path.read_bytes())
|
||||
verified = verify_release_signature(
|
||||
record_path,
|
||||
first_path,
|
||||
allowed_signers,
|
||||
expected_publisher=PUBLISHER,
|
||||
)
|
||||
self.assertEqual(verified.publisher, PUBLISHER)
|
||||
|
||||
def test_signature_rejects_release_record_tampering(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
base = Path(directory)
|
||||
_, record_path, _ = self.build(base)
|
||||
private_key, allowed_signers = publisher_key(base)
|
||||
signature_path, _ = sign_release_record(
|
||||
record_path,
|
||||
private_key,
|
||||
PUBLISHER,
|
||||
)
|
||||
value = json.loads(record_path.read_text(encoding="utf-8"))
|
||||
value["artifact_size"] += 1
|
||||
record_path.write_text(
|
||||
json.dumps(
|
||||
value,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
)
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
with self.assertRaisesRegex(
|
||||
SignatureError,
|
||||
"signed release digest mismatch",
|
||||
):
|
||||
verify_release_signature(
|
||||
record_path,
|
||||
signature_path,
|
||||
allowed_signers,
|
||||
)
|
||||
|
||||
def test_signing_rejects_tampered_artifact(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
base = Path(directory)
|
||||
artifact, record_path, _ = self.build(base)
|
||||
private_key, _ = publisher_key(base)
|
||||
artifact.write_bytes(artifact.read_bytes() + b"tampered")
|
||||
|
||||
with self.assertRaisesRegex(ReleaseError, "size mismatch"):
|
||||
sign_release_record(
|
||||
record_path,
|
||||
private_key,
|
||||
PUBLISHER,
|
||||
)
|
||||
|
||||
def test_signature_rejects_cryptographic_tampering(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
base = Path(directory)
|
||||
_, record_path, _ = self.build(base)
|
||||
private_key, allowed_signers = publisher_key(base)
|
||||
signature_path, _ = sign_release_record(
|
||||
record_path,
|
||||
private_key,
|
||||
PUBLISHER,
|
||||
)
|
||||
value = json.loads(signature_path.read_text(encoding="utf-8"))
|
||||
raw = bytearray(base64.b64decode(value["signature_base64"]))
|
||||
raw[len(raw) // 2] ^= 0x01
|
||||
value["signature_base64"] = base64.b64encode(raw).decode("ascii")
|
||||
signature_path.write_text(
|
||||
json.dumps(
|
||||
value,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
)
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
with self.assertRaisesRegex(
|
||||
SignatureError,
|
||||
"signature verification failed",
|
||||
):
|
||||
verify_release_signature(
|
||||
record_path,
|
||||
signature_path,
|
||||
allowed_signers,
|
||||
)
|
||||
|
||||
def test_signature_rejects_untrusted_publisher_key(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
base = Path(directory)
|
||||
_, record_path, _ = self.build(base)
|
||||
private_key, _ = publisher_key(base / "trusted")
|
||||
untrusted_root = base / "untrusted"
|
||||
untrusted_root.mkdir()
|
||||
_, wrong_allowed_signers = publisher_key(untrusted_root)
|
||||
signature_path, _ = sign_release_record(
|
||||
record_path,
|
||||
private_key,
|
||||
PUBLISHER,
|
||||
)
|
||||
|
||||
with self.assertRaisesRegex(
|
||||
SignatureError,
|
||||
"signature verification failed",
|
||||
):
|
||||
verify_release_signature(
|
||||
record_path,
|
||||
signature_path,
|
||||
wrong_allowed_signers,
|
||||
)
|
||||
|
||||
def test_trust_file_rejects_non_ed25519_entries(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
base = Path(directory)
|
||||
_, record_path, _ = self.build(base)
|
||||
private_key, allowed_signers = publisher_key(base)
|
||||
signature_path, _ = sign_release_record(
|
||||
record_path,
|
||||
private_key,
|
||||
PUBLISHER,
|
||||
)
|
||||
allowed_signers.write_text(
|
||||
allowed_signers.read_text(encoding="utf-8").replace(
|
||||
"ssh-ed25519",
|
||||
"ssh-rsa",
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
with self.assertRaisesRegex(SignatureError, "simple Ed25519"):
|
||||
verify_release_signature(
|
||||
record_path,
|
||||
signature_path,
|
||||
allowed_signers,
|
||||
)
|
||||
|
||||
def test_signature_binds_publisher_even_when_key_has_two_principals(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
base = Path(directory)
|
||||
_, record_path, _ = self.build(base)
|
||||
private_key, allowed_signers = publisher_key(base)
|
||||
public_key = private_key.with_suffix(".pub").read_text(
|
||||
encoding="utf-8"
|
||||
).strip()
|
||||
with allowed_signers.open("a", encoding="utf-8") as stream:
|
||||
stream.write(f"other-publisher {public_key}\n")
|
||||
signature_path, _ = sign_release_record(
|
||||
record_path,
|
||||
private_key,
|
||||
PUBLISHER,
|
||||
)
|
||||
value = json.loads(signature_path.read_text(encoding="utf-8"))
|
||||
value["publisher"] = "other-publisher"
|
||||
signature_path.write_text(
|
||||
json.dumps(
|
||||
value,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
)
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
with self.assertRaisesRegex(
|
||||
SignatureError,
|
||||
"signature verification failed",
|
||||
):
|
||||
verify_release_signature(
|
||||
record_path,
|
||||
signature_path,
|
||||
allowed_signers,
|
||||
)
|
||||
|
||||
def test_signing_rejects_noncanonical_release_record(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
base = Path(directory)
|
||||
_, record_path, _ = self.build(base)
|
||||
private_key, _ = publisher_key(base)
|
||||
record_path.write_bytes(b" " + record_path.read_bytes())
|
||||
|
||||
with self.assertRaisesRegex(SignatureError, "not canonical"):
|
||||
sign_release_record(
|
||||
record_path,
|
||||
private_key,
|
||||
PUBLISHER,
|
||||
)
|
||||
|
||||
def test_signed_activation_persists_and_reverifies_signature(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
base = Path(directory)
|
||||
artifact, record_path, _ = self.build(base)
|
||||
private_key, allowed_signers = publisher_key(base)
|
||||
signature_path, _ = sign_release_record(
|
||||
record_path,
|
||||
private_key,
|
||||
PUBLISHER,
|
||||
)
|
||||
|
||||
release_dir = install_release(
|
||||
artifact,
|
||||
record_path,
|
||||
base / "state",
|
||||
signature=signature_path,
|
||||
allowed_signers=allowed_signers,
|
||||
expected_publisher=PUBLISHER,
|
||||
health_check=lambda release: None,
|
||||
)
|
||||
|
||||
self.assertTrue((release_dir / ".release.sig.json").is_file())
|
||||
record, signature = verify_installed_release_signature(
|
||||
release_dir,
|
||||
allowed_signers,
|
||||
expected_publisher=PUBLISHER,
|
||||
)
|
||||
self.assertEqual(record.plugin_id, "hello-command")
|
||||
self.assertEqual(signature.publisher, PUBLISHER)
|
||||
|
||||
def test_signed_activation_requires_passing_health_check(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
base = Path(directory)
|
||||
artifact, record_path, _ = self.build(base)
|
||||
private_key, allowed_signers = publisher_key(base)
|
||||
signature_path, _ = sign_release_record(
|
||||
record_path,
|
||||
private_key,
|
||||
PUBLISHER,
|
||||
)
|
||||
state = base / "state"
|
||||
|
||||
with self.assertRaisesRegex(
|
||||
ActivationError,
|
||||
"health check is required",
|
||||
):
|
||||
install_release(
|
||||
artifact,
|
||||
record_path,
|
||||
state,
|
||||
signature=signature_path,
|
||||
allowed_signers=allowed_signers,
|
||||
expected_publisher=PUBLISHER,
|
||||
)
|
||||
self.assertFalse(state.exists())
|
||||
|
||||
def test_failed_health_check_does_not_switch_current(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
base = Path(directory)
|
||||
artifact, record_path, record = self.build(base)
|
||||
private_key, allowed_signers = publisher_key(base)
|
||||
signature_path, _ = sign_release_record(
|
||||
record_path,
|
||||
private_key,
|
||||
PUBLISHER,
|
||||
)
|
||||
state = base / "state"
|
||||
|
||||
with self.assertRaisesRegex(
|
||||
ActivationError,
|
||||
"health check failed",
|
||||
):
|
||||
install_release(
|
||||
artifact,
|
||||
record_path,
|
||||
state,
|
||||
signature=signature_path,
|
||||
allowed_signers=allowed_signers,
|
||||
expected_publisher=PUBLISHER,
|
||||
health_check=lambda release: (_ for _ in ()).throw(
|
||||
RuntimeError("unhealthy")
|
||||
),
|
||||
)
|
||||
self.assertFalse((state / "current").exists())
|
||||
self.assertFalse(
|
||||
(state / "releases" / record.source_digest).exists()
|
||||
)
|
||||
|
||||
def test_signed_activation_rejects_wrong_publisher_policy(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
base = Path(directory)
|
||||
artifact, record_path, _ = self.build(base)
|
||||
private_key, allowed_signers = publisher_key(base)
|
||||
signature_path, _ = sign_release_record(
|
||||
record_path,
|
||||
private_key,
|
||||
PUBLISHER,
|
||||
)
|
||||
state = base / "state"
|
||||
|
||||
with self.assertRaisesRegex(
|
||||
SignatureError,
|
||||
"publisher does not match policy",
|
||||
):
|
||||
install_release(
|
||||
artifact,
|
||||
record_path,
|
||||
state,
|
||||
signature=signature_path,
|
||||
allowed_signers=allowed_signers,
|
||||
expected_publisher="other-publisher",
|
||||
)
|
||||
self.assertFalse(state.exists())
|
||||
|
||||
def test_signed_rollback_reverifies_previous_release(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
base = Path(directory)
|
||||
private_key, allowed_signers = publisher_key(base)
|
||||
first_artifact, first_record_path, first_record = self.build(
|
||||
base,
|
||||
"first",
|
||||
"one",
|
||||
)
|
||||
first_signature, _ = sign_release_record(
|
||||
first_record_path,
|
||||
private_key,
|
||||
PUBLISHER,
|
||||
)
|
||||
second_artifact, second_record_path, second_record = self.build(
|
||||
base,
|
||||
"second",
|
||||
"two",
|
||||
)
|
||||
second_signature, _ = sign_release_record(
|
||||
second_record_path,
|
||||
private_key,
|
||||
PUBLISHER,
|
||||
)
|
||||
state = base / "state"
|
||||
install_release(
|
||||
first_artifact,
|
||||
first_record_path,
|
||||
state,
|
||||
signature=first_signature,
|
||||
allowed_signers=allowed_signers,
|
||||
expected_publisher=PUBLISHER,
|
||||
health_check=lambda release: None,
|
||||
)
|
||||
install_release(
|
||||
second_artifact,
|
||||
second_record_path,
|
||||
state,
|
||||
signature=second_signature,
|
||||
allowed_signers=allowed_signers,
|
||||
expected_publisher=PUBLISHER,
|
||||
health_check=lambda release: None,
|
||||
)
|
||||
|
||||
destination = rollback_release(
|
||||
state,
|
||||
allowed_signers=allowed_signers,
|
||||
expected_publisher=PUBLISHER,
|
||||
)
|
||||
|
||||
self.assertEqual(destination.name, first_record.source_digest)
|
||||
self.assertEqual(
|
||||
(state / "current").resolve().name,
|
||||
first_record.source_digest,
|
||||
)
|
||||
self.assertEqual(
|
||||
(state / "previous").resolve().name,
|
||||
second_record.source_digest,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,106 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
SDK_SRC = PROJECT_ROOT / "packages" / "jwebot-sdk" / "src"
|
||||
KERNEL_SRC = PROJECT_ROOT / "packages" / "jwebot-kernel" / "src"
|
||||
sys.path[:0] = [str(SDK_SRC), str(KERNEL_SRC)]
|
||||
|
||||
from jwebot_kernel import ( # noqa: E402
|
||||
SourcePolicyError,
|
||||
parse_manifest,
|
||||
validate_source_tree,
|
||||
)
|
||||
|
||||
|
||||
def manifest():
|
||||
return parse_manifest(
|
||||
{
|
||||
"id": "test-command",
|
||||
"author": "tester",
|
||||
"description": "Run a test command",
|
||||
"help": ["/test"],
|
||||
"version": "1.0.0",
|
||||
"api_version": "1",
|
||||
"entrypoint": "test_command:plugin",
|
||||
"type": "command",
|
||||
"capabilities": {
|
||||
"commands": ["/test"],
|
||||
"storage": False,
|
||||
"network_profiles": [],
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class SourcePolicyTests(unittest.TestCase):
|
||||
def validate(self, source: str):
|
||||
temporary = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(temporary.cleanup)
|
||||
root = Path(temporary.name)
|
||||
source_root = root / "src"
|
||||
source_root.mkdir()
|
||||
(source_root / "test_command.py").write_text(source, encoding="utf-8")
|
||||
return validate_source_tree(root, manifest())
|
||||
|
||||
def test_sdk_and_pure_stdlib_imports_are_allowed(self):
|
||||
paths = self.validate(
|
||||
"\n".join(
|
||||
(
|
||||
"from dataclasses import dataclass",
|
||||
"from jwebot_sdk import ReplyPlan",
|
||||
"",
|
||||
"@dataclass",
|
||||
"class Value:",
|
||||
" number: int",
|
||||
"",
|
||||
"def reply():",
|
||||
" return ReplyPlan.text(str(Value(1).number))",
|
||||
)
|
||||
)
|
||||
)
|
||||
self.assertEqual(len(paths), 1)
|
||||
|
||||
def test_subprocess_import_is_rejected(self):
|
||||
with self.assertRaisesRegex(SourcePolicyError, "JWP001"):
|
||||
self.validate("import subprocess\n")
|
||||
|
||||
def test_socket_import_is_rejected(self):
|
||||
with self.assertRaisesRegex(SourcePolicyError, "JWP001"):
|
||||
self.validate("import socket\n")
|
||||
|
||||
def test_open_is_rejected(self):
|
||||
with self.assertRaisesRegex(SourcePolicyError, "JWP002"):
|
||||
self.validate('open("/tmp/value")\n')
|
||||
|
||||
def test_dynamic_import_is_rejected(self):
|
||||
with self.assertRaisesRegex(SourcePolicyError, "JWP002"):
|
||||
self.validate('__import__("os")\n')
|
||||
|
||||
def test_dunder_attribute_access_is_rejected(self):
|
||||
with self.assertRaisesRegex(SourcePolicyError, "JWP003"):
|
||||
self.validate("value.__class__\n")
|
||||
|
||||
def test_relative_local_import_is_allowed(self):
|
||||
paths = self.validate("from .helper import value\n")
|
||||
self.assertEqual(len(paths), 1)
|
||||
|
||||
def test_symlinked_source_is_rejected(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
source_root = root / "src"
|
||||
source_root.mkdir()
|
||||
target = root / "outside.py"
|
||||
target.write_text("value = 1\n", encoding="utf-8")
|
||||
(source_root / "test_command.py").symlink_to(target)
|
||||
with self.assertRaisesRegex(SourcePolicyError, "JWP006"):
|
||||
validate_source_tree(root, manifest())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,102 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
SDK_SRC = PROJECT_ROOT / "packages" / "jwebot-sdk" / "src"
|
||||
KERNEL_SRC = PROJECT_ROOT / "packages" / "jwebot-kernel" / "src"
|
||||
sys.path[:0] = [str(SDK_SRC), str(KERNEL_SRC)]
|
||||
|
||||
from jwebot_kernel.telemetry import RuntimeTelemetry # noqa: E402
|
||||
|
||||
|
||||
class TelemetryTests(unittest.TestCase):
|
||||
def test_event_hashes_identifiers_and_drops_unknown_fields(self):
|
||||
observed = []
|
||||
logger = mock.Mock()
|
||||
telemetry = RuntimeTelemetry(
|
||||
component="edge",
|
||||
observer=observed.append,
|
||||
logger=logger,
|
||||
)
|
||||
|
||||
value = telemetry.emit(
|
||||
"edge.route.classified",
|
||||
event_id="private-event-id",
|
||||
group_id="private-group",
|
||||
route="local",
|
||||
reason="local_coin",
|
||||
component="attacker",
|
||||
message_body="must-not-appear",
|
||||
)
|
||||
|
||||
self.assertNotIn("private-event-id", str(dict(value)))
|
||||
self.assertNotIn("private-group", str(dict(value)))
|
||||
self.assertNotIn("message_body", value)
|
||||
self.assertEqual(value["route"], "local")
|
||||
self.assertEqual(value["component"], "edge")
|
||||
self.assertEqual(len(observed), 1)
|
||||
logger.info.assert_called_once()
|
||||
|
||||
def test_elapsed_updates_low_cardinality_snapshot(self):
|
||||
values = iter((10.0, 10.025))
|
||||
telemetry = RuntimeTelemetry(
|
||||
component="host-client",
|
||||
logger=mock.Mock(),
|
||||
monotonic=lambda: next(values),
|
||||
)
|
||||
started = telemetry.monotonic()
|
||||
telemetry.elapsed(
|
||||
"host.invoke",
|
||||
started,
|
||||
status="ok",
|
||||
plugin_id="coin",
|
||||
)
|
||||
|
||||
snapshot = telemetry.snapshot()
|
||||
self.assertEqual(snapshot["counts"]["host.invoke:ok"], 1)
|
||||
self.assertEqual(
|
||||
snapshot["latencies"]["host.invoke"]["average_ms"],
|
||||
25.0,
|
||||
)
|
||||
|
||||
def test_span_records_error_type_without_swallowing(self):
|
||||
observed = []
|
||||
values = iter((1.0, 1.01))
|
||||
telemetry = RuntimeTelemetry(
|
||||
component="edge",
|
||||
observer=observed.append,
|
||||
logger=mock.Mock(),
|
||||
monotonic=lambda: next(values),
|
||||
)
|
||||
|
||||
with self.assertRaisesRegex(RuntimeError, "failed"):
|
||||
with telemetry.span("edge.local.execute", route="local"):
|
||||
raise RuntimeError("failed")
|
||||
|
||||
self.assertEqual(observed[0]["status"], "error")
|
||||
self.assertEqual(observed[0]["error_type"], "RuntimeError")
|
||||
self.assertNotIn("failed", str(dict(observed[0])))
|
||||
|
||||
def test_disabled_telemetry_has_no_side_effects(self):
|
||||
observer = mock.Mock()
|
||||
logger = mock.Mock()
|
||||
telemetry = RuntimeTelemetry(
|
||||
component="edge",
|
||||
enabled=False,
|
||||
observer=observer,
|
||||
logger=logger,
|
||||
)
|
||||
|
||||
self.assertEqual(dict(telemetry.emit("edge.test")), {})
|
||||
observer.assert_not_called()
|
||||
logger.info.assert_not_called()
|
||||
self.assertEqual(telemetry.snapshot()["counts"], {})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,193 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
from tests.test_jwebot_edge import MODULE, config, event
|
||||
|
||||
|
||||
class LocalCommandParityTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.enabled_temp = tempfile.TemporaryDirectory()
|
||||
self.compat_temp = tempfile.TemporaryDirectory()
|
||||
self.enabled = MODULE.EdgeApplication(
|
||||
config(
|
||||
Path(self.enabled_temp.name),
|
||||
local_extensions_enabled=True,
|
||||
)
|
||||
)
|
||||
self.compat = MODULE.EdgeApplication(
|
||||
config(
|
||||
Path(self.compat_temp.name),
|
||||
local_extensions_enabled=False,
|
||||
)
|
||||
)
|
||||
|
||||
def tearDown(self):
|
||||
self.enabled.close()
|
||||
self.compat.close()
|
||||
self.enabled_temp.cleanup()
|
||||
self.compat_temp.cleanup()
|
||||
|
||||
def assert_command_equal(
|
||||
self,
|
||||
event_id: str,
|
||||
text: str,
|
||||
*,
|
||||
sender: str = "Alice",
|
||||
):
|
||||
incoming = event(
|
||||
event_id,
|
||||
text,
|
||||
sender=sender,
|
||||
)
|
||||
enabled = self.enabled.accept(dict(incoming))
|
||||
compat = self.compat.accept(dict(incoming))
|
||||
self.assertEqual(enabled["route"], compat["route"])
|
||||
self.assertEqual(enabled["text"], compat["text"])
|
||||
return enabled, compat
|
||||
|
||||
def test_stateless_command_families_match(self):
|
||||
commands = (
|
||||
("help", "@JWeBot /功能列表"),
|
||||
("dice", "@JWeBot /丢骰子5d20"),
|
||||
("coin", "@JWeBot /翻硬币"),
|
||||
("random", "@JWeBot /随机数 -10 100"),
|
||||
("lot", "@JWeBot /抽签"),
|
||||
("fortune", "@JWeBot /今日运势"),
|
||||
("memory-help", "@JWeBot /记忆帮助"),
|
||||
("scan-empty", "@JWeBot /扫码排行"),
|
||||
("repeat-empty", "@JWeBot /复读榜"),
|
||||
("history-empty", "@JWeBot /查记录 测试"),
|
||||
)
|
||||
for event_id, text in commands:
|
||||
with self.subTest(command=text):
|
||||
self.assert_command_equal(event_id, text)
|
||||
|
||||
def test_memory_subcommands_match(self):
|
||||
commands = (
|
||||
("remember", "@JWeBot /记住 我喜欢家庭服务器"),
|
||||
("my-memory", "@JWeBot /我的记忆"),
|
||||
("person-memory", "@JWeBot /人物记忆 Alice"),
|
||||
("forget-keyword", "@JWeBot /忘记 家庭服务器"),
|
||||
("remember-again", "@JWeBot /记住 我喜欢技术细节"),
|
||||
("forget-all", "@JWeBot /忘记 全部"),
|
||||
)
|
||||
for event_id, text in commands:
|
||||
with self.subTest(command=text):
|
||||
self.assert_command_equal(event_id, text)
|
||||
|
||||
def test_dice_challenge_subcommands_match(self):
|
||||
commands = (
|
||||
("challenge-start", "@JWeBot /丢骰子挑战 开始 5d20", "Alice"),
|
||||
("challenge-alice", "@JWeBot /丢骰子挑战", "Alice"),
|
||||
("challenge-gb", "@JWeBot /丢骰子挑战", "Bob"),
|
||||
("challenge-state", "@JWeBot /丢骰子挑战 状态", "Alice"),
|
||||
("challenge-settle", "@JWeBot /丢骰子挑战 结算", "Alice"),
|
||||
("challenge-record", "@JWeBot /丢骰子挑战 战绩", "Alice"),
|
||||
)
|
||||
for event_id, text, sender in commands:
|
||||
with self.subTest(command=text, sender=sender):
|
||||
self.assert_command_equal(event_id, text, sender=sender)
|
||||
|
||||
def test_guess_number_subcommands_match(self):
|
||||
self.assert_command_equal(
|
||||
"guess-start",
|
||||
"@JWeBot /猜数字 开始 50",
|
||||
)
|
||||
enabled_state = self.enabled.ledger.local_game(
|
||||
"group-a",
|
||||
"guess_number",
|
||||
)
|
||||
compat_state = self.compat.ledger.local_game(
|
||||
"group-a",
|
||||
"guess_number",
|
||||
)
|
||||
self.assertEqual(enabled_state["target"], compat_state["target"])
|
||||
target = enabled_state["target"]
|
||||
self.assert_command_equal(
|
||||
"guess-status",
|
||||
"@JWeBot /猜数字 状态",
|
||||
)
|
||||
self.assert_command_equal(
|
||||
"guess-win",
|
||||
f"@JWeBot /猜 {target}",
|
||||
)
|
||||
|
||||
def test_rps_subcommands_match(self):
|
||||
commands = (
|
||||
("rps-start", "@JWeBot /石头剪刀布 开始", "Alice"),
|
||||
("rps-join", "@JWeBot /石头剪刀布 加入", "Bob"),
|
||||
("rps-first", "@JWeBot /石头剪刀布 出 石头", "Alice"),
|
||||
("rps-status", "@JWeBot /石头剪刀布 状态", "Alice"),
|
||||
("rps-second", "@JWeBot /石头剪刀布 出 剪刀", "Bob"),
|
||||
("rps-end", "@JWeBot /石头剪刀布 结束", "Alice"),
|
||||
)
|
||||
for event_id, text, sender in commands:
|
||||
with self.subTest(command=text, sender=sender):
|
||||
self.assert_command_equal(event_id, text, sender=sender)
|
||||
|
||||
def test_status_balance_and_steam_match(self):
|
||||
balance = [
|
||||
{
|
||||
"currency": "CNY",
|
||||
"total_balance": "10.00",
|
||||
"granted_balance": "2.00",
|
||||
"topped_up_balance": "8.00",
|
||||
}
|
||||
]
|
||||
for application in (self.enabled, self.compat):
|
||||
application.local.steam = mock.Mock()
|
||||
application.local.steam.render.return_value = (
|
||||
"Steam:yr 正在玩 Dota 2"
|
||||
)
|
||||
with mock.patch.object(
|
||||
MODULE.LocalCommandEngine,
|
||||
"service_state",
|
||||
return_value="active",
|
||||
), mock.patch.object(
|
||||
MODULE.DeepSeekClient,
|
||||
"balance",
|
||||
return_value=balance,
|
||||
):
|
||||
self.assert_command_equal("status", "@JWeBot /状态")
|
||||
self.assert_command_equal("server-status", "@JWeBot /status")
|
||||
self.assert_command_equal("jwebot-version", "@JWeBot /version")
|
||||
self.assert_command_equal("debug-telemetry", "@JWeBot /debug")
|
||||
self.assert_command_equal("balance", "@JWeBot /余额")
|
||||
self.assert_command_equal("steam", "@JWeBot /steam")
|
||||
enabled_row = self.enabled.ledger.row("steam")
|
||||
compat_row = self.compat.ledger.row("steam")
|
||||
self.assertEqual(
|
||||
self.enabled.generate_steam_reply(enabled_row),
|
||||
self.compat.generate_steam_reply(compat_row),
|
||||
)
|
||||
|
||||
def test_extension_game_state_survives_application_restart(self):
|
||||
response = self.enabled.accept(
|
||||
event("restart-game", "@JWeBot /猜数字 开始 30")
|
||||
)
|
||||
self.assertIn("猜数字开局", response["text"])
|
||||
expected = self.enabled.ledger.local_game(
|
||||
"group-a",
|
||||
"guess_number",
|
||||
)
|
||||
self.enabled.close()
|
||||
self.enabled = MODULE.EdgeApplication(
|
||||
config(
|
||||
Path(self.enabled_temp.name),
|
||||
local_extensions_enabled=True,
|
||||
)
|
||||
)
|
||||
restored = self.enabled.ledger.local_game(
|
||||
"group-a",
|
||||
"guess_number",
|
||||
)
|
||||
self.assertEqual(restored["target"], expected["target"])
|
||||
self.assertEqual(restored["upper"], 30)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,136 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
OFFICIAL_SRC = (
|
||||
PROJECT_ROOT
|
||||
/ "packages"
|
||||
/ "jwebot-official-extensions"
|
||||
/ "src"
|
||||
)
|
||||
SDK_SRC = PROJECT_ROOT / "packages" / "jwebot-sdk" / "src"
|
||||
sys.path[:0] = [str(SDK_SRC), str(OFFICIAL_SRC)]
|
||||
|
||||
from jwebot_official_extensions import ( # noqa: E402
|
||||
DIRECT_KERNEL_COMMAND_KINDS,
|
||||
JWEBOT_AUTHOR,
|
||||
LOCAL_COMMAND_ALIASES,
|
||||
LOCAL_EXTENSIONS,
|
||||
OFFICIAL_AUTHOR,
|
||||
extension_for_kind,
|
||||
render_feature_list,
|
||||
)
|
||||
|
||||
|
||||
class LocalExtensionInventoryTests(unittest.TestCase):
|
||||
def test_every_command_kind_is_direct_or_has_exactly_one_owner(self):
|
||||
expected = set(LOCAL_COMMAND_ALIASES.values())
|
||||
owned = [
|
||||
kind
|
||||
for extension in LOCAL_EXTENSIONS
|
||||
for kind in extension.command_kinds
|
||||
]
|
||||
self.assertEqual(
|
||||
set(owned) | set(DIRECT_KERNEL_COMMAND_KINDS),
|
||||
expected,
|
||||
)
|
||||
self.assertEqual(len(owned), len(set(owned)))
|
||||
self.assertFalse(set(owned) & set(DIRECT_KERNEL_COMMAND_KINDS))
|
||||
|
||||
def test_every_alias_is_exposed_by_its_owner(self):
|
||||
for alias, kind in LOCAL_COMMAND_ALIASES.items():
|
||||
with self.subTest(alias=alias, kind=kind):
|
||||
owner = extension_for_kind(kind)
|
||||
if kind in DIRECT_KERNEL_COMMAND_KINDS:
|
||||
self.assertIsNone(owner)
|
||||
continue
|
||||
self.assertIsNotNone(owner)
|
||||
assert owner is not None
|
||||
self.assertIn(f"/{alias}", owner.commands)
|
||||
|
||||
def test_extension_ids_and_commands_do_not_collide(self):
|
||||
ids = [extension.extension_id for extension in LOCAL_EXTENSIONS]
|
||||
commands = [
|
||||
command.casefold()
|
||||
for extension in LOCAL_EXTENSIONS
|
||||
for command in extension.commands
|
||||
]
|
||||
self.assertEqual(len(ids), len(set(ids)))
|
||||
self.assertEqual(len(commands), len(set(commands)))
|
||||
|
||||
def test_help_is_generated_from_every_plugin_description(self):
|
||||
help_text = render_feature_list()
|
||||
self.assertLessEqual(len(help_text), 900)
|
||||
for extension in LOCAL_EXTENSIONS:
|
||||
with self.subTest(extension=extension.extension_id):
|
||||
self.assertTrue(extension.description)
|
||||
self.assertTrue(extension.help_entries)
|
||||
self.assertIn(extension.extension_id, help_text)
|
||||
self.assertIn(extension.description, help_text)
|
||||
for entry in extension.help_entries:
|
||||
self.assertIn(entry, help_text)
|
||||
|
||||
def test_expected_granular_plugins_and_authors_are_present(self):
|
||||
self.assertEqual(
|
||||
{
|
||||
extension.extension_id: extension.author
|
||||
for extension in LOCAL_EXTENSIONS
|
||||
},
|
||||
{
|
||||
"person-memory": OFFICIAL_AUTHOR,
|
||||
"server-status": OFFICIAL_AUTHOR,
|
||||
"jwebot-version": OFFICIAL_AUTHOR,
|
||||
"debug-telemetry": OFFICIAL_AUTHOR,
|
||||
"dice": JWEBOT_AUTHOR,
|
||||
"coin": JWEBOT_AUTHOR,
|
||||
"random-number": JWEBOT_AUTHOR,
|
||||
"draw-lot": JWEBOT_AUTHOR,
|
||||
"daily-fortune": JWEBOT_AUTHOR,
|
||||
"guess-number": JWEBOT_AUTHOR,
|
||||
"rock-paper-scissors": JWEBOT_AUTHOR,
|
||||
"scan-ranking": JWEBOT_AUTHOR,
|
||||
"repeat-ranking": JWEBOT_AUTHOR,
|
||||
"history-search": JWEBOT_AUTHOR,
|
||||
"steam-presence": JWEBOT_AUTHOR,
|
||||
},
|
||||
)
|
||||
|
||||
def test_only_approved_collections_combine_function_kinds(self):
|
||||
combined = {
|
||||
extension.extension_id: extension.command_kinds
|
||||
for extension in LOCAL_EXTENSIONS
|
||||
if len(extension.command_kinds) > 1
|
||||
}
|
||||
self.assertEqual(
|
||||
combined,
|
||||
{
|
||||
"person-memory": (
|
||||
"remember",
|
||||
"my_memory",
|
||||
"forget_memory",
|
||||
"person_memory",
|
||||
"memory_help",
|
||||
),
|
||||
"server-status": ("local_status", "server_status"),
|
||||
"dice": ("dice", "dice_challenge"),
|
||||
},
|
||||
)
|
||||
|
||||
def test_kernel_direct_commands_are_not_plugins(self):
|
||||
self.assertEqual(
|
||||
DIRECT_KERNEL_COMMAND_KINDS,
|
||||
frozenset(
|
||||
{
|
||||
"feature_list",
|
||||
"deepseek_balance",
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
SDK_SRC = PROJECT_ROOT / "packages" / "jwebot-sdk" / "src"
|
||||
OFFICIAL_SRC = (
|
||||
PROJECT_ROOT
|
||||
/ "packages"
|
||||
/ "jwebot-official-extensions"
|
||||
/ "src"
|
||||
)
|
||||
sys.path[:0] = [str(SDK_SRC), str(OFFICIAL_SRC)]
|
||||
|
||||
from jwebot_official_extensions.local_commands import ( # noqa: E402
|
||||
LocalCommandEngine,
|
||||
)
|
||||
|
||||
|
||||
class TelemetryDebugTests(unittest.TestCase):
|
||||
def test_parses_journal_lines_and_renders_p50_p95(self):
|
||||
text = "\n".join(
|
||||
[
|
||||
'telemetry {"name":"gui.send.total","status":"ok",'
|
||||
'"elapsed_ms":100}',
|
||||
'telemetry {"name":"gui.send.total","status":"ok",'
|
||||
'"elapsed_ms":200}',
|
||||
'telemetry {"name":"gui.send.total","status":"ok",'
|
||||
'"elapsed_ms":300}',
|
||||
'telemetry {"name":"provider.direct","status":"error",'
|
||||
'"elapsed_ms":9999}',
|
||||
'not telemetry',
|
||||
]
|
||||
)
|
||||
|
||||
rows = LocalCommandEngine.telemetry_rows_from_text(text)
|
||||
rendered = LocalCommandEngine.render_telemetry_summary(rows)
|
||||
|
||||
self.assertEqual(len(rows), 4)
|
||||
self.assertIn("调试埋点 p50/p95", rendered)
|
||||
self.assertIn("gui.send.total", rendered)
|
||||
self.assertIn("n=3", rendered)
|
||||
self.assertIn("p50=200.0ms", rendered)
|
||||
self.assertIn("p95=290.0ms", rendered)
|
||||
self.assertNotIn("9999", rendered)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,153 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import importlib.util
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
SDK_SRC = PROJECT_ROOT / "packages" / "jwebot-sdk" / "src"
|
||||
KERNEL_SRC = PROJECT_ROOT / "packages" / "jwebot-kernel" / "src"
|
||||
OFFICIAL_SRC = (
|
||||
PROJECT_ROOT
|
||||
/ "packages"
|
||||
/ "jwebot-official-extensions"
|
||||
/ "src"
|
||||
)
|
||||
sys.path[:0] = [str(SDK_SRC), str(KERNEL_SRC), str(OFFICIAL_SRC)]
|
||||
|
||||
from jwebot_kernel import parse_manifest, validate_source_tree # noqa: E402
|
||||
from jwebot_official_extensions import LOCAL_EXTENSIONS # noqa: E402
|
||||
from jwebot_sdk import Event, PlanKind # noqa: E402
|
||||
|
||||
|
||||
class _Registry:
|
||||
def __init__(self):
|
||||
self.handlers = {}
|
||||
|
||||
def register(self, command, handler):
|
||||
if command in self.handlers:
|
||||
raise AssertionError(f"duplicate command: {command}")
|
||||
self.handlers[command] = handler
|
||||
|
||||
|
||||
class _Services:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
def call(self, profile, *, operation, payload):
|
||||
self.calls.append((profile, operation, payload))
|
||||
return {"text": f"{profile}:{payload['command']}"}
|
||||
|
||||
|
||||
class _Context:
|
||||
def __init__(self):
|
||||
self.commands = _Registry()
|
||||
self.services = _Services()
|
||||
|
||||
|
||||
class LocalPluginPackageTests(unittest.TestCase):
|
||||
def event(self):
|
||||
return Event(
|
||||
event_id="event-1",
|
||||
observed_at="2026-10-14T10:00:00+08:00",
|
||||
sender="Alice",
|
||||
text="@JWeBot test",
|
||||
group_fingerprint="group-a",
|
||||
mention_bot=True,
|
||||
)
|
||||
|
||||
def test_every_local_package_validates_registers_and_invokes(self):
|
||||
for descriptor in LOCAL_EXTENSIONS:
|
||||
with self.subTest(extension=descriptor.extension_id):
|
||||
module_name = descriptor.extension_id.replace("-", "_")
|
||||
root = (
|
||||
PROJECT_ROOT
|
||||
/ "extensions"
|
||||
/ descriptor.directory
|
||||
/ descriptor.extension_id
|
||||
)
|
||||
manifest = parse_manifest(
|
||||
{
|
||||
"id": descriptor.extension_id,
|
||||
"author": descriptor.author,
|
||||
"description": descriptor.description,
|
||||
"help": list(descriptor.help_entries),
|
||||
"version": "1.0.0",
|
||||
"api_version": "1",
|
||||
"entrypoint": f"{module_name}:plugin",
|
||||
"type": "command",
|
||||
"capabilities": {
|
||||
"commands": list(descriptor.commands),
|
||||
"storage": False,
|
||||
"network_profiles": [],
|
||||
"service_profiles": [descriptor.extension_id],
|
||||
},
|
||||
}
|
||||
)
|
||||
self.assertEqual(manifest.plugin_id, descriptor.extension_id)
|
||||
self.assertEqual(manifest.author, descriptor.author)
|
||||
self.assertEqual(
|
||||
manifest.description,
|
||||
descriptor.description,
|
||||
)
|
||||
self.assertEqual(
|
||||
manifest.help_entries,
|
||||
descriptor.help_entries,
|
||||
)
|
||||
self.assertEqual(
|
||||
manifest.capabilities.commands,
|
||||
descriptor.commands,
|
||||
)
|
||||
self.assertEqual(
|
||||
manifest.capabilities.service_profiles,
|
||||
(descriptor.extension_id,),
|
||||
)
|
||||
validate_source_tree(root, manifest)
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
f"official_{module_name}",
|
||||
root / "src" / f"{module_name}.py",
|
||||
)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
assert spec.loader is not None
|
||||
spec.loader.exec_module(module)
|
||||
context = _Context()
|
||||
module.plugin(context)
|
||||
self.assertEqual(
|
||||
set(context.commands.handlers),
|
||||
set(descriptor.commands),
|
||||
)
|
||||
first = descriptor.commands[0]
|
||||
result = context.commands.handlers[first](
|
||||
self.event(),
|
||||
"argument",
|
||||
)
|
||||
self.assertEqual(result.reply.kind, PlanKind.TEXT)
|
||||
self.assertEqual(
|
||||
context.services.calls[0][0],
|
||||
descriptor.extension_id,
|
||||
)
|
||||
self.assertEqual(
|
||||
context.services.calls[0][2]["command"],
|
||||
first,
|
||||
)
|
||||
self.assertEqual(
|
||||
context.services.calls[0][2]["arguments"],
|
||||
"argument",
|
||||
)
|
||||
|
||||
def test_generated_local_packages_are_in_sync(self):
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"sync_local_extensions",
|
||||
PROJECT_ROOT / "tools" / "sync-local-extensions.py",
|
||||
)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
assert spec.loader is not None
|
||||
spec.loader.exec_module(module)
|
||||
self.assertEqual(module.synchronize(check=True), [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,87 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import importlib.util
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
MODULE_PATH = PROJECT_ROOT / "tools" / "performance-baseline.py"
|
||||
SPEC = importlib.util.spec_from_file_location(
|
||||
"performance_baseline",
|
||||
MODULE_PATH,
|
||||
)
|
||||
MODULE = importlib.util.module_from_spec(SPEC)
|
||||
assert SPEC.loader is not None
|
||||
sys.modules[SPEC.name] = MODULE
|
||||
SPEC.loader.exec_module(MODULE)
|
||||
|
||||
|
||||
class StatisticsTests(unittest.TestCase):
|
||||
def test_percentile_interpolates_sorted_values(self):
|
||||
self.assertEqual(MODULE.percentile([40, 10, 30, 20], 50), 25)
|
||||
self.assertEqual(MODULE.percentile([10, 20], 95), 19.5)
|
||||
|
||||
def test_percentile_rejects_empty_samples(self):
|
||||
with self.assertRaisesRegex(ValueError, "at least one"):
|
||||
MODULE.percentile([], 50)
|
||||
|
||||
def test_summary_exposes_required_latency_percentiles(self):
|
||||
value = MODULE.summarize_ms([1.0, 2.0, 3.0, 20.0])
|
||||
self.assertEqual(value["samples"], 4)
|
||||
self.assertEqual(value["p50_ms"], 2.5)
|
||||
self.assertEqual(value["max_ms"], 20.0)
|
||||
|
||||
|
||||
class TelemetryAnalysisTests(unittest.TestCase):
|
||||
def test_prefixed_telemetry_line_is_parsed(self):
|
||||
value = MODULE.parse_telemetry_line(
|
||||
'2026-10-14 INFO telemetry '
|
||||
'{"name":"ocr.scan","status":"ok","elapsed_ms":12.5}'
|
||||
)
|
||||
self.assertEqual(value["name"], "ocr.scan")
|
||||
self.assertEqual(value["elapsed_ms"], 12.5)
|
||||
|
||||
def test_unrelated_or_invalid_lines_are_ignored(self):
|
||||
self.assertIsNone(MODULE.parse_telemetry_line("ordinary log line"))
|
||||
self.assertIsNone(MODULE.parse_telemetry_line("telemetry not-json"))
|
||||
self.assertIsNone(MODULE.parse_telemetry_line('{"elapsed_ms":12}'))
|
||||
|
||||
def test_summary_ranks_stages_by_p95_and_counts_statuses(self):
|
||||
records = [
|
||||
{
|
||||
"name": "edge.ack.prepared",
|
||||
"status": "ok",
|
||||
"elapsed_ms": 2.0,
|
||||
"event_hash": "event-a",
|
||||
},
|
||||
{
|
||||
"name": "edge.ack.prepared",
|
||||
"status": "ok",
|
||||
"elapsed_ms": 4.0,
|
||||
"event_hash": "event-b",
|
||||
},
|
||||
{
|
||||
"name": "ocr.scan",
|
||||
"status": "ok",
|
||||
"elapsed_ms": 5000.0,
|
||||
},
|
||||
{
|
||||
"name": "ocr.scan",
|
||||
"status": "error",
|
||||
"elapsed_ms": "private-invalid-value",
|
||||
},
|
||||
]
|
||||
|
||||
value = MODULE.summarize_telemetry(records)
|
||||
|
||||
self.assertEqual(value["records"], 4)
|
||||
self.assertEqual(value["events"], 2)
|
||||
self.assertEqual(value["stages"][0]["name"], "ocr.scan")
|
||||
self.assertEqual(value["stages"][1]["name"], "edge.ack.prepared")
|
||||
self.assertEqual(value["statuses"]["ocr.scan:error"], 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,102 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
SDK_SRC = PROJECT_ROOT / "packages" / "jwebot-sdk" / "src"
|
||||
sys.path.insert(0, str(SDK_SRC))
|
||||
|
||||
from jwebot_sdk import ( # noqa: E402
|
||||
ContractViolation,
|
||||
Event,
|
||||
ExtensionResult,
|
||||
Fact,
|
||||
PlanKind,
|
||||
ReplyPlan,
|
||||
)
|
||||
|
||||
|
||||
class EventContractTests(unittest.TestCase):
|
||||
def event(self, **overrides):
|
||||
value = {
|
||||
"version": 1,
|
||||
"event_id": "event-1",
|
||||
"observed_at": "2026-10-14T10:00:00+08:00",
|
||||
"sender": "Alice",
|
||||
"text": "@JWeBot hello",
|
||||
"confidence": 0.95,
|
||||
"sender_confidence": 0.96,
|
||||
"body_confidence": 0.97,
|
||||
"side": "incoming",
|
||||
"mention_bot": True,
|
||||
"group_fingerprint": "group-a",
|
||||
"source": "rapidocr-local",
|
||||
}
|
||||
value.update(overrides)
|
||||
return value
|
||||
|
||||
def test_event_round_trip_preserves_normalized_fields(self):
|
||||
event = Event.from_mapping(self.event())
|
||||
self.assertEqual(event.to_mapping(), self.event())
|
||||
|
||||
def test_event_rejects_outgoing_messages(self):
|
||||
with self.assertRaisesRegex(ContractViolation, "incoming"):
|
||||
Event.from_mapping(self.event(side="outgoing"))
|
||||
|
||||
def test_event_rejects_out_of_range_confidence(self):
|
||||
with self.assertRaisesRegex(ContractViolation, "between 0 and 1"):
|
||||
Event.from_mapping(self.event(confidence=1.1))
|
||||
|
||||
def test_event_rejects_unsupported_version(self):
|
||||
with self.assertRaisesRegex(ContractViolation, "unsupported event version"):
|
||||
Event.from_mapping(self.event(version=2))
|
||||
|
||||
|
||||
class PlanContractTests(unittest.TestCase):
|
||||
def test_text_plan_has_a_bounded_serialized_shape(self):
|
||||
plan = ReplyPlan.text("hello")
|
||||
self.assertEqual(plan.kind, PlanKind.TEXT)
|
||||
self.assertEqual(plan.to_mapping(), {"kind": "text", "text": "hello"})
|
||||
|
||||
def test_no_reply_plan_cannot_contain_text(self):
|
||||
with self.assertRaisesRegex(ContractViolation, "cannot contain text"):
|
||||
ReplyPlan(kind=PlanKind.NO_REPLY, message="unexpected")
|
||||
|
||||
def test_result_freezes_metadata_and_serializes_facts(self):
|
||||
result = ExtensionResult(
|
||||
reply=ReplyPlan.text("hello"),
|
||||
facts=(Fact(kind="topic", text="likes servers", confidence=0.8),),
|
||||
metadata={"source": "test"},
|
||||
)
|
||||
self.assertEqual(result.to_mapping()["metadata"], {"source": "test"})
|
||||
with self.assertRaises(TypeError):
|
||||
result.metadata["source"] = "changed" # type: ignore[index]
|
||||
|
||||
def test_result_rejects_unbounded_fact_count(self):
|
||||
facts = tuple(Fact(kind="topic", text=f"fact-{index}") for index in range(13))
|
||||
with self.assertRaisesRegex(ContractViolation, "exceeds 12 facts"):
|
||||
ExtensionResult(facts=facts)
|
||||
|
||||
def test_result_round_trip_preserves_reply_facts_and_metadata(self):
|
||||
result = ExtensionResult(
|
||||
reply=ReplyPlan.text("hello"),
|
||||
facts=(Fact(kind="topic", text="likes servers", confidence=0.8),),
|
||||
metadata={"source": "test"},
|
||||
)
|
||||
restored = ExtensionResult.from_mapping(result.to_mapping())
|
||||
self.assertEqual(restored.reply, result.reply)
|
||||
self.assertEqual(restored.facts, result.facts)
|
||||
self.assertEqual(dict(restored.metadata), dict(result.metadata))
|
||||
|
||||
def test_result_rejects_wrong_api_version(self):
|
||||
value = ExtensionResult(reply=ReplyPlan.text("hello")).to_mapping()
|
||||
value["api_version"] = "2"
|
||||
with self.assertRaisesRegex(ContractViolation, "API version"):
|
||||
ExtensionResult.from_mapping(value)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,56 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import importlib.util
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
MODULE_PATH = PROJECT_ROOT / "tools" / "check-secrets.py"
|
||||
SPEC = importlib.util.spec_from_file_location("check_secrets", MODULE_PATH)
|
||||
MODULE = importlib.util.module_from_spec(SPEC)
|
||||
assert SPEC.loader is not None
|
||||
SPEC.loader.exec_module(MODULE)
|
||||
|
||||
|
||||
class SnapshotScannerTests(unittest.TestCase):
|
||||
def test_snapshot_without_git_scans_regular_files(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
(root / "safe.txt").write_text("safe\n", encoding="utf-8")
|
||||
(root / ".venv").mkdir()
|
||||
(root / ".venv" / "ignored.txt").write_text(
|
||||
"JWEBOT_EDGE_STEAM_API_KEY=not-for-scan\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
with mock.patch.object(MODULE, "ROOT", root), mock.patch.object(
|
||||
MODULE,
|
||||
"has_git_repository",
|
||||
return_value=False,
|
||||
):
|
||||
self.assertEqual(MODULE.tracked_paths(), ["safe.txt"])
|
||||
self.assertEqual(MODULE.scan_tracked(), [])
|
||||
|
||||
def test_snapshot_scanner_reports_fingerprint_not_value(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
(root / "config.env").write_text(
|
||||
"JWEBOT_EDGE_STEAM_API_KEY=local-sensitive-value\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
with mock.patch.object(MODULE, "ROOT", root), mock.patch.object(
|
||||
MODULE,
|
||||
"has_git_repository",
|
||||
return_value=False,
|
||||
):
|
||||
findings = MODULE.scan_tracked()
|
||||
|
||||
self.assertEqual(findings[0][0], "populated_secret")
|
||||
self.assertEqual(findings[0][1], "config.env")
|
||||
self.assertNotIn("local-sensitive-value", str(findings))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,341 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import importlib.util
|
||||
import sys
|
||||
import unittest
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
from PIL import Image
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
MODULE_PATH = PROJECT_ROOT / "wechat_adaptive_gui_bridge.py"
|
||||
SPEC = importlib.util.spec_from_file_location("wechat_adaptive_gui_bridge", MODULE_PATH)
|
||||
MODULE = importlib.util.module_from_spec(SPEC)
|
||||
assert SPEC.loader is not None
|
||||
sys.modules[SPEC.name] = MODULE
|
||||
SPEC.loader.exec_module(MODULE)
|
||||
|
||||
|
||||
class CoordinateTests(unittest.TestCase):
|
||||
def test_baseline_coordinates_are_unchanged(self):
|
||||
self.assertEqual(
|
||||
MODULE.scaled_point(
|
||||
MODULE.BASE_DRAFT_POINT,
|
||||
width=MODULE.BASE_WIDTH,
|
||||
height=MODULE.BASE_HEIGHT,
|
||||
),
|
||||
MODULE.BASE_DRAFT_POINT,
|
||||
)
|
||||
|
||||
def test_current_vnc_coordinates_are_scaled(self):
|
||||
self.assertEqual(
|
||||
MODULE.scaled_point(
|
||||
MODULE.BASE_DRAFT_POINT,
|
||||
width=1470,
|
||||
height=694,
|
||||
),
|
||||
(450, 625),
|
||||
)
|
||||
self.assertEqual(
|
||||
MODULE.scaled_point(
|
||||
MODULE.BASE_SEND_POINT,
|
||||
width=1470,
|
||||
height=694,
|
||||
),
|
||||
(1419, 637),
|
||||
)
|
||||
self.assertEqual(
|
||||
MODULE.scaled_box(
|
||||
MODULE.BASE_INPUT_BOX,
|
||||
width=1470,
|
||||
height=694,
|
||||
),
|
||||
(306, 582, 1469, 693),
|
||||
)
|
||||
self.assertEqual(
|
||||
MODULE.scaled_region(
|
||||
MODULE.BASE_INPUT_TEXT_ROI,
|
||||
width=1470,
|
||||
height=694,
|
||||
),
|
||||
(327, 595, 653, 60),
|
||||
)
|
||||
self.assertEqual(MODULE.INPUT_TEXT_ROI_NAME, "input_text_core_v2")
|
||||
|
||||
|
||||
class VncKeyboardTests(unittest.TestCase):
|
||||
def test_vnc_key_event_has_the_expected_wire_format(self):
|
||||
connection = mock.Mock()
|
||||
MODULE.vnc_key_event(connection, down=True, keysym=ord("v"))
|
||||
connection.sendall.assert_called_once_with(
|
||||
MODULE.struct.pack(">BBHI", 4, 1, 0, ord("v"))
|
||||
)
|
||||
|
||||
def test_x11_clipboard_rejects_a_failed_owner(self):
|
||||
process = mock.Mock()
|
||||
process.returncode = 1
|
||||
with mock.patch.object(
|
||||
MODULE.subprocess,
|
||||
"Popen",
|
||||
return_value=process,
|
||||
):
|
||||
with self.assertRaisesRegex(RuntimeError, "clipboard operation failed"):
|
||||
MODULE.set_x_clipboard("测试")
|
||||
|
||||
def test_vnc_pointer_event_has_the_expected_wire_format(self):
|
||||
connection = mock.Mock()
|
||||
MODULE.vnc_pointer_event(connection, buttons=1, x=450, y=652)
|
||||
connection.sendall.assert_called_once_with(
|
||||
MODULE.struct.pack(">BBHH", 5, 1, 450, 652)
|
||||
)
|
||||
|
||||
def test_vnc_select_all_and_clear_uses_vnc_key_events(self):
|
||||
connection = mock.Mock()
|
||||
MODULE.vnc_select_all_and_clear(connection)
|
||||
payloads = [call.args[0] for call in connection.sendall.call_args_list]
|
||||
self.assertEqual(
|
||||
payloads,
|
||||
[
|
||||
MODULE.struct.pack(">BBHI", 4, 1, 0, 0xFFE3),
|
||||
MODULE.struct.pack(">BBHI", 4, 1, 0, ord("a")),
|
||||
MODULE.struct.pack(">BBHI", 4, 0, 0, ord("a")),
|
||||
MODULE.struct.pack(">BBHI", 4, 0, 0, 0xFFE3),
|
||||
MODULE.struct.pack(">BBHI", 4, 1, 0, 0xFF08),
|
||||
MODULE.struct.pack(">BBHI", 4, 0, 0, 0xFF08),
|
||||
],
|
||||
)
|
||||
|
||||
def test_vnc_return_key_uses_the_standard_enter_keysym(self):
|
||||
connection = mock.Mock()
|
||||
MODULE.vnc_key_press(connection, 0xFF0D)
|
||||
self.assertEqual(
|
||||
[call.args[0] for call in connection.sendall.call_args_list],
|
||||
[
|
||||
MODULE.struct.pack(">BBHI", 4, 1, 0, 0xFF0D),
|
||||
MODULE.struct.pack(">BBHI", 4, 0, 0, 0xFF0D),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
class InputVerificationTests(unittest.TestCase):
|
||||
def test_changed_pixels_ignores_a_small_cursor_change(self):
|
||||
before = Image.new("RGB", (20, 20), "white")
|
||||
after = before.copy()
|
||||
after.putpixel((1, 1), (0, 0, 0))
|
||||
self.assertEqual(MODULE.changed_pixels(before, after), 1)
|
||||
|
||||
def test_changed_pixels_detects_pasted_text_region(self):
|
||||
before = Image.new("RGB", (20, 20), "white")
|
||||
after = before.copy()
|
||||
for x in range(10):
|
||||
for y in range(10):
|
||||
after.putpixel((x, y), (0, 0, 0))
|
||||
self.assertGreaterEqual(
|
||||
MODULE.changed_pixels(before, after),
|
||||
MODULE.MIN_DRAFT_CHANGED_PIXELS,
|
||||
)
|
||||
|
||||
def test_changed_pixels_rejects_an_unchanged_send_region(self):
|
||||
image = Image.new("RGB", (20, 20), "white")
|
||||
self.assertLess(
|
||||
MODULE.changed_pixels(image, image.copy()),
|
||||
MODULE.MIN_DRAFT_CHANGED_PIXELS,
|
||||
)
|
||||
|
||||
def test_ink_pixels_detects_text_and_blank_roi(self):
|
||||
blank = Image.new("RGB", (20, 20), "white")
|
||||
text = blank.copy()
|
||||
for x in range(8):
|
||||
for y in range(8):
|
||||
text.putpixel((x, y), (0, 0, 0))
|
||||
|
||||
self.assertLessEqual(MODULE.ink_pixels(blank), MODULE.MAX_EMPTY_ROI_INK_PIXELS)
|
||||
self.assertGreater(MODULE.ink_pixels(text), MODULE.MAX_EMPTY_ROI_INK_PIXELS)
|
||||
|
||||
def test_wait_for_input_empty_reports_poll_count(self):
|
||||
dirty = Image.new("RGB", (20, 20), "white")
|
||||
for x in range(8):
|
||||
for y in range(8):
|
||||
dirty.putpixel((x, y), (0, 0, 0))
|
||||
blank = Image.new("RGB", (20, 20), "white")
|
||||
monotonic_values = iter((1.0, 1.0, 1.05, 1.05))
|
||||
with mock.patch.object(
|
||||
MODULE,
|
||||
"input_image",
|
||||
side_effect=[dirty, blank],
|
||||
), mock.patch.object(
|
||||
MODULE.time,
|
||||
"monotonic",
|
||||
side_effect=lambda: next(monotonic_values),
|
||||
), mock.patch.object(MODULE.time, "sleep"):
|
||||
_, ink, polls, elapsed_ms = MODULE.wait_for_input_empty(
|
||||
"window",
|
||||
width=1470,
|
||||
height=694,
|
||||
timeout_seconds=MODULE.CLEAR_CONFIRM_TIMEOUT_SECONDS,
|
||||
)
|
||||
|
||||
self.assertLessEqual(ink, MODULE.MAX_EMPTY_ROI_INK_PIXELS)
|
||||
self.assertEqual(polls, 2)
|
||||
self.assertEqual(elapsed_ms, 50.0)
|
||||
|
||||
def test_wait_for_input_change_reports_poll_count_and_duration(self):
|
||||
before = Image.new("RGB", (20, 20), "white")
|
||||
after = before.copy()
|
||||
for x in range(10):
|
||||
for y in range(10):
|
||||
after.putpixel((x, y), (0, 0, 0))
|
||||
monotonic_values = iter((1.0, 1.0, 1.05))
|
||||
with mock.patch.object(
|
||||
MODULE,
|
||||
"input_image",
|
||||
return_value=after,
|
||||
), mock.patch.object(
|
||||
MODULE,
|
||||
"changed_pixels",
|
||||
return_value=MODULE.MIN_SEND_CHANGED_PIXELS,
|
||||
), mock.patch.object(
|
||||
MODULE.time,
|
||||
"monotonic",
|
||||
side_effect=lambda: next(monotonic_values),
|
||||
):
|
||||
_, changed, polls, elapsed_ms = MODULE.wait_for_input_change(
|
||||
"window",
|
||||
before,
|
||||
width=1470,
|
||||
height=694,
|
||||
threshold=MODULE.MIN_SEND_CHANGED_PIXELS,
|
||||
timeout_seconds=MODULE.SEND_CONFIRM_TIMEOUT_SECONDS,
|
||||
)
|
||||
|
||||
self.assertEqual(changed, MODULE.MIN_SEND_CHANGED_PIXELS)
|
||||
self.assertEqual(polls, 1)
|
||||
self.assertEqual(elapsed_ms, 50.0)
|
||||
|
||||
|
||||
class SendTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temporary = tempfile.TemporaryDirectory()
|
||||
self.pending_text = Path(self.temporary.name) / "pending.txt"
|
||||
self.pending_token = Path(self.temporary.name) / "pending.token"
|
||||
self.pending_text.write_text("reply", encoding="utf-8")
|
||||
self.pending_token.write_text("token\n", encoding="utf-8")
|
||||
self.patches = [
|
||||
mock.patch.object(MODULE, "PENDING_TEXT", self.pending_text),
|
||||
mock.patch.object(MODULE, "PENDING_TOKEN", self.pending_token),
|
||||
mock.patch.object(MODULE, "window_id", return_value="window"),
|
||||
mock.patch.object(MODULE, "geometry", return_value=(1470, 694)),
|
||||
mock.patch.object(MODULE, "position", return_value=(0, 0)),
|
||||
mock.patch.object(MODULE.time, "sleep"),
|
||||
]
|
||||
for patch in self.patches:
|
||||
patch.start()
|
||||
|
||||
def tearDown(self):
|
||||
for patch in reversed(self.patches):
|
||||
patch.stop()
|
||||
self.temporary.cleanup()
|
||||
|
||||
def test_send_presses_return_first_after_roi_flag(self):
|
||||
connection = mock.Mock()
|
||||
before = Image.new("RGB", (20, 20), "white")
|
||||
after = before.copy()
|
||||
with mock.patch.object(
|
||||
MODULE,
|
||||
"vnc_focus_input",
|
||||
return_value=connection,
|
||||
) as focus, mock.patch.object(
|
||||
MODULE,
|
||||
"input_image",
|
||||
return_value=before,
|
||||
), mock.patch.object(
|
||||
MODULE,
|
||||
"wait_for_input_change",
|
||||
return_value=(after, MODULE.MIN_SEND_CHANGED_PIXELS, 1, 50.0),
|
||||
) as wait:
|
||||
MODULE.send("token")
|
||||
|
||||
focus.assert_called_once_with("window", (450, 625), origin=(0, 0))
|
||||
connection.sendall.assert_any_call(
|
||||
MODULE.struct.pack(">BBHI", 4, 1, 0, 0xFF0D)
|
||||
)
|
||||
self.assertEqual(wait.call_args.kwargs["box"], MODULE.BASE_INPUT_TEXT_ROI)
|
||||
self.assertFalse(self.pending_text.exists())
|
||||
self.assertFalse(self.pending_token.exists())
|
||||
|
||||
def test_send_uses_button_when_return_is_not_confirmed(self):
|
||||
first_connection = mock.Mock()
|
||||
second_connection = mock.Mock()
|
||||
before = Image.new("RGB", (20, 20), "white")
|
||||
after_return = before.copy()
|
||||
after_click = before.copy()
|
||||
with mock.patch.object(
|
||||
MODULE,
|
||||
"vnc_focus_input",
|
||||
side_effect=[first_connection, second_connection],
|
||||
) as focus, mock.patch.object(
|
||||
MODULE,
|
||||
"input_image",
|
||||
return_value=before,
|
||||
), mock.patch.object(
|
||||
MODULE,
|
||||
"wait_for_input_change",
|
||||
side_effect=[
|
||||
(after_click, 0, 18, 900.0),
|
||||
(after_return, MODULE.MIN_SEND_CHANGED_PIXELS, 1, 50.0),
|
||||
],
|
||||
):
|
||||
MODULE.send("token")
|
||||
|
||||
self.assertEqual(
|
||||
[call.args for call in focus.call_args_list],
|
||||
[("window", (450, 625)), ("window", (1419, 637))],
|
||||
)
|
||||
first_connection.sendall.assert_any_call(
|
||||
MODULE.struct.pack(">BBHI", 4, 1, 0, 0xFF0D)
|
||||
)
|
||||
self.assertFalse(self.pending_text.exists())
|
||||
self.assertFalse(self.pending_token.exists())
|
||||
|
||||
def test_deliver_text_confirms_clear_before_paste(self):
|
||||
connection = mock.Mock()
|
||||
blank = Image.new("RGB", (20, 20), "white")
|
||||
after_paste = blank.copy()
|
||||
after_send = blank.copy()
|
||||
with mock.patch.object(
|
||||
MODULE,
|
||||
"vnc_focus_input",
|
||||
return_value=connection,
|
||||
), mock.patch.object(
|
||||
MODULE,
|
||||
"wait_for_input_empty",
|
||||
return_value=(blank, 0, 1, 50.0),
|
||||
) as empty, mock.patch.object(
|
||||
MODULE,
|
||||
"vnc_paste",
|
||||
) as paste, mock.patch.object(
|
||||
MODULE,
|
||||
"input_image",
|
||||
return_value=after_paste,
|
||||
), mock.patch.object(
|
||||
MODULE,
|
||||
"wait_for_input_change",
|
||||
side_effect=[
|
||||
(after_paste, MODULE.MIN_DRAFT_CHANGED_PIXELS, 1, 50.0),
|
||||
(after_send, MODULE.MIN_SEND_CHANGED_PIXELS, 1, 50.0),
|
||||
],
|
||||
) as wait:
|
||||
payload = MODULE.deliver_text("reply")
|
||||
|
||||
self.assertEqual(payload["state"], "sent")
|
||||
empty.assert_called_once()
|
||||
paste.assert_called_once_with(connection, "reply")
|
||||
self.assertEqual(len(wait.call_args_list), 2)
|
||||
connection.sendall.assert_any_call(
|
||||
MODULE.struct.pack(">BBHI", 4, 1, 0, 0xFF0D)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,639 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import importlib.util
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
MODULE_PATH = PROJECT_ROOT / "wechat_adaptive_recorder.py"
|
||||
SPEC = importlib.util.spec_from_file_location("wechat_adaptive_recorder", MODULE_PATH)
|
||||
MODULE = importlib.util.module_from_spec(SPEC)
|
||||
assert SPEC.loader is not None
|
||||
sys.modules[SPEC.name] = MODULE
|
||||
SPEC.loader.exec_module(MODULE)
|
||||
|
||||
|
||||
class FakeGuiUpstream:
|
||||
def __init__(self) -> None:
|
||||
self.events = []
|
||||
self.bridge_payloads = []
|
||||
self.current_group = "group-a"
|
||||
self.title_captures = 0
|
||||
self.full_captures = 0
|
||||
|
||||
@contextmanager
|
||||
def gui_interaction_lock(self, *, blocking):
|
||||
yield True
|
||||
|
||||
def capture_image(self):
|
||||
self.full_captures += 1
|
||||
return object()
|
||||
|
||||
def title_fingerprint(self, image):
|
||||
return self.current_group
|
||||
|
||||
def capture_title_fingerprint(self):
|
||||
self.title_captures += 1
|
||||
return self.current_group
|
||||
|
||||
def telemetry_event(self, name, **kwargs):
|
||||
self.events.append((name, kwargs))
|
||||
|
||||
def emit_bridge_timings(self, payload, **kwargs):
|
||||
self.bridge_payloads.append((payload, kwargs))
|
||||
|
||||
|
||||
class FakeGuiBridge:
|
||||
def __init__(self) -> None:
|
||||
self.calls = []
|
||||
self.active = 0
|
||||
self.max_active = 0
|
||||
self.lock = threading.Lock()
|
||||
|
||||
def deliver_text(self, reply, *, before_send=None):
|
||||
with self.lock:
|
||||
self.active += 1
|
||||
self.max_active = max(self.max_active, self.active)
|
||||
try:
|
||||
self.calls.append(reply)
|
||||
if before_send is not None:
|
||||
before_send()
|
||||
time.sleep(0.01)
|
||||
return {
|
||||
"ok": True,
|
||||
"state": "sent",
|
||||
"timings": [{"phase": "deliver.total", "elapsed_ms": 1.0}],
|
||||
}
|
||||
finally:
|
||||
with self.lock:
|
||||
self.active -= 1
|
||||
|
||||
|
||||
class BlockingGuiBridge(FakeGuiBridge):
|
||||
def __init__(self, blocked_reply):
|
||||
super().__init__()
|
||||
self.blocked_reply = blocked_reply
|
||||
self.blocked_started = threading.Event()
|
||||
self.release_blocked = threading.Event()
|
||||
|
||||
def deliver_text(self, reply, *, before_send=None):
|
||||
with self.lock:
|
||||
self.active += 1
|
||||
self.max_active = max(self.max_active, self.active)
|
||||
try:
|
||||
self.calls.append(reply)
|
||||
if reply == self.blocked_reply:
|
||||
self.blocked_started.set()
|
||||
if not self.release_blocked.wait(2):
|
||||
raise RuntimeError("blocked GUI job was not released")
|
||||
if before_send is not None:
|
||||
before_send()
|
||||
return {
|
||||
"ok": True,
|
||||
"state": "sent",
|
||||
"timings": [{"phase": "deliver.total", "elapsed_ms": 1.0}],
|
||||
}
|
||||
finally:
|
||||
with self.lock:
|
||||
self.active -= 1
|
||||
|
||||
|
||||
class LayoutScalingTests(unittest.TestCase):
|
||||
def run_gui_priority_scenario(self, queued_jobs):
|
||||
upstream = FakeGuiUpstream()
|
||||
bridge = BlockingGuiBridge("active-final")
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
sender = MODULE.GuiSender(
|
||||
upstream,
|
||||
bridge,
|
||||
socket_path=Path(temporary) / "gui.sock",
|
||||
)
|
||||
sender.start()
|
||||
errors = []
|
||||
|
||||
def submit(reply, source):
|
||||
try:
|
||||
sender.send_reply(
|
||||
reply,
|
||||
"group-a",
|
||||
source=source,
|
||||
timeout_seconds=3,
|
||||
)
|
||||
except BaseException as error:
|
||||
errors.append(error)
|
||||
|
||||
threads = [
|
||||
threading.Thread(
|
||||
target=submit,
|
||||
args=("active-final", "delivery-helper"),
|
||||
)
|
||||
]
|
||||
threads[0].start()
|
||||
self.assertTrue(bridge.blocked_started.wait(1))
|
||||
for index, (reply, source) in enumerate(queued_jobs, start=1):
|
||||
thread = threading.Thread(target=submit, args=(reply, source))
|
||||
threads.append(thread)
|
||||
thread.start()
|
||||
deadline = time.monotonic() + 1
|
||||
while (
|
||||
sender.jobs.qsize() < index
|
||||
and time.monotonic() < deadline
|
||||
):
|
||||
time.sleep(0.01)
|
||||
self.assertEqual(sender.jobs.qsize(), index)
|
||||
|
||||
bridge.release_blocked.set()
|
||||
for thread in threads:
|
||||
thread.join(timeout=3)
|
||||
self.assertFalse(thread.is_alive())
|
||||
sender.stop()
|
||||
|
||||
self.assertEqual(errors, [])
|
||||
self.assertEqual(bridge.max_active, 1)
|
||||
return bridge.calls
|
||||
|
||||
def test_fast_ack_telemetry_keeps_legacy_and_dedicated_names(self):
|
||||
source = Path(MODULE.__file__).read_text(encoding="utf-8")
|
||||
for name in (
|
||||
"recorder.ack.gui",
|
||||
"recorder.ack.end_to_end",
|
||||
"recorder.ack.confirm",
|
||||
"recorder.fast_ack.gui",
|
||||
"recorder.fast_ack.end_to_end",
|
||||
"recorder.fast_ack.confirm",
|
||||
):
|
||||
self.assertIn(name, source)
|
||||
|
||||
def test_default_upstream_is_installed_with_the_adaptive_runtime(self):
|
||||
self.assertEqual(
|
||||
MODULE.UPSTREAM,
|
||||
MODULE.HOME
|
||||
/ ".local"
|
||||
/ "lib"
|
||||
/ "wechat-health-watch"
|
||||
/ "wechat_local_ocr.py",
|
||||
)
|
||||
|
||||
def test_wait_for_ready_retries_without_exiting_the_service(self):
|
||||
sleep = mock.Mock()
|
||||
with mock.patch.object(
|
||||
MODULE,
|
||||
"configure_layout",
|
||||
side_effect=[
|
||||
RuntimeError("login required"),
|
||||
(1440, 873),
|
||||
],
|
||||
) as configure:
|
||||
layout = MODULE.wait_for_ready(
|
||||
mock.Mock(),
|
||||
poll_seconds=2.5,
|
||||
sleep=sleep,
|
||||
monotonic=mock.Mock(return_value=10.0),
|
||||
)
|
||||
|
||||
self.assertEqual(layout, (1440, 873))
|
||||
self.assertEqual(configure.call_count, 2)
|
||||
sleep.assert_called_once_with(2.5)
|
||||
|
||||
def test_wait_for_ready_rejects_a_busy_loop(self):
|
||||
with self.assertRaisesRegex(ValueError, "positive"):
|
||||
MODULE.wait_for_ready(mock.Mock(), poll_seconds=0)
|
||||
|
||||
def test_ready_capture_preserves_the_legacy_zero_argument_call(self):
|
||||
upstream = mock.Mock()
|
||||
capture = mock.Mock(return_value="image")
|
||||
with mock.patch.object(MODULE, "wait_for_ready") as ready:
|
||||
result = MODULE.capture_when_ready(upstream, capture)
|
||||
|
||||
self.assertEqual(result, "image")
|
||||
ready.assert_called_once_with(upstream)
|
||||
capture.assert_called_once_with()
|
||||
|
||||
def test_ready_capture_forwards_the_verified_window_identifier(self):
|
||||
upstream = mock.Mock()
|
||||
capture = mock.Mock(return_value="image")
|
||||
with mock.patch.object(
|
||||
MODULE,
|
||||
"wait_for_ready",
|
||||
) as ready, mock.patch.object(
|
||||
MODULE,
|
||||
"configure_layout",
|
||||
) as configure:
|
||||
result = MODULE.capture_when_ready(upstream, capture, "42")
|
||||
|
||||
self.assertEqual(result, "image")
|
||||
ready.assert_not_called()
|
||||
configure.assert_called_once_with(upstream, identifier="42")
|
||||
capture.assert_called_once_with("42")
|
||||
|
||||
def test_verified_capture_layout_does_not_rediscover_the_window(self):
|
||||
upstream = mock.Mock()
|
||||
upstream.window_geometry.return_value = (1440, 873)
|
||||
|
||||
layout = MODULE.configure_layout(upstream, identifier="42")
|
||||
|
||||
self.assertEqual(layout, (1440, 873))
|
||||
upstream.window_id.assert_not_called()
|
||||
upstream.window_geometry.assert_called_once_with("42")
|
||||
|
||||
def test_ack_end_to_end_age_uses_the_observed_timestamp(self):
|
||||
self.assertEqual(
|
||||
MODULE.elapsed_since_iso_ms(
|
||||
"2026-10-14T12:00:00+00:00",
|
||||
now=datetime(
|
||||
2026,
|
||||
10,
|
||||
14,
|
||||
12,
|
||||
0,
|
||||
1,
|
||||
250000,
|
||||
tzinfo=timezone.utc,
|
||||
),
|
||||
),
|
||||
1250.0,
|
||||
)
|
||||
self.assertIsNone(MODULE.elapsed_since_iso_ms("not-a-timestamp"))
|
||||
|
||||
def test_baseline_box_is_preserved(self):
|
||||
self.assertEqual(
|
||||
MODULE.scale_box(
|
||||
MODULE.BASE_MESSAGE_BOX,
|
||||
width=MODULE.BASE_WIDTH,
|
||||
height=MODULE.BASE_HEIGHT,
|
||||
),
|
||||
MODULE.BASE_MESSAGE_BOX,
|
||||
)
|
||||
|
||||
def test_current_vnc_box_is_scaled_and_bounded(self):
|
||||
box = MODULE.scale_box(
|
||||
MODULE.BASE_MESSAGE_BOX,
|
||||
width=1470,
|
||||
height=694,
|
||||
)
|
||||
self.assertEqual(box, (306, 64, 1469, 582))
|
||||
self.assertLess(box[0], box[2])
|
||||
self.assertLess(box[1], box[3])
|
||||
|
||||
def test_scaled_box_never_exceeds_image_bounds(self):
|
||||
self.assertEqual(
|
||||
MODULE.scale_box(
|
||||
MODULE.BASE_MESSAGE_BOX,
|
||||
width=320,
|
||||
height=200,
|
||||
),
|
||||
(67, 18, 320, 168),
|
||||
)
|
||||
|
||||
def test_capture_heartbeat_is_shorter_than_health_staleness_window(self):
|
||||
self.assertLess(
|
||||
MODULE.CAPTURE_HEARTBEAT_SECONDS,
|
||||
180,
|
||||
)
|
||||
|
||||
def test_openclaw_transport_retries_without_control_socket(self):
|
||||
with mock.patch.dict(
|
||||
MODULE.os.environ,
|
||||
{"WECHAT_HEALTH_CLOUD_HOST": "cloud.example"},
|
||||
clear=True,
|
||||
):
|
||||
command = MODULE.user_openclaw_command(
|
||||
mock.Mock(SSH_KEY=Path("/tmp/key")),
|
||||
agent="wechat-responder",
|
||||
session="probe",
|
||||
timeout_seconds=30,
|
||||
)
|
||||
self.assertIn("-F", command)
|
||||
self.assertIn("/dev/null", command)
|
||||
self.assertIn("ConnectionAttempts=3", command)
|
||||
self.assertIn("ServerAliveInterval=5", command)
|
||||
self.assertIn("ServerAliveCountMax=2", command)
|
||||
self.assertIn("IPQoS=none", command)
|
||||
self.assertNotIn("ControlMaster=auto", command)
|
||||
self.assertNotIn("--model", command)
|
||||
self.assertNotIn("--thinking", command)
|
||||
|
||||
def test_edge_reply_is_used_without_invoking_legacy_router(self):
|
||||
legacy = mock.Mock(return_value="legacy")
|
||||
with mock.patch.object(
|
||||
MODULE.jwebot_edge,
|
||||
"request_edge",
|
||||
return_value={"status": "reply", "text": "本地回复喵"},
|
||||
):
|
||||
reply = MODULE.edge_router_reply(
|
||||
{"event_id": "event-1", "text": "@JWeBot 你好"},
|
||||
legacy_router=legacy,
|
||||
)
|
||||
|
||||
self.assertEqual(reply, "本地回复喵")
|
||||
legacy.assert_not_called()
|
||||
|
||||
def test_fast_ack_is_sent_by_callback_without_upstream_duplicate(self):
|
||||
legacy = mock.Mock(return_value="legacy")
|
||||
fast_ack_sender = mock.Mock(return_value=True)
|
||||
incoming = {
|
||||
"event_id": "event-1",
|
||||
"group_fingerprint": "group-a",
|
||||
"text": "@JWeBot 你好",
|
||||
}
|
||||
response = {
|
||||
"status": "reply",
|
||||
"route": "chat",
|
||||
"text": MODULE.jwebot_edge.FAST_ACKNOWLEDGEMENT,
|
||||
"event_id": "event-1",
|
||||
"fast_ack": True,
|
||||
}
|
||||
with mock.patch.object(
|
||||
MODULE.jwebot_edge,
|
||||
"request_edge",
|
||||
return_value=response,
|
||||
):
|
||||
reply = MODULE.edge_router_reply(
|
||||
incoming,
|
||||
legacy_router=legacy,
|
||||
fast_ack_sender=fast_ack_sender,
|
||||
)
|
||||
|
||||
self.assertEqual(reply, "NO_REPLY")
|
||||
fast_ack_sender.assert_called_once_with(incoming, response)
|
||||
legacy.assert_not_called()
|
||||
|
||||
def test_failed_fast_ack_does_not_fall_back_to_a_second_sender(self):
|
||||
legacy = mock.Mock(return_value="legacy")
|
||||
fast_ack_sender = mock.Mock(return_value=False)
|
||||
response = {
|
||||
"status": "reply",
|
||||
"route": "chat",
|
||||
"text": MODULE.jwebot_edge.FAST_ACKNOWLEDGEMENT,
|
||||
"event_id": "event-1",
|
||||
"fast_ack": True,
|
||||
}
|
||||
with mock.patch.object(
|
||||
MODULE.jwebot_edge,
|
||||
"request_edge",
|
||||
return_value=response,
|
||||
):
|
||||
reply = MODULE.edge_router_reply(
|
||||
{"event_id": "event-1", "text": "@JWeBot 你好"},
|
||||
legacy_router=legacy,
|
||||
fast_ack_sender=fast_ack_sender,
|
||||
)
|
||||
|
||||
self.assertEqual(reply, "NO_REPLY")
|
||||
fast_ack_sender.assert_called_once()
|
||||
legacy.assert_not_called()
|
||||
|
||||
def test_hash_command_uses_legacy_router_when_edge_is_unavailable(self):
|
||||
legacy = mock.Mock(return_value="legacy")
|
||||
with mock.patch.object(
|
||||
MODULE.jwebot_edge,
|
||||
"request_edge",
|
||||
side_effect=MODULE.jwebot_edge.EdgeError("unavailable"),
|
||||
):
|
||||
reply = MODULE.edge_router_reply(
|
||||
{"event_id": "event-1", "text": "@JWeBot #状态"},
|
||||
legacy_router=legacy,
|
||||
)
|
||||
|
||||
self.assertEqual(reply, "legacy")
|
||||
legacy.assert_called_once()
|
||||
|
||||
def test_non_hash_request_never_falls_back_to_legacy_router(self):
|
||||
legacy = mock.Mock(return_value="legacy")
|
||||
with mock.patch.object(
|
||||
MODULE.jwebot_edge,
|
||||
"request_edge",
|
||||
side_effect=MODULE.jwebot_edge.EdgeError("unavailable"),
|
||||
):
|
||||
reply = MODULE.edge_router_reply(
|
||||
{"event_id": "event-1", "text": "@JWeBot /丢骰子5d20"},
|
||||
legacy_router=legacy,
|
||||
)
|
||||
|
||||
self.assertIn("Local edge unavailable", reply)
|
||||
legacy.assert_not_called()
|
||||
|
||||
def test_gui_sender_serializes_concurrent_jobs(self):
|
||||
upstream = FakeGuiUpstream()
|
||||
bridge = FakeGuiBridge()
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
sender = MODULE.GuiSender(
|
||||
upstream,
|
||||
bridge,
|
||||
socket_path=Path(temporary) / "gui.sock",
|
||||
)
|
||||
sender.start()
|
||||
try:
|
||||
callbacks = []
|
||||
threads = [
|
||||
threading.Thread(
|
||||
target=sender.send_reply,
|
||||
args=(f"reply-{index}", "group-a"),
|
||||
kwargs={
|
||||
"event_id": f"event-{index}",
|
||||
"before_send": lambda i=index: callbacks.append(
|
||||
f"before-{i}"
|
||||
),
|
||||
"after_send": lambda i=index: callbacks.append(
|
||||
f"after-{i}"
|
||||
),
|
||||
"timeout_seconds": 2,
|
||||
},
|
||||
)
|
||||
for index in range(3)
|
||||
]
|
||||
for thread in threads:
|
||||
thread.start()
|
||||
for thread in threads:
|
||||
thread.join(timeout=2)
|
||||
|
||||
self.assertTrue(all(not thread.is_alive() for thread in threads))
|
||||
self.assertEqual(bridge.max_active, 1)
|
||||
self.assertCountEqual(
|
||||
bridge.calls,
|
||||
["reply-0", "reply-1", "reply-2"],
|
||||
)
|
||||
self.assertEqual(len(callbacks), 6)
|
||||
self.assertTrue(upstream.bridge_payloads)
|
||||
self.assertEqual(upstream.title_captures, 6)
|
||||
self.assertEqual(upstream.full_captures, 0)
|
||||
finally:
|
||||
sender.stop()
|
||||
|
||||
def test_gui_sender_prioritizes_new_ack_over_queued_final(self):
|
||||
calls = self.run_gui_priority_scenario(
|
||||
[
|
||||
("queued-final", "delivery-helper"),
|
||||
("newer-ack", "ack-local"),
|
||||
]
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
calls,
|
||||
["active-final", "newer-ack", "queued-final"],
|
||||
)
|
||||
|
||||
def test_gui_sender_prioritizes_local_reply_over_queued_final(self):
|
||||
calls = self.run_gui_priority_scenario(
|
||||
[
|
||||
("queued-final", "delivery-helper"),
|
||||
("newer-local", "ocr"),
|
||||
]
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
calls,
|
||||
["active-final", "newer-local", "queued-final"],
|
||||
)
|
||||
|
||||
def test_gui_sender_preserves_fifo_within_final_priority(self):
|
||||
calls = self.run_gui_priority_scenario(
|
||||
[
|
||||
("first-queued-final", "delivery-helper"),
|
||||
("second-queued-final", "delivery-helper"),
|
||||
]
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
calls,
|
||||
[
|
||||
"active-final",
|
||||
"first-queued-final",
|
||||
"second-queued-final",
|
||||
],
|
||||
)
|
||||
|
||||
def test_gui_sender_stop_sentinel_terminates_idle_worker(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
sender = MODULE.GuiSender(
|
||||
FakeGuiUpstream(),
|
||||
FakeGuiBridge(),
|
||||
socket_path=Path(temporary) / "gui.sock",
|
||||
)
|
||||
sender.start()
|
||||
sender.stop()
|
||||
|
||||
self.assertFalse(sender.worker.is_alive())
|
||||
self.assertFalse(sender.server.is_alive())
|
||||
self.assertEqual(sender.jobs.unfinished_tasks, 0)
|
||||
|
||||
def test_gui_sender_fails_closed_on_title_region_mismatch(self):
|
||||
upstream = FakeGuiUpstream()
|
||||
upstream.current_group = "other-group"
|
||||
bridge = FakeGuiBridge()
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
sender = MODULE.GuiSender(
|
||||
upstream,
|
||||
bridge,
|
||||
socket_path=Path(temporary) / "gui.sock",
|
||||
)
|
||||
sender.start()
|
||||
try:
|
||||
with self.assertRaisesRegex(RuntimeError, "GUI sender failed"):
|
||||
sender.send_reply(
|
||||
"reply",
|
||||
"group-a",
|
||||
event_id="event-1",
|
||||
timeout_seconds=2,
|
||||
)
|
||||
self.assertEqual(bridge.calls, [])
|
||||
self.assertEqual(upstream.title_captures, 1)
|
||||
self.assertEqual(upstream.full_captures, 0)
|
||||
finally:
|
||||
sender.stop()
|
||||
|
||||
def test_gui_sender_falls_back_to_full_window_group_checks(self):
|
||||
upstream = FakeGuiUpstream()
|
||||
upstream.capture_title_fingerprint = None
|
||||
bridge = FakeGuiBridge()
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
sender = MODULE.GuiSender(
|
||||
upstream,
|
||||
bridge,
|
||||
socket_path=Path(temporary) / "gui.sock",
|
||||
)
|
||||
sender.start()
|
||||
try:
|
||||
sender.send_reply(
|
||||
"reply",
|
||||
"group-a",
|
||||
event_id="event-1",
|
||||
timeout_seconds=2,
|
||||
)
|
||||
self.assertEqual(bridge.calls, ["reply"])
|
||||
self.assertEqual(upstream.title_captures, 0)
|
||||
self.assertEqual(upstream.full_captures, 2)
|
||||
finally:
|
||||
sender.stop()
|
||||
|
||||
def test_gui_sender_socket_submits_to_worker_queue(self):
|
||||
upstream = FakeGuiUpstream()
|
||||
bridge = FakeGuiBridge()
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
socket_path = Path(temporary) / "gui.sock"
|
||||
sender = MODULE.GuiSender(upstream, bridge, socket_path=socket_path)
|
||||
sender.start()
|
||||
try:
|
||||
deadline = time.monotonic() + 2
|
||||
while not socket_path.exists() and time.monotonic() < deadline:
|
||||
time.sleep(0.01)
|
||||
response = MODULE.submit_gui_sender_request(
|
||||
{
|
||||
"op": "send",
|
||||
"event_id": "event-1",
|
||||
"group_fingerprint": "group-a",
|
||||
"reply": "queued reply",
|
||||
"source": "delivery-helper",
|
||||
},
|
||||
socket_path=socket_path,
|
||||
timeout_seconds=2,
|
||||
)
|
||||
|
||||
self.assertTrue(response["ok"])
|
||||
self.assertEqual(response["state"], "sent")
|
||||
self.assertEqual(bridge.calls, ["queued reply"])
|
||||
finally:
|
||||
sender.stop()
|
||||
|
||||
def test_edge_request_telemetry_excludes_message_body(self):
|
||||
legacy = mock.Mock(return_value="legacy")
|
||||
telemetry = mock.Mock()
|
||||
with mock.patch.object(
|
||||
MODULE,
|
||||
"TELEMETRY",
|
||||
telemetry,
|
||||
), mock.patch.object(
|
||||
MODULE.jwebot_edge,
|
||||
"request_edge",
|
||||
return_value={
|
||||
"status": "reply",
|
||||
"route": "local",
|
||||
"text": "本地回复喵",
|
||||
},
|
||||
):
|
||||
MODULE.edge_router_reply(
|
||||
{
|
||||
"event_id": "event-1",
|
||||
"group_fingerprint": "group-a",
|
||||
"text": "@JWeBot 私密正文",
|
||||
},
|
||||
legacy_router=legacy,
|
||||
)
|
||||
|
||||
_, kwargs = telemetry.elapsed.call_args
|
||||
self.assertEqual(kwargs["route"], "local")
|
||||
self.assertNotIn("text", kwargs)
|
||||
self.assertNotIn("私密正文", str(kwargs))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,336 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import importlib.util
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
MODULE_PATH = PROJECT_ROOT / "wechat_health_watch.py"
|
||||
SPEC = importlib.util.spec_from_file_location("wechat_health_watch", MODULE_PATH)
|
||||
MODULE = importlib.util.module_from_spec(SPEC)
|
||||
assert SPEC.loader is not None
|
||||
sys.modules[SPEC.name] = MODULE
|
||||
SPEC.loader.exec_module(MODULE)
|
||||
|
||||
|
||||
class TransitionTests(unittest.TestCase):
|
||||
def test_incident_requires_two_consecutive_unhealthy_scans(self):
|
||||
state = {}
|
||||
|
||||
self.assertIsNone(
|
||||
MODULE.update_incident(
|
||||
state,
|
||||
codes=["recorder_stale"],
|
||||
now=100.0,
|
||||
)
|
||||
)
|
||||
self.assertEqual(state["wechat_chain"]["consecutive_failures"], 1)
|
||||
|
||||
self.assertEqual(
|
||||
MODULE.update_incident(
|
||||
state,
|
||||
codes=["recorder_stale"],
|
||||
now=160.0,
|
||||
),
|
||||
"incident",
|
||||
)
|
||||
self.assertTrue(state["wechat_chain"]["active"])
|
||||
self.assertTrue(state["wechat_chain"]["incident_pending"])
|
||||
|
||||
def test_healthy_scan_resets_the_consecutive_failure_counter(self):
|
||||
state = {"wechat_chain": {"consecutive_failures": 1}}
|
||||
|
||||
self.assertIsNone(MODULE.update_incident(state, codes=[], now=100.0))
|
||||
self.assertEqual(state["wechat_chain"]["consecutive_failures"], 0)
|
||||
self.assertIsNone(
|
||||
MODULE.update_incident(
|
||||
state,
|
||||
codes=["wechat_window"],
|
||||
now=160.0,
|
||||
)
|
||||
)
|
||||
|
||||
def test_recovery_is_immediate_after_a_sent_incident(self):
|
||||
state = {
|
||||
"wechat_chain": {
|
||||
"active": True,
|
||||
"incident_sent": True,
|
||||
"incident_last_notification_attempt_at": 100.0,
|
||||
"consecutive_failures": 2,
|
||||
}
|
||||
}
|
||||
|
||||
self.assertEqual(
|
||||
MODULE.update_incident(state, codes=[], now=101.0),
|
||||
"recovery",
|
||||
)
|
||||
|
||||
def test_failed_incident_notification_retries_after_cooldown(self):
|
||||
state = {
|
||||
"wechat_chain": {
|
||||
"active": True,
|
||||
"incident_pending": True,
|
||||
"incident_last_notification_attempt_at": 100.0,
|
||||
}
|
||||
}
|
||||
|
||||
self.assertIsNone(
|
||||
MODULE.update_incident(
|
||||
state,
|
||||
codes=["recorder_stale"],
|
||||
now=100.0 + MODULE.NOTIFICATION_COOLDOWN_SECONDS - 1,
|
||||
)
|
||||
)
|
||||
self.assertEqual(
|
||||
MODULE.update_incident(
|
||||
state,
|
||||
codes=["recorder_stale"],
|
||||
now=100.0 + MODULE.NOTIFICATION_COOLDOWN_SECONDS,
|
||||
),
|
||||
"incident",
|
||||
)
|
||||
|
||||
|
||||
class RecorderStateTests(unittest.TestCase):
|
||||
def test_missing_recorder_state_is_unhealthy(self):
|
||||
with tempfile.TemporaryDirectory() as directory, mock.patch.object(
|
||||
MODULE,
|
||||
"RECORDER_STATE",
|
||||
Path(directory) / "missing.json",
|
||||
):
|
||||
self.assertEqual(
|
||||
MODULE.recorder_is_fresh(100.0),
|
||||
(False, "recorder_state"),
|
||||
)
|
||||
|
||||
def test_stale_recorder_state_is_unhealthy(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
state_path = Path(directory) / "recorder-state.json"
|
||||
state_path.write_text(
|
||||
'{"last_capture_at":"1970-01-01T00:00:00+00:00"}',
|
||||
encoding="utf-8",
|
||||
)
|
||||
with mock.patch.object(MODULE, "RECORDER_STATE", state_path):
|
||||
self.assertEqual(
|
||||
MODULE.recorder_is_fresh(
|
||||
MODULE.RECORDER_FRESHNESS_SECONDS + 1
|
||||
),
|
||||
(False, "recorder_stale"),
|
||||
)
|
||||
|
||||
def test_fresh_recorder_state_is_healthy(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
state_path = Path(directory) / "recorder-state.json"
|
||||
state_path.write_text(
|
||||
'{"last_capture_at":"1970-01-01T00:00:00+00:00"}',
|
||||
encoding="utf-8",
|
||||
)
|
||||
with mock.patch.object(MODULE, "RECORDER_STATE", state_path):
|
||||
self.assertEqual(
|
||||
MODULE.recorder_is_fresh(
|
||||
MODULE.RECORDER_FRESHNESS_SECONDS
|
||||
),
|
||||
(True, ""),
|
||||
)
|
||||
|
||||
|
||||
class HealthCodeTests(unittest.TestCase):
|
||||
def test_login_sized_window_is_not_accepted_as_ready(self):
|
||||
with mock.patch.object(
|
||||
MODULE,
|
||||
"is_active",
|
||||
return_value=True,
|
||||
), mock.patch.object(
|
||||
MODULE,
|
||||
"is_user_active",
|
||||
return_value=True,
|
||||
), mock.patch.object(
|
||||
MODULE,
|
||||
"wechat_process_present",
|
||||
return_value=True,
|
||||
), mock.patch.object(
|
||||
MODULE,
|
||||
"weixin_window_geometry",
|
||||
return_value=(280, 380),
|
||||
), mock.patch.object(
|
||||
MODULE,
|
||||
"recorder_is_fresh",
|
||||
return_value=(True, ""),
|
||||
), mock.patch.object(
|
||||
MODULE,
|
||||
"has_unacknowledged_failed_delivery",
|
||||
return_value=False,
|
||||
):
|
||||
self.assertEqual(
|
||||
MODULE.health_codes(100.0),
|
||||
["wechat_login_required"],
|
||||
)
|
||||
|
||||
def test_missing_window_is_reported(self):
|
||||
with mock.patch.object(
|
||||
MODULE,
|
||||
"is_active",
|
||||
return_value=True,
|
||||
), mock.patch.object(
|
||||
MODULE,
|
||||
"is_user_active",
|
||||
return_value=True,
|
||||
), mock.patch.object(
|
||||
MODULE,
|
||||
"wechat_process_present",
|
||||
return_value=True,
|
||||
), mock.patch.object(
|
||||
MODULE,
|
||||
"weixin_window_geometry",
|
||||
return_value=None,
|
||||
), mock.patch.object(
|
||||
MODULE,
|
||||
"recorder_is_fresh",
|
||||
return_value=(True, ""),
|
||||
), mock.patch.object(
|
||||
MODULE,
|
||||
"has_unacknowledged_failed_delivery",
|
||||
return_value=False,
|
||||
):
|
||||
self.assertEqual(MODULE.health_codes(100.0), ["wechat_window"])
|
||||
|
||||
def test_failed_reply_is_reported_as_an_incident(self):
|
||||
with mock.patch.object(
|
||||
MODULE,
|
||||
"is_active",
|
||||
return_value=True,
|
||||
), mock.patch.object(
|
||||
MODULE,
|
||||
"is_user_active",
|
||||
return_value=True,
|
||||
), mock.patch.object(
|
||||
MODULE,
|
||||
"wechat_process_present",
|
||||
return_value=True,
|
||||
), mock.patch.object(
|
||||
MODULE,
|
||||
"weixin_window_geometry",
|
||||
return_value=(1470, 694),
|
||||
), mock.patch.object(
|
||||
MODULE,
|
||||
"recorder_is_fresh",
|
||||
return_value=(True, ""),
|
||||
), mock.patch.object(
|
||||
MODULE,
|
||||
"has_unacknowledged_failed_delivery",
|
||||
return_value=True,
|
||||
):
|
||||
self.assertEqual(MODULE.health_codes(100.0), ["responder_failed"])
|
||||
|
||||
|
||||
class ConfigurationTests(unittest.TestCase):
|
||||
def test_notification_adapter_is_loaded_from_generated_config(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
config_file = Path(directory) / "notification.json"
|
||||
config_file.write_text(
|
||||
'{"enabled":true,"command":["/usr/bin/true"],'
|
||||
'"timeout_seconds":15}',
|
||||
encoding="utf-8",
|
||||
)
|
||||
with mock.patch.object(
|
||||
MODULE,
|
||||
"NOTIFICATION_CONFIG",
|
||||
config_file,
|
||||
):
|
||||
self.assertEqual(
|
||||
MODULE.notification_config(),
|
||||
{
|
||||
"enabled": True,
|
||||
"command": ["/usr/bin/true"],
|
||||
"timeout_seconds": 15,
|
||||
},
|
||||
)
|
||||
|
||||
def test_notification_message_is_sent_on_stdin_without_shell(self):
|
||||
with mock.patch.object(
|
||||
MODULE,
|
||||
"notification_config",
|
||||
return_value={
|
||||
"enabled": True,
|
||||
"command": ["/usr/local/bin/notifier", "--json"],
|
||||
"timeout_seconds": 20,
|
||||
},
|
||||
), mock.patch.object(
|
||||
MODULE,
|
||||
"run",
|
||||
return_value=subprocess.CompletedProcess([], 0, "", ""),
|
||||
) as run:
|
||||
MODULE.send_official_notification("health message")
|
||||
|
||||
run.assert_called_once_with(
|
||||
["/usr/local/bin/notifier", "--json"],
|
||||
timeout=20,
|
||||
data="health message",
|
||||
)
|
||||
|
||||
|
||||
class NotificationFailureTests(unittest.TestCase):
|
||||
def test_timeout_is_accepted_as_an_uncertain_incident(self):
|
||||
state = {"wechat_chain": {"incident_pending": True}}
|
||||
with mock.patch.object(
|
||||
MODULE,
|
||||
"send_official_notification",
|
||||
side_effect=MODULE.NotificationDeliveryUncertain("delivery_timeout"),
|
||||
):
|
||||
self.assertTrue(
|
||||
MODULE.finish_notification(
|
||||
state,
|
||||
"incident",
|
||||
codes=["wechat_window"],
|
||||
start_result="not_needed",
|
||||
now=100.0,
|
||||
dry_run=False,
|
||||
)
|
||||
)
|
||||
self.assertEqual(state["wechat_chain"]["last_notification_result"], "uncertain")
|
||||
self.assertFalse(state["wechat_chain"]["incident_pending"])
|
||||
self.assertTrue(state["wechat_chain"]["incident_sent"])
|
||||
|
||||
def test_remote_error_code_has_priority(self):
|
||||
result = subprocess.CompletedProcess(
|
||||
[],
|
||||
75,
|
||||
"",
|
||||
"delivery_error=provider_context_rejected",
|
||||
)
|
||||
self.assertEqual(
|
||||
MODULE.notification_failure_code(result),
|
||||
"provider_context_rejected",
|
||||
)
|
||||
|
||||
def test_prepare_failure_is_sanitized(self):
|
||||
result = subprocess.CompletedProcess(
|
||||
[],
|
||||
1,
|
||||
"",
|
||||
"sendMessage ret=-2 errmsg=prepare failed",
|
||||
)
|
||||
self.assertEqual(
|
||||
MODULE.notification_failure_code(result),
|
||||
"provider_prepare_failed",
|
||||
)
|
||||
|
||||
def test_context_failure_is_sanitized(self):
|
||||
result = subprocess.CompletedProcess(
|
||||
[],
|
||||
1,
|
||||
"",
|
||||
"context_token expired",
|
||||
)
|
||||
self.assertEqual(
|
||||
MODULE.notification_failure_code(result),
|
||||
"provider_context_rejected",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Executable
+1231
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user