365 lines
15 KiB
Python
365 lines
15 KiB
Python
"""常驻监听网站闭环端到端测试。
|
||
|
||
上一轮只验到注入假抓取的单测:真实抓取、真实 run_loop 轮次
|
||
边界、经服务端 API 的全链路(创建 -> 基线 -> 变化建任务 ->
|
||
暂停 -> 恢复 -> 失败退避 -> 结束)都没有跑过。本文件补这一环,
|
||
不改任何生产默认值、不改生产校验口径:
|
||
|
||
- 间隔仍是生产下限 60 秒;到期不是改间隔,而是给驱动传一个
|
||
更晚的 now(模拟时间流逝),与上一轮单测同款时间注入。
|
||
- 目标 URL 仍走生产校验:主机名 website-loop.test 不是 IP 字
|
||
面、不是 localhost,所以校验通过;测试进程内只把这个主机
|
||
名的 DNS 解析指向本地测试 HTTP 服务(相当于 /etc/hosts 的
|
||
一条测试条目),抓取本身走真实 default_fetch(urllib、超时、
|
||
1MB 截断),不注入假 fetcher、不绕过生产代码。
|
||
- 内容服务是线程内真实 http.server,可变内容、也可停服;
|
||
控制面是真实 ThreadingHTTPServer + 真实 SQLite 临时库;
|
||
变化建出的任务、轮询、审计都直接读库/接口核对。
|
||
|
||
不联网、不跑真实模型、不碰生产。
|
||
"""
|
||
import http.client
|
||
import http.server
|
||
import json
|
||
import os
|
||
import socket
|
||
import sqlite3
|
||
import sys
|
||
import tempfile
|
||
import threading
|
||
import time
|
||
import unittest
|
||
from pathlib import Path
|
||
from types import SimpleNamespace
|
||
|
||
try:
|
||
from cloud.auth import AuthStore
|
||
from cloud.resident import _content_hash
|
||
from cloud.server import create_server
|
||
from cloud.worker import Worker
|
||
except ImportError: # discover -s cloud/tests 时把项目根目录补进 sys.path
|
||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||
from cloud.auth import AuthStore
|
||
from cloud.resident import _content_hash
|
||
from cloud.server import create_server
|
||
from cloud.worker import Worker
|
||
|
||
HOST = "website-loop.test"
|
||
|
||
|
||
class WebsiteLoopTestCase(unittest.TestCase):
|
||
def setUp(self):
|
||
self.tmp = tempfile.TemporaryDirectory()
|
||
self.db_path = os.path.join(self.tmp.name, "control.db")
|
||
self.auth = AuthStore(self.db_path)
|
||
self.owner_id, self.owner_key = self.auth.create_user("owner")
|
||
self.server = create_server(self.db_path, "127.0.0.1", 0)
|
||
self.port = self.server.server_address[1]
|
||
self.thread = threading.Thread(
|
||
target=self.server.serve_forever, daemon=True)
|
||
self.thread.start()
|
||
status, self.project = self._req(
|
||
"POST", "/projects", {"name": "demo"}, self.owner_key)
|
||
self.assertEqual(status, 201, self.project)
|
||
# 可变内容服务:state 字典跨重启保留,停服/重启可复现。
|
||
self.state = {"content": "version one"}
|
||
state = self.state
|
||
|
||
class Handler(http.server.BaseHTTPRequestHandler):
|
||
def do_GET(self):
|
||
body = state["content"].encode("utf-8")
|
||
self.send_response(200)
|
||
self.send_header("Content-Type", "text/plain")
|
||
self.send_header("Content-Length", str(len(body)))
|
||
self.end_headers()
|
||
self.wfile.write(body)
|
||
|
||
def log_message(self, *args):
|
||
pass
|
||
|
||
self._handler = Handler
|
||
self.content_server = http.server.ThreadingHTTPServer(
|
||
("127.0.0.1", 0), Handler)
|
||
self.content_port = self.content_server.server_address[1]
|
||
self.content_thread = threading.Thread(
|
||
target=self.content_server.serve_forever, daemon=True)
|
||
self.content_thread.start()
|
||
# 测试专用 DNS:只把 website-loop.test 指到本地服务,
|
||
# 其它主机一律走原始解析;生产校验与抓取代码一字不改。
|
||
self._orig_getaddrinfo = socket.getaddrinfo
|
||
|
||
def patched(host, port, family=0, type=0, proto=0, flags=0):
|
||
if host == HOST:
|
||
return [(socket.AF_INET, socket.SOCK_STREAM, 6, "",
|
||
("127.0.0.1", port))]
|
||
return self._orig_getaddrinfo(
|
||
host, port, family, type, proto, flags)
|
||
|
||
socket.getaddrinfo = patched
|
||
# 测试环境自带 egress 代理:urllib 默认会把这个测试主机名
|
||
# 也送代理并被断开。只给本测试主机名加 no_proxy(测试
|
||
# 进程内生效),生产抓取代码与代理行为一字不改。
|
||
self._old_no_proxy = os.environ.get("no_proxy")
|
||
self._old_NO_PROXY = os.environ.get("NO_PROXY")
|
||
os.environ["no_proxy"] = (
|
||
(self._old_no_proxy + ",") if self._old_no_proxy
|
||
else "") + HOST
|
||
os.environ["NO_PROXY"] = (
|
||
(self._old_NO_PROXY + ",") if self._old_NO_PROXY
|
||
else "") + HOST
|
||
self.worker = Worker(
|
||
self.db_path, "/fake/host.py",
|
||
env={"XCM_API_KEY": "test-placeholder"},
|
||
runner=lambda cmd: SimpleNamespace(
|
||
returncode=0, stdout="{}", stderr=""))
|
||
|
||
def tearDown(self):
|
||
socket.getaddrinfo = self._orig_getaddrinfo
|
||
if self._old_no_proxy is None:
|
||
os.environ.pop("no_proxy", None)
|
||
else:
|
||
os.environ["no_proxy"] = self._old_no_proxy
|
||
if self._old_NO_PROXY is None:
|
||
os.environ.pop("NO_PROXY", None)
|
||
else:
|
||
os.environ["NO_PROXY"] = self._old_NO_PROXY
|
||
try:
|
||
self.content_server.shutdown()
|
||
self.content_server.server_close()
|
||
except Exception:
|
||
pass
|
||
self.content_thread.join(timeout=5)
|
||
self.server.shutdown()
|
||
self.server.server_close()
|
||
self.thread.join(timeout=5)
|
||
try:
|
||
self.auth.conn.close()
|
||
except Exception:
|
||
pass
|
||
self.tmp.cleanup()
|
||
|
||
def _req(self, method, path, body=None, key=None):
|
||
conn = http.client.HTTPConnection("127.0.0.1", self.port,
|
||
timeout=10)
|
||
hdrs = {}
|
||
payload = None
|
||
if body is not None:
|
||
payload = json.dumps(body).encode("utf-8")
|
||
hdrs["Content-Type"] = "application/json"
|
||
if key is not None:
|
||
hdrs["X-API-Key"] = key
|
||
try:
|
||
conn.request(method, path, body=payload, headers=hdrs)
|
||
resp = conn.getresponse()
|
||
data = resp.read()
|
||
finally:
|
||
conn.close()
|
||
try:
|
||
parsed = json.loads(data.decode("utf-8")) if data else None
|
||
except ValueError:
|
||
parsed = None
|
||
return resp.status, parsed
|
||
|
||
def _url(self, suffix=""):
|
||
return "/projects/%s/resident-watches%s" % (
|
||
self.project["id"], suffix)
|
||
|
||
def _create_watch(self):
|
||
body = {"name": "site1",
|
||
"target_url": "http://%s:%d/data" % (
|
||
HOST, self.content_port),
|
||
"prompt": "变化时汇报",
|
||
"interval_seconds": 60}
|
||
status, watch = self._req("POST", self._url(), body,
|
||
self.owner_key)
|
||
self.assertEqual(status, 201, watch)
|
||
return watch
|
||
|
||
def _get_watch(self, watch_id):
|
||
status, body = self._req(
|
||
"GET", self._url("/" + watch_id), None, self.owner_key)
|
||
self.assertEqual(status, 200, body)
|
||
return body
|
||
|
||
def _tasks(self):
|
||
conn = sqlite3.connect(self.db_path)
|
||
try:
|
||
return conn.execute(
|
||
"SELECT id, prompt, status FROM tasks"
|
||
" ORDER BY created_at, id").fetchall()
|
||
finally:
|
||
conn.close()
|
||
|
||
def _audit(self):
|
||
conn = sqlite3.connect(self.db_path)
|
||
try:
|
||
return [r[0] for r in conn.execute(
|
||
"SELECT action FROM audit_log WHERE project_id = ?",
|
||
(self.project["id"],)).fetchall()]
|
||
finally:
|
||
conn.close()
|
||
|
||
def test_full_website_loop_end_to_end(self):
|
||
# 1. 创建:经服务端 API,running、游标为空、下次轮询在位。
|
||
watch = self._create_watch()
|
||
wid = watch["id"]
|
||
self.assertEqual(watch["status"], "running")
|
||
self.assertEqual(watch["interval_seconds"], 60)
|
||
self.assertEqual(watch["fetch_timeout_seconds"], 10)
|
||
self.assertIsNone(watch["last_content_hash"])
|
||
self.assertIsNotNone(watch["next_poll_at"])
|
||
self.assertIn("resident_watch_created", self._audit())
|
||
t0 = time.time()
|
||
|
||
# 2. 基线:真实抓取 version one,只建游标,不建任务。
|
||
polls = self.worker.process_due_resident_watches(now=t0 + 120)
|
||
self.assertEqual(len(polls), 1, polls)
|
||
self.assertEqual(polls[0]["status"], "baseline")
|
||
self.assertEqual(
|
||
polls[0]["observed_hash"], _content_hash("version one"))
|
||
self.assertEqual(self._tasks(), [])
|
||
body = self._get_watch(wid)
|
||
self.assertEqual(body["last_content_hash"],
|
||
_content_hash("version one"))
|
||
self.assertEqual(len(body["polls"]), 1)
|
||
self.assertEqual(body["polls"][0]["status"], "baseline")
|
||
|
||
# 3. 变化:内容改成 version two,到期轮询建出 queued 任务。
|
||
self.state["content"] = "version two"
|
||
polls = self.worker.process_due_resident_watches(now=t0 + 240)
|
||
self.assertEqual(len(polls), 1, polls)
|
||
self.assertEqual(polls[0]["status"], "changed")
|
||
self.assertIsNotNone(polls[0]["task_id"])
|
||
tasks = self._tasks()
|
||
self.assertEqual(len(tasks), 1)
|
||
self.assertEqual(tasks[0][0], polls[0]["task_id"])
|
||
self.assertEqual(tasks[0][2], "queued")
|
||
self.assertIn("变化时汇报", tasks[0][1])
|
||
self.assertIn("version two", tasks[0][1])
|
||
self.assertIn("resident_watch_change_task_created",
|
||
self._audit())
|
||
body = self._get_watch(wid)
|
||
self.assertEqual(body["last_content_hash"],
|
||
_content_hash("version two"))
|
||
self.assertIn("version two", body["last_change_text"])
|
||
|
||
# 4. 暂停:暂停期间再改内容,驱动不轮询、游标不动。
|
||
status, body = self._req(
|
||
"POST", self._url("/%s/pause" % wid), {}, self.owner_key)
|
||
self.assertEqual(status, 200, body)
|
||
self.assertEqual(body["status"], "paused")
|
||
self.assertIsNone(body["next_poll_at"])
|
||
self.state["content"] = "version three"
|
||
polls = self.worker.process_due_resident_watches(now=t0 + 400)
|
||
self.assertEqual(polls, [])
|
||
body = self._get_watch(wid)
|
||
self.assertEqual(len(body["polls"]), 2)
|
||
self.assertEqual(body["last_content_hash"],
|
||
_content_hash("version two"))
|
||
|
||
# 5. 恢复:用旧游标比对,报出暂停期间的变化并建任务,
|
||
# 不是从头建基线。
|
||
status, body = self._req(
|
||
"POST", self._url("/%s/resume" % wid), {}, self.owner_key)
|
||
self.assertEqual(status, 200, body)
|
||
self.assertEqual(body["status"], "running")
|
||
polls = self.worker.process_due_resident_watches(now=t0 + 400)
|
||
self.assertEqual(len(polls), 1, polls)
|
||
self.assertEqual(polls[0]["status"], "changed")
|
||
tasks = self._tasks()
|
||
self.assertEqual(len(tasks), 2)
|
||
self.assertIn("version three", tasks[1][1])
|
||
body = self._get_watch(wid)
|
||
self.assertEqual(body["last_content_hash"],
|
||
_content_hash("version three"))
|
||
|
||
# 6. 失败:停服后轮询记 failed、退避、不结束、游标不动;
|
||
# 恢复服务后内容未变,轮询记 unchanged、失败清零。
|
||
self.content_server.shutdown()
|
||
self.content_server.server_close()
|
||
self.content_thread.join(timeout=5)
|
||
polls = self.worker.process_due_resident_watches(now=t0 + 600)
|
||
self.assertEqual(len(polls), 1, polls)
|
||
self.assertEqual(polls[0]["status"], "failed")
|
||
body = self._get_watch(wid)
|
||
self.assertEqual(body["status"], "running")
|
||
self.assertEqual(body["consecutive_failures"], 1)
|
||
self.assertEqual(body["last_content_hash"],
|
||
_content_hash("version three"))
|
||
self.assertGreater(body["next_poll_at"], t0 + 600 + 60)
|
||
self.content_server = http.server.ThreadingHTTPServer(
|
||
("127.0.0.1", self.content_port), self._handler)
|
||
self.content_thread = threading.Thread(
|
||
target=self.content_server.serve_forever, daemon=True)
|
||
self.content_thread.start()
|
||
polls = self.worker.process_due_resident_watches(now=t0 + 800)
|
||
self.assertEqual(len(polls), 1, polls)
|
||
self.assertEqual(polls[0]["status"], "unchanged")
|
||
body = self._get_watch(wid)
|
||
self.assertEqual(body["consecutive_failures"], 0)
|
||
self.assertEqual(len(self._tasks()), 2)
|
||
|
||
# 7. 结束:终态,驱动不再轮询,改/停/恢复一律 409。
|
||
status, body = self._req(
|
||
"POST", self._url("/%s/end" % wid), {}, self.owner_key)
|
||
self.assertEqual(status, 200, body)
|
||
self.assertEqual(body["status"], "ended")
|
||
self.assertIsNone(body["next_poll_at"])
|
||
self.assertEqual(body["last_content_hash"],
|
||
_content_hash("version three"))
|
||
polls = self.worker.process_due_resident_watches(now=t0 + 1000)
|
||
self.assertEqual(polls, [])
|
||
for action in ("pause", "resume", "end"):
|
||
status, body = self._req(
|
||
"POST", self._url("/%s/%s" % (wid, action)), {},
|
||
self.owner_key)
|
||
self.assertEqual(status, 409, action)
|
||
status, _ = self._req(
|
||
"PATCH", self._url("/" + wid), {"interval_seconds": 120},
|
||
self.owner_key)
|
||
self.assertEqual(status, 409)
|
||
body = self._get_watch(wid)
|
||
self.assertEqual(body["status"], "ended")
|
||
self.assertEqual(len(body["polls"]), 5)
|
||
|
||
def test_run_loop_boundary_calls_resident_driver(self):
|
||
# 8. 驱动接线:run_loop 轮次边界确实调到常驻驱动,
|
||
# 不只是测试直接调 process_due。run_all 等无关边界
|
||
# 在本测试里打桩,只留 resident 驱动走真实抓取。
|
||
watch = self._create_watch()
|
||
wid = watch["id"]
|
||
worker = self.worker
|
||
calls = []
|
||
original = Worker.process_due_resident_watches
|
||
|
||
def recording():
|
||
# 实例属性上的函数不会自动绑定 self,必须闭包捕获
|
||
# worker,不能写成 def recording(self)。
|
||
calls.append(time.time())
|
||
return original(worker)
|
||
|
||
worker.process_due_resident_watches = recording
|
||
worker.run_all = lambda: []
|
||
worker.cleanup_stale_containers = lambda: None
|
||
worker._write_heartbeat = lambda: None
|
||
worker.register = lambda: None
|
||
worker._process_push_due = lambda: None
|
||
worker.process_auto_memories = lambda: None
|
||
worker._reap_kill_requests = lambda *a, **k: None
|
||
worker.auto_resume_interrupted = lambda: None
|
||
worker._heartbeat_running_tasks = lambda: None
|
||
worker._registry_heartbeat = lambda: None
|
||
worker._code_changed = lambda: False
|
||
worker._code_signature_valid = lambda: True
|
||
worker.run_loop(interval=0.05, max_seconds=0.4)
|
||
self.assertGreaterEqual(len(calls), 1)
|
||
body = self._get_watch(wid)
|
||
self.assertEqual(len(body["polls"]), 1)
|
||
self.assertEqual(body["polls"][0]["status"], "baseline")
|
||
self.assertEqual(body["last_content_hash"],
|
||
_content_hash("version one"))
|
||
|
||
|
||
if __name__ == "__main__":
|
||
unittest.main()
|