152 lines
9.5 KiB
Python
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
|