assistant/apps/server/tests/test_learning_extensions.py

242 lines
15 KiB
Python

"""Isolated API contracts. Browser actions here use FakeWorker, not a platform."""
from __future__ import annotations
import copy
import sqlite3
from datetime import datetime, timedelta, timezone
import pytest
from fastapi import HTTPException
from fastapi.testclient import TestClient
from apps.server.db import StoreConflict, digest, encoded, identifier, now
from apps.server.learning_checks import output_assertion_checks
from apps.server.main import create_app
from apps.server.tests.test_learning import FakeWorker, client, initialize, working_candidate
from apps.server.tests.test_postgres import pg_client
def extract_output(text="建材评论", *, href="http://127.0.0.1/demo"):
return {"items": [{"body": text, "link": href}], "count": 1, "truncated": False, "source_url": "http://127.0.0.1/demo", "coverage": "bounded"}
EXTRACT = {"kind": "extract", "selector": '[data-testid="result"]', "output_name": "structured", "limit": 2,
"fields": [{"name": "body", "source": "text", "required": True}, {"name": "link", "selector": "a", "source": "href"}]}
def test_action_budgets_nested_fields_and_parameterized_target(client):
initialize(client)
task, _ = working_candidate(client)
prefix = "/api/learning/tasks/" + task["id"]
worker = client.worker_reference
original = worker.call
async def structured_call(method, params):
result = await original(method, params)
if method == "action" and params["kind"] == "extract":
result["result"] = extract_output()
return result
worker.call = structured_call
actions = [{"kind": "scroll", "selector": "body", "pixels": 600, "max_scrolls": 2},
{"kind": "wait", "selector": "body", "state": "visible", "timeout_ms": 2000},
{**EXTRACT, "target": {"field": "text", "parameter": "keyword"}}]
for action in actions:
response = client.post(prefix + "/browser/action", json=action)
assert response.status_code == 200, response.text
wire = worker.calls[-1][1]
assert wire["target"] == {"field": "text", "value": "建材"}
candidate = client.post(prefix + "/candidates", json={}).json()
assert candidate["manifest"]["schema_version"] == 2
assert candidate["manifest"]["steps"][-1]["target"] == {"field": "text", "parameter": "keyword"}
assert client.post("/api/learning/candidates/" + candidate["id"] + "/check").json()["status"] == "passed"
for bad in [
{"kind": "scroll", "selector": "body", "max_scrolls": 11},
{"kind": "scroll", "selector": "body", "pixels": True},
{"kind": "wait", "selector": "body", "state": "text"},
{"kind": "wait", "selector": "body", "timeout_ms": 15001},
{**EXTRACT, "limit": 101},
{**EXTRACT, "fields": [{"name": "bad", "source": "attribute", "attribute": "data-token"}]},
{**EXTRACT, "fields": [{"name": "constructor", "source": "text"}]},
{**EXTRACT, "fields": [{"name": "bad", "selector": "xpath=//input", "source": "text"}]},
{**EXTRACT, "fields": [{"name": "bad", "selector": "body", "source": "text"}]},
{**EXTRACT, "fields": [{"name": "bad", "source": "attribute", "attribute": "data-1bad"}]},
{**EXTRACT, "fields": [{"name": "bad", "source": "text", "required": "true"}]},
{**EXTRACT, "target": {"field": "text", "value": " "}},
{"kind": "wait", "selector": "body", "state": "text", "text": " "},
{"kind": "scroll", "selector": "body", "target": {"field": "text", "value": "unexpected"}},
{**EXTRACT, "target": {"field": "attribute", "attribute": "value", "value": "secret"}},
{**EXTRACT, "target": {"field": "text", "parameter": "missing"}},
{**EXTRACT, "target": {"field": "text", "parameter": "keyword", "value": "ambiguous"}},
{"kind": "extract", "selector": "body", "output_name": "all", "fields": [{"name": "body", "source": "text"}]},
]:
result = client.post(prefix + "/browser/action", json=bad)
assert result.status_code == 422, result.text
def test_hidden_wait_freezes_prior_same_task_selector_evidence(client):
initialize(client)
task, _ = working_candidate(client)
prefix = "/api/learning/tasks/" + task["id"]
worker = client.worker_reference
original = worker.capture
def without_results(task_id):
result = original(task_id)
result["elements"] = [item for item in result["elements"] if item["selector"] != '[data-testid="result"]']
return result
worker.capture = without_results
client.post(prefix + "/browser/capture", json={})
result = client.post(prefix + "/browser/action", json={"kind": "wait", "selector": '[data-testid="result"]', "state": "hidden"})
assert result.status_code == 200, result.text
assert result.json()["step"]["selector_evidence_id"]
assert client.post(prefix + "/browser/action", json={"kind": "wait", "selector": "#never-seen", "state": "hidden"}).status_code == 422
candidate = client.post(prefix + "/candidates", json={}).json()
assert client.post("/api/learning/candidates/" + candidate["id"] + "/check").json()["status"] == "passed"
def test_runner_budget_and_invalid_recording_output_do_not_freeze_success(client, monkeypatch):
initialize(client)
task, candidate = working_candidate(client)
prefix = "/api/learning/tasks/" + task["id"]
service = client.app_reference.state.learning
result = client.post(prefix + "/browser/action", json=EXTRACT)
assert result.status_code == 409, "FakeWorker intentionally returns an invalid extract shape"
assert len(service.steps(task["id"])) == 3
changed = copy.deepcopy(candidate)
changed["manifest"]["steps"] = candidate["manifest"]["steps"] * 11
assert any("步骤预算" in error for error in service.static_errors(changed))
monkeypatch.setattr(service, "steps", lambda task_id: [{}] * 30)
assert client.post(prefix + "/browser/action", json={"kind": "scroll", "selector": "body"}).status_code == 409
def test_structured_output_assertions_check_fields_counts_and_exclude_urls():
contracts = {"structured": EXTRACT}
good = extract_output()
assert all(item["passed"] for item in output_assertion_checks({"structured": good}, {"structured": "建材"}, contracts))
for changed in [
{**good, "count": 2}, {**good, "truncated": "false"},
{**good, "items": [{"body": None, "link": "http://127.0.0.1/"}]},
{**good, "items": [{"body": "建材", "link": None, "undeclared": "extra"}]},
{**good, "coverage": "empty"},
]:
checks = output_assertion_checks({"structured": changed}, {"structured": "建材"}, contracts)
assert not next(item for item in checks if item["name"].startswith("output_schema"))["passed"]
only_link = extract_output("无关文本", href="http://127.0.0.1/?q=建材")
assert not all(item["passed"] for item in output_assertion_checks({"structured": only_link}, {"structured": "建材"}, contracts))
clipped = {**good, "truncated": True}
assert all(item["passed"] for item in output_assertion_checks({"structured": clipped}, {"structured": "建材"}, contracts))
empty = {"items": [], "count": 0, "truncated": True, "source_url": good["source_url"], "coverage": "empty"}
checks = output_assertion_checks({"structured": empty}, {"structured": "建材"}, contracts)
assert next(item for item in checks if item["name"].startswith("output_schema"))["passed"]
assert not all(item["passed"] for item in checks), "empty is a valid shape but not a successful text assertion"
def test_legacy_v1_frozen_candidate_still_checks_and_replays(client):
initialize(client)
task, current = working_candidate(client)
legacy = copy.deepcopy(current["manifest"])
legacy["schema_version"] = 1
candidate_id = identifier("candidate")
client.app_reference.state.store.execute("INSERT INTO candidates(id,task_id,name,version,digest,manifest_json,created_at) VALUES($1,$2,$3,$4,$5,$6,$7)", (candidate_id, task["id"], "历史 V1", 2, digest(legacy), encoded(legacy), now()))
prefix = "/api/learning/candidates/" + candidate_id
assert client.post(prefix + "/check").json()["status"] == "passed"
response = client.post(prefix + "/verify", json={"expected_outputs": {"results": "建材"}})
assert response.status_code == 200 and response.json()["status"] == "passed"
assert client.worker_reference.calls[-1][1]["schema_version"] == 1
assert client.get(prefix).json()["manifest"] == legacy
@pytest.fixture(params=["client", "pg_client"])
def quality_client(request):
return request.getfixturevalue(request.param)
def test_quality_deadlines_late_feedback_recovery_immutability_and_restart(quality_client, monkeypatch):
client = quality_client
initialize(client)
task, candidate = working_candidate(client)
prefix = "/api/learning/candidates/" + candidate["id"]
client.post(prefix + "/check")
clock = [datetime(2026, 10, 24, 1, 0, tzinfo=timezone.utc)]
monkeypatch.setattr("apps.server.learning.now", lambda: clock[0].isoformat(timespec="milliseconds"))
payload = {"expected_outputs": {"results": "建材"}}
exploration = client.post(prefix + "/verify", json=payload).json()
assert exploration["mode"] == "exploration" and exploration["observation_due_at"] is None
clear = {"state": "clear", "note": "人工核对通知与功能", "process_review": "passed", "source": "manual"}
def append(record, data):
return client.post("/api/learning/validations/" + record["id"] + "/quality-observations", json=data)
assert append(exploration, clear).status_code == 409
assert client.post(prefix + "/verify", json={**payload, "mode": "runtime"}).status_code == 422
runtime_input = {**payload, "mode": "runtime", "baseline_note": "人工检查官方消息及功能,开始前无已知异常", "observation_window_minutes": 1}
runtime = client.post(prefix + "/verify", json=runtime_input).json()
assert runtime["quality"]["state"] == "awaiting_observation"
assert datetime.fromisoformat(runtime["observation_due_at"]) == clock[0] + timedelta(minutes=1)
assert append(runtime, clear).status_code == 409
assert append(runtime, {**clear, "state": "waiting", "note": "等待窗口到期", "process_review": "not_reviewed"}).status_code == 201
assert append(runtime, {**clear, "occurred_at": "2026-10-24T01:00:00"}).status_code == 422
assert append(runtime, {**clear, "occurred_at": "2099-01-01T00:00:00Z"}).status_code == 422
clock[0] += timedelta(minutes=2)
assert client.get(prefix).json()["quality"]["state"] == "awaiting_observation", "elapsed time is not an automatic review"
assert append(runtime, {**clear, "process_review": "not_reviewed"}).status_code == 409
early_check = (datetime.fromisoformat(runtime["started_at"]) + timedelta(seconds=30)).isoformat()
assert append(runtime, {**clear, "occurred_at": early_check}).status_code == 409
assert client.get(prefix).json()["quality"]["state"] == "awaiting_observation", "late registration cannot turn a pre-deadline check into clearance"
observation = append(runtime, {**clear, "occurred_at": runtime["observation_due_at"]})
assert observation.status_code == 201 and observation.json()["verification"] == "operator_reported"
assert client.get(prefix).json()["quality"]["state"] == "verified_in_scope"
assert not client.post(prefix + "/publish-check").json()["published"]
store = client.app_reference.state.store
original_validation = store.one("SELECT data_json FROM validations WHERE id=$1", (runtime["id"],))["data_json"]
clock[0] += timedelta(minutes=1)
alert = append(runtime, {**clear, "state": "alert", "source": "official_message", "notice_ref": "本机合成反馈-1", "note": "延后收到的测试异常,需复核关联"})
assert alert.status_code == 201
assert client.get(prefix).json()["quality"]["state"] == "review_required"
assert append(runtime, clear).status_code == 201
assert client.get(prefix).json()["quality"]["state"] == "review_required", "old run cannot clear a later alert"
clock[0] += timedelta(seconds=1)
recovered = client.post(prefix + "/verify", json=runtime_input).json()
clock[0] += timedelta(minutes=2)
assert append(recovered, clear).status_code == 201
assert client.get(prefix).json()["quality"]["state"] == "verified_in_scope"
clock[0] += timedelta(seconds=1)
assert append(runtime, {**clear, "state": "uncertain", "note": "旧运行又出现关联未知的后续反馈"}).status_code == 201
assert client.get(prefix).json()["quality"]["state"] == "review_required"
assert store.one("SELECT data_json FROM validations WHERE id=$1", (runtime["id"],))["data_json"] == original_validation
conflict = StoreConflict if store.backend == "postgresql" else sqlite3.IntegrityError
for statement in ("UPDATE quality_observations SET data_json='{}'", "DELETE FROM quality_observations"):
with pytest.raises(conflict):
store.execute(statement)
if store.backend == "postgresql":
with pytest.raises(StoreConflict):
store.execute("TRUNCATE quality_observations")
restarted = create_app(store.home, FakeWorker(store.home), bootstrap_admin=False,
database_url=getattr(client, "test_database_url", ""), database_schema=getattr(client, "test_schema", None))
with TestClient(restarted, base_url="http://127.0.0.1:8810") as second:
second.cookies.update(client.cookies)
dto = second.get(prefix).json()
assert dto["quality"]["state"] == "review_required"
recorded = next(row for row in dto["validations"] if row["id"] == runtime["id"])
assert len(recorded["quality_observations"]) == 5
assert recorded["quality"]["state"] == "review_required"
assert not second.get("/api/learning/tasks/" + task["id"]).json()["browser_readiness"]["ready"]
def test_quality_auth_scopes_csrf_and_failed_runtime_cannot_pass(client):
initialize(client)
_, candidate = working_candidate(client)
prefix = "/api/learning/candidates/" + candidate["id"]
static = client.post(prefix + "/check").json()
payload = {"mode": "runtime", "baseline_note": "本机合成基线", "expected_outputs": {"results": "不会出现"}, "observation_window_minutes": 1}
run = client.post(prefix + "/verify", json=payload).json()
assert run["status"] == "failed" and run["quality"]["state"] == "review_required"
url = "/api/learning/validations/" + run["id"] + "/quality-observations"
body = {"state": "clear", "note": "人工测试", "process_review": "passed"}
assert client.post(url, json=body, headers={"X-CSRF-Token": "invalid"}).status_code == 403
assert client.post(url, json=body).status_code == 409
assert client.post(url.replace(run["id"], static["id"]), json={**body, "state": "waiting"}).status_code == 422
with pytest.raises(HTTPException) as caught:
client.app_reference.state.learning.append_quality_observation(run["id"], "different-maintainer", {**body, "source": "manual"})
assert caught.value.status_code == 404
with TestClient(client.app_reference, base_url="http://127.0.0.1:8810") as outsider:
outsider.headers["X-CSRF-Token"] = outsider.get("/api/auth/status").json()["csrf_token"]
assert outsider.post(url, json=body).status_code == 401