assistant/apps/server/tests/test_scroll_seek.py

152 lines
9.5 KiB
Python

"""Bounded dynamic seek recording, frozen evidence, and 7/8-step compatibility."""
from __future__ import annotations
import copy
import pytest
from apps.server.db import digest
from apps.server.learning import safe_seek_result
from apps.server.reading import detail_contract
from apps.server.tests.test_discovery import item, search_candidate
from apps.server.tests.test_learning import client, initialize, working_candidate
from apps.server.tests.test_postgres import pg_client
from apps.server.tests.test_reading import NOTE_ID, TITLE, learned_detail
from apps.server.worker import WorkerError
ROOT = "#seek"
TARGET = '[data-testid="detail-link"]'
SEEK = {"kind": "scroll", "selector": ROOT, "direction": "down", "pixels": 320, "max_scrolls": 3, "timeout_ms": 5000,
"until_target": {"selector": TARGET, "field": "text", "parameter": "keyword"}}
MOVEMENT = {"before": 0, "after": 320, "scroll_height": 1000, "client_height": 500}
@pytest.fixture(params=["client", "pg_client"])
def seek_client(request):
return request.getfixturevalue(request.param)
def test_dynamic_seek_records_only_observed_target_and_freezes_parameters(seek_client):
client = seek_client
initialize(client)
task, legacy = working_candidate(client)
prefix = "/api/learning/tasks/" + task["id"]
worker = client.worker_reference
capture, call = worker.capture, worker.call
state = {"mode": "normal"}
def observed(task_id):
value = capture(task_id)
value["elements"].append({"selector": ROOT, "tag": "div", "kind": "read", "unique": True, "sensitive": False, "scroll_y": {"can_scroll": True}, "read_hints": [{"selector": ".actual-hint", "tag": "a"}]})
next(element for element in value["elements"] if element["selector"] == TARGET)["collection"] = True
return value
worker.capture = observed
async def completed(method, params):
if method == "action" and params.get("until_target") and state["mode"] == "failure":
raise WorkerError("fixture boundary", "SCROLL_TARGET_BOUNDARY", before=worker.capture(task["id"]), after=worker.capture(task["id"]))
value = await call(method, params)
if method == "action" and params.get("until_target"):
value["result"] = {"scrolls": 1, "movements": [MOVEMENT], "target_reached": True, "stop_reason": "target_reached"}
if state["mode"] == "bad_result":
value["result"]["target_reached"] = False
if method == "verify":
value["seek_results"] = [{"step_position": 4, "scrolls": 1, "movements": [MOVEMENT], "target_reached": True, "stop_reason": "target_reached"}]
value["verify_budget_ms"] = 90000
return value
worker.call = completed
assert client.post(prefix + "/browser/capture", json={}).status_code == 200
result = client.post(prefix + "/browser/action", json=SEEK)
assert result.status_code == 200, result.text
wire = [params for method, params in worker.calls if method == "action"][-1]
assert wire["until_target"] == {"selector": TARGET, "field": "text", "value": "建材"}
assert result.json()["step"]["until_target"] == SEEK["until_target"]
candidate = client.post(prefix + "/candidates", json={}).json()
assert candidate["manifest"]["steps"][-1]["until_target"] == SEEK["until_target"]
endpoint = "/api/learning/candidates/" + candidate["id"]
assert client.post(endpoint + "/check").json()["status"] == "passed"
verification = client.post(endpoint + "/verify", json={"parameters": {"keyword": "装修"}, "expected_outputs": {"results": "装修"}})
assert verification.status_code == 200 and verification.json()["status"] == "passed", verification.text
assert verification.json()["seek_results"][0]["scrolls"] == 1
assert verification.json()["environment"]["verify_budget_ms"] == 90000
assert worker.calls[-1][1]["steps"][-1]["until_target"] == SEEK["until_target"]
# Invalid inputs must fail before any browser command, and cannot broaden
# the root, use a credential attribute, or invent an unseen locator.
for invalid in ({**SEEK, "until_selector": ".target"}, {key: value for key, value in SEEK.items() if key != "timeout_ms"},
{**SEEK, "timeout_ms": True}, {**SEEK, "timeout_ms": 15001}, {**SEEK, "selector": "body"},
{**SEEK, "until_target": None}, {**SEEK, "until_target": {**SEEK["until_target"], "selector": "body a"}},
{**SEEK, "until_target": {**SEEK["until_target"], "selector": ".unobserved"}},
{**SEEK, "until_target": {**SEEK["until_target"], "value": "both"}},
{**SEEK, "until_target": {**SEEK["until_target"], "parameter": "undeclared"}},
{**SEEK, "until_target": {"selector": TARGET, "field": "attribute", "attribute": "data-token", "value": "x"}},
{"kind": "scroll", "selector": ROOT, "timeout_ms": 1000}, {"kind": "click", "selector": TARGET, "until_target": SEEK["until_target"]}):
calls = len(worker.calls)
assert client.post(prefix + "/browser/action", json=invalid).status_code == 422
assert not any(method == "action" for method, _ in worker.calls[calls:])
service = client.app_reference.state.learning
for change in ({"selector": ".unobserved"}, {"parameter": "undeclared"}):
altered = copy.deepcopy(candidate)
altered["manifest"]["steps"][-1]["until_target"].update(change)
altered["digest"] = digest(altered["manifest"])
assert service.static_errors(altered)
# A valid selector from the scroll root's actual read_hints is also accepted.
assert client.post(prefix + "/browser/action", json={**SEEK, "until_target": {"selector": ".actual-hint", "field": "text", "value": "固定目标"}}).status_code == 200
old_steps = len(client.get(prefix).json()["steps"])
for mode in ("failure", "bad_result"):
state["mode"] = mode
failure = client.post(prefix + "/browser/action", json=SEEK)
assert failure.status_code == 409 and len(failure.json()["detail"]["failure_observation_ids"]) == 2
assert len(client.get(prefix).json()["steps"]) == old_steps
stored = client.get("/api/learning/candidates/" + legacy["id"]).json()
assert stored["digest"] == legacy["digest"] and stored["manifest"] == legacy["manifest"]
def test_eight_step_reading_seek_trace_failure_and_legacy_contract(seek_client):
client = seek_client
initialize(client)
_, search = search_candidate(client, [item(TITLE)])
run = client.post("/api/discovery/runs", json={"search_candidate_id": search["id"], "keyword": "准备装修"}).json()
_, legacy, _ = learned_detail(client)
_, candidate, state = learned_detail(client, seek=True)
assert detail_contract(legacy["manifest"]) and detail_contract(candidate["manifest"])
assert len(candidate["manifest"]["steps"]) == 8
endpoint = "/api/discovery/runs/" + run["id"] + "/readings"
payload = {"detail_candidate_id": candidate["id"], "note_id": NOTE_ID}
result = client.post(endpoint, json=payload)
assert result.status_code == 201 and result.json()["status"] == "completed", result.text
reading = result.json()
assert reading["seek_results"] == [{"step_position": 4, "scrolls": 1, "movements": [MOVEMENT], "target_reached": True, "stop_reason": "target_reached"}]
validation = next(item for item in client.get("/api/learning/candidates/" + candidate["id"]).json()["validations"] if item["id"] == reading["validation_id"])
assert validation["environment"]["verify_budget_ms"] == 95000
assert validation["note_binding"]["note_id"] == NOTE_ID
state["mode"] = "seek_boundary"
failed = client.post(endpoint, json=payload).json()
assert failed["status"] == "failed" and failed["detail"] is failed["comments"] is None
assert failed["seek_results"][0]["scrolls"] == 1 and failed["seek_results"][0]["target_reached"] is False
assert failed["seek_results"][0]["stop_reason"] == "boundary" and "SCROLL_TARGET_BOUNDARY" in failed["error_codes"]
for mode, code in (("bad_seek_summary", "SEEK_RESULT_INVALID"), ("bad_budget", "VERIFY_BUDGET_INVALID")):
state["mode"] = mode
failed = client.post(endpoint, json=payload).json()
assert failed["status"] != "completed" and code in failed["error_codes"]
# Neither CSS changes nor target changes can sneak a different click into
# the 8-step business contract, even when both are syntactically valid.
for field, value in (("selector", ".another-card"), ("parameter", "keyword"), ("field", "href")):
altered = copy.deepcopy(candidate["manifest"])
altered["steps"][3]["until_target"][field] = value
assert not detail_contract(altered)
stored = client.get("/api/learning/candidates/" + legacy["id"]).json()
assert stored["manifest"] == legacy["manifest"] and stored["digest"] == legacy["digest"]
assert client.get("/api/discovery/readings/" + reading["id"]).json() == reading
def test_seek_summary_is_closed_finite_and_budget_bound():
good = {"step_position": 4, "scrolls": 1, "movements": [MOVEMENT], "target_reached": True, "stop_reason": "target_reached"}
assert safe_seek_result(good, SEEK, 4) == good
for bad in ({**good, "step_position": True}, {**good, "scrolls": True}, {**good, "scrolls": 4}, {**good, "target_reached": False},
{**good, "stop_reason": "invented"}, {**good, "private": "fixture-private"}, {**good, "movements": [{**MOVEMENT, "before": float("inf")}]},
{**good, "movements": [{**MOVEMENT, "after": True}]}, {**good, "movements": [{**MOVEMENT, "query": "fixture-private"}]},
{**good, "movements": [{**MOVEMENT, "before": -1}]}):
assert safe_seek_result(bad, SEEK, 4) is None