"""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