Skip to content

Commit 54a61c0

Browse files
committed
feat(browser-act): seed channel manifests for 2 packs + interpreter integration tests (PR-D)
Author channel.manifest.json for two real vendored packs (our files, not upstream bytes), proving the PR-C interpreter drives real packs: - search-research/google-search-serp: login-free, single-page (mode "none"), emits one SERP dict, success required_field "organicResults". - ecommerce/taobao-keyword-search: url_page pagination (page number), stop_when "result_count < 10", product list, required_field "itemId". Manifest field/arg shapes verified against the real scripts (serp-extract.py takes no args -> organicResults dict; search-products.py positional keyword + --page/--sort -> itemId list). tests/integration/test_browser_act_seeds.py (8 tests): both manifests load+validate, google happy path, taobao pagination stop_when (12+5=17 over 2 pages), min_count-not-met failure, needs_human on captcha. Real pack scripts run; only the browser-act eval hop is mocked. VENDOR.md: Seed-manifests section (prose->manifest translation + limits: no URL-encoding of params, google seeded single-page since start=(page-1)*num offset isn't computable from {page} context yet, ~76 packs still manifest-less, api-skill packs use a different exec shape this interpreter doesn't model). Interpreter unchanged (purely additive). 1484 -> 1492 passed, zero regression.
1 parent 5996373 commit 54a61c0

4 files changed

Lines changed: 333 additions & 0 deletions

File tree

backend/browser_act_packs/VENDOR.md

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,3 +59,112 @@ To pull upstream updates:
5959
directories verbatim (do not touch our `channel.manifest.json` files when
6060
they exist — those are ours, not upstream's).
6161
3. Update the commit hash and date at the top of this file.
62+
63+
## Seed manifests (PR-D)
64+
65+
Two of the 78 vendored packs got a hand-authored `channel.manifest.json`
66+
(GOAL-7 decision #5), chosen as the pair that can be validated end-to-end
67+
without a logged-in session: `search-research/google-search-serp` (a
68+
login-free public search) and `ecommerce/taobao-keyword-search` (a
69+
login-free public listing search page). Both translations below were
70+
checked against the pack's real `scripts/*.py` before being written (field
71+
names / arg shapes match what the script actually emits/accepts).
72+
73+
### `search-research/google-search-serp`
74+
75+
SKILL.md prose → manifest:
76+
77+
- **navigate**: SKILL.md's `navigate https://www.google.com/search?q=...`
78+
→ `steps[0] = {"op": "navigate", "url_template":
79+
"https://www.google.com/search?q={query}&num={num}&hl={lang}&gl={country}"}`.
80+
`query`/`num`/`lang`/`country` map straight onto the four querystring
81+
params the script's JS itself reads back out of `window.location.search`
82+
(`q`, `num`, `hl`, `gl`) — `num`/`lang`/`country` default to `"10"`/`""`/`""`
83+
so a bare `{"query": "..."}` config still works.
84+
- **wait**: SKILL.md's `wait stable` → `steps[1] = {"op": "wait",
85+
"wait_mode": "stable"}`.
86+
- **eval_script**: SKILL.md's `eval "$(python scripts/serp-extract.py)"`
87+
`steps[2] = {"op": "eval_script", "script": "scripts/serp-extract.py",
88+
"args": []}`. Confirmed against the real script: `serp-extract.py` takes
89+
**no argparse arguments at all** (it emits a fixed JS snippet that reads
90+
everything it needs, including `q`/`num`/`hl`/`gl`, straight from
91+
`window.location.search` at eval time) — so `args: []` is correct, not an
92+
oversight.
93+
- **pagination**: `{"mode": "none"}` — single page only (see Known
94+
limitation #2 below).
95+
- **success**: `{"min_count": 1, "required_field": "organicResults"}`. The
96+
script's JS always returns one JSON **object** (the whole SERP), never a
97+
list, so `collect()` wraps it as a single item; `required_field` checks
98+
that item has a (truthy) `organicResults` key. Confirmed against the real
99+
script: the emitted JS's success-path return value is `{searchQuery,
100+
resultsTotal, organicResults, paidResults, relatedQueries, peopleAlsoAsk,
101+
aiOverview}``organicResults` is present verbatim.
102+
103+
### `ecommerce/taobao-keyword-search`
104+
105+
SKILL.md prose → manifest:
106+
107+
- **navigate**: SKILL.md's `navigate https://s.taobao.com/search?q=...`
108+
`steps[0] = {"op": "navigate", "url_template":
109+
"https://s.taobao.com/search?q={keyword}&page={page}&ie=utf8"}`. `{page}`
110+
comes from the channel's own per-page loop context (decision #5's
111+
`pagination`), not from `param_schema` — page 1 is implicit, page 2+ is
112+
driven by `pagination.url_template` below.
113+
- **wait**: `steps[1] = {"op": "wait", "wait_mode": "stable"}`.
114+
- **eval_script**: SKILL.md's `eval "$(python scripts/search-products.py
115+
{keyword} --page {page} --sort {sort})"``steps[2] = {"op":
116+
"eval_script", "script": "scripts/search-products.py", "args":
117+
["{keyword}", "--page", "{page}", "--sort", "{sort}"]}`. Confirmed against
118+
the real script: `search-products.py`'s argparse takes a **positional**
119+
`keyword` (documentation-only per its own comment — the URL already
120+
carries `q=`) plus `--page` (default `"1"`) and `--sort` (default `""`,
121+
values `""`/`"sale-desc"`/`"price-asc"`/`"price-desc"`) among other
122+
optional flags (`--tab`, `--start-price`, `--end-price`) this seed
123+
manifest does not yet expose — arg names/order match exactly.
124+
- **pagination**: `{"mode": "url_page", "url_template":
125+
"https://s.taobao.com/search?q={keyword}&page={page}&ie=utf8",
126+
"page_param": "page", "stop_when": "result_count < 10"}` — matches
127+
SKILL.md's documented "stop when a page returns fewer than 10 results"
128+
pagination note; the interpreter's `_stop_when_triggered` regex
129+
(`backend/channels/browser_act_channel.py`) parses `"result_count < N"`
130+
directly.
131+
- **success**: `{"min_count": 1, "required_field": "itemId"}`. Confirmed
132+
against the real script: the emitted JS's success path returns a JSON
133+
**list** of product dicts, each shaped `{itemId, itemUrl, title,
134+
subTitle, priceYuan, priceDesc, imageUrl, salesCount, shopName, location,
135+
rating, tags}``itemId` is present verbatim on every item.
136+
137+
### Known limitations
138+
139+
1. **No URL-encoding of params into `url_template`.** The interpreter
140+
(`_run_page` in `backend/channels/browser_act_channel.py`) does
141+
`url_template.format(**ctx)` — a plain string substitution, not a
142+
URL-encoding one. This is fine for simple ASCII keywords/queries, but
143+
SKILL.md's own prose notes that `q` should be URL-encoded (spaces,
144+
non-ASCII, `&`/`#`/`?` in the search term would corrupt the querystring
145+
otherwise). A real-world caller must pre-encode `query`/`keyword` before
146+
passing them in `params`, or a future interpreter enhancement should
147+
URL-encode template substitutions itself — out of scope for this PR.
148+
2. **`google-search-serp` is seeded single-page (`pagination.mode:
149+
"none"`).** Its real pagination is a `start=(page-1)*num` offset
150+
querystring param (see the script's own `start`/`num` parsing), which
151+
this interpreter's `{page}` context (a bare 1-based page counter, not an
152+
offset) can't compute without a per-pack formula the generic manifest
153+
schema doesn't currently express. Multi-page google is a future
154+
enhancement (e.g. a `pagination.mode` that lets a manifest declare an
155+
offset formula), not something this seed pretends to support.
156+
3. **The other ~76 vendored packs have no `channel.manifest.json` yet.**
157+
Seeding is intentionally incremental (GOAL-7 PR-D scope: 2 packs to prove
158+
the pipeline, not full coverage) — `backend/browser_act_packs/manifest.py`
159+
(`PackManifest`) is the extension point; add a `channel.manifest.json`
160+
next to a pack's `SKILL.md` as each one is needed, translating its prose
161+
the same way as above. In particular, the `*-api-skill` packs (e.g.
162+
`search-research/web-search-scraper-api-skill`, confirmed by reading its
163+
script: it `requests.post`s straight to `https://api.browseract.com/v2/
164+
workflow` with a Bearer API key) use a **different** execution shape
165+
entirely — their scripts call the BrowserAct **API** directly (HTTP, no
166+
browser session at all) instead of navigate→wait→eval against a live
167+
browser-act session, so this channel's navigate/wait/eval_script
168+
interpreter does not — and structurally cannot — model them; they would
169+
need either a distinct `step.op` (e.g. `"api_call"`) or a separate
170+
channel entirely, a decision deferred rather than made silently here.
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
"domain": "ecommerce",
3+
"capability": "taobao-keyword-search",
4+
"param_schema": [
5+
{"name": "keyword", "required": true},
6+
{"name": "sort", "required": false, "default": ""}
7+
],
8+
"steps": [
9+
{"op": "navigate", "url_template": "https://s.taobao.com/search?q={keyword}&page={page}&ie=utf8"},
10+
{"op": "wait", "wait_mode": "stable"},
11+
{"op": "eval_script", "script": "scripts/search-products.py", "args": ["{keyword}", "--page", "{page}", "--sort", "{sort}"]}
12+
],
13+
"pagination": {"mode": "url_page", "url_template": "https://s.taobao.com/search?q={keyword}&page={page}&ie=utf8", "page_param": "page", "stop_when": "result_count < 10"},
14+
"success": {"min_count": 1, "required_field": "itemId"}
15+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
{
2+
"domain": "search-research",
3+
"capability": "google-search-serp",
4+
"param_schema": [
5+
{"name": "query", "required": true},
6+
{"name": "num", "required": false, "default": "10"},
7+
{"name": "lang", "required": false, "default": ""},
8+
{"name": "country", "required": false, "default": ""}
9+
],
10+
"steps": [
11+
{"op": "navigate", "url_template": "https://www.google.com/search?q={query}&num={num}&hl={lang}&gl={country}"},
12+
{"op": "wait", "wait_mode": "stable"},
13+
{"op": "eval_script", "script": "scripts/serp-extract.py", "args": []}
14+
],
15+
"pagination": {"mode": "none"},
16+
"success": {"min_count": 1, "required_field": "organicResults"}
17+
}
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
"""Integration tests for GOAL-7 PR-D seed manifests.
2+
3+
Exercises the two hand-authored ``channel.manifest.json`` seeds
4+
(``search-research/google-search-serp``, ``ecommerce/taobao-keyword-search``)
5+
against the REAL vendored pack tree (``PackCatalog()`` at its default root --
6+
no synthetic ``tmp_path`` pack like PR-C's unit tests use) and the REAL pack
7+
scripts (``scripts/serp-extract.py``, ``scripts/search-products.py`` run for
8+
real via ``run_pack_script``, PR-C's second subprocess hop). Only the
9+
browser-act eval hop -- the one that would need an actual browser/DOM -- is
10+
mocked, at the same seam PR-C's ``tests/unit/channels/
11+
test_browser_act_channel.py`` patches: ``backend.browser_act.cli._run``. This
12+
proves the two manifests authored in this PR are wired correctly against the
13+
real vendored files, not a stand-in.
14+
"""
15+
16+
import json
17+
18+
import pytest
19+
from unittest.mock import patch
20+
21+
from backend.browser_act.cli import BrowserActResult
22+
from backend.browser_act_packs.catalog import PackCatalog
23+
from backend.browser_act_packs.manifest import PackManifest, load_manifest
24+
from backend.channels.browser_act_channel import BrowserActChannel
25+
26+
GOOGLE_PACK = "search-research/google-search-serp"
27+
TAOBAO_PACK = "ecommerce/taobao-keyword-search"
28+
29+
30+
def _run_side_effect(eval_responses: list[str]):
31+
"""Same seam/shape as PR-C's test_browser_act_channel.py helper: answer
32+
"eval" subcommands from eval_responses in order (repeating the last one
33+
once exhausted), no-op every other subcommand (navigate/wait/click/input).
34+
"""
35+
responses = list(eval_responses)
36+
state = {"i": 0}
37+
38+
async def _side_effect(args, *, timeout=None, env=None):
39+
subcommand = args[2] if len(args) > 2 else None
40+
if subcommand == "eval":
41+
i = min(state["i"], len(responses) - 1)
42+
state["i"] += 1
43+
return BrowserActResult(returncode=0, stdout=responses[i], stderr="")
44+
return BrowserActResult(returncode=0, stdout="", stderr="")
45+
46+
return _side_effect
47+
48+
49+
@pytest.fixture
50+
def channel():
51+
# Real vendored-packs root (no tmp_path override) -- these tests exercise
52+
# the actual PR-D manifests + actual PR-A/scripts.py files on disk.
53+
return BrowserActChannel(catalog=PackCatalog())
54+
55+
56+
# ── (a) both seed manifests load + validate ──────────────────────────────
57+
58+
59+
def test_google_seed_manifest_loads_and_validates():
60+
manifest_path = (
61+
PackCatalog().root
62+
/ "search-research"
63+
/ "google-search-serp"
64+
/ "channel.manifest.json"
65+
)
66+
manifest = load_manifest(manifest_path)
67+
assert isinstance(manifest, PackManifest)
68+
assert manifest.domain == "search-research"
69+
assert manifest.capability == "google-search-serp"
70+
71+
72+
def test_taobao_seed_manifest_loads_and_validates():
73+
manifest_path = (
74+
PackCatalog().root
75+
/ "ecommerce"
76+
/ "taobao-keyword-search"
77+
/ "channel.manifest.json"
78+
)
79+
manifest = load_manifest(manifest_path)
80+
assert isinstance(manifest, PackManifest)
81+
assert manifest.domain == "ecommerce"
82+
assert manifest.capability == "taobao-keyword-search"
83+
84+
85+
@pytest.mark.asyncio
86+
async def test_google_seed_validate_config_valid(channel):
87+
errors = await channel.validate_config({"pack": GOOGLE_PACK, "params": {"query": "x"}})
88+
assert errors == []
89+
90+
91+
@pytest.mark.asyncio
92+
async def test_taobao_seed_validate_config_valid(channel):
93+
errors = await channel.validate_config(
94+
{"pack": TAOBAO_PACK, "params": {"keyword": "x"}}
95+
)
96+
assert errors == []
97+
98+
99+
# ── (b) google seed happy path (single page, mode "none") ────────────────
100+
101+
102+
@pytest.mark.asyncio
103+
async def test_google_seed_happy_path_single_page(channel):
104+
serp = {
105+
"searchQuery": {"term": "machine learning"},
106+
"organicResults": [
107+
{"position": 1, "title": "A", "url": "http://a"},
108+
{"position": 2, "title": "B", "url": "http://b"},
109+
],
110+
"paidResults": [],
111+
"relatedQueries": [],
112+
}
113+
with patch(
114+
"backend.browser_act.cli._run",
115+
side_effect=_run_side_effect([json.dumps(serp)]),
116+
):
117+
result = await channel.collect(
118+
{"pack": GOOGLE_PACK, "params": {"query": "machine learning"}}, {}
119+
)
120+
121+
assert result.success is True
122+
# The real serp-extract.py JS emits one dict (the whole SERP), so
123+
# collect() wraps it as a single item -- not one item per organic result.
124+
assert len(result.items) == 1
125+
assert len(result.items[0]["organicResults"]) == 2
126+
assert result.metadata["pages_fetched"] == 1
127+
128+
129+
# ── (c) taobao seed pagination + stop_when (multi page) ──────────────────
130+
131+
132+
@pytest.mark.asyncio
133+
async def test_taobao_seed_paginates_and_stops_on_stop_when(channel):
134+
page1 = [{"itemId": str(n), "title": f"p{n}"} for n in range(12)]
135+
page2 = [{"itemId": str(n), "title": f"p{n}"} for n in range(5)]
136+
137+
with patch(
138+
"backend.browser_act.cli._run",
139+
side_effect=_run_side_effect([json.dumps(page1), json.dumps(page2)]),
140+
) as mock_run:
141+
result = await channel.collect(
142+
{"pack": TAOBAO_PACK, "params": {"keyword": "耳机"}}, {}
143+
)
144+
145+
eval_calls = [
146+
call
147+
for call in mock_run.call_args_list
148+
if len(call.args[0]) > 2 and call.args[0][2] == "eval"
149+
]
150+
# page1 has 12 items (>= 10) -> pagination continues; page2 has 5 items
151+
# (< 10) -> "result_count < 10" stop_when fires after that page.
152+
assert len(eval_calls) == 2
153+
assert result.success is True
154+
assert len(result.items) == 17
155+
assert result.metadata["pages_fetched"] == 2
156+
157+
158+
# ── (d) success.min_count not met -> failure ──────────────────────────────
159+
160+
161+
@pytest.mark.asyncio
162+
async def test_taobao_seed_empty_page_fails_min_count(channel):
163+
with patch(
164+
"backend.browser_act.cli._run",
165+
side_effect=_run_side_effect([json.dumps([])]),
166+
):
167+
result = await channel.collect(
168+
{"pack": TAOBAO_PACK, "params": {"keyword": "耳机"}}, {}
169+
)
170+
171+
assert result.success is False
172+
assert result.error_type == "error"
173+
assert "min_count" in result.error
174+
175+
176+
# ── (e) needs_human still works with a real seed ──────────────────────────
177+
178+
179+
@pytest.mark.asyncio
180+
async def test_google_seed_needs_human_on_captcha(channel):
181+
with patch(
182+
"backend.browser_act.cli._run",
183+
side_effect=_run_side_effect(
184+
[json.dumps({"error": True, "message": "captcha required"})]
185+
),
186+
):
187+
result = await channel.collect(
188+
{"pack": GOOGLE_PACK, "params": {"query": "machine learning"}}, {}
189+
)
190+
191+
assert result.success is False
192+
assert result.error_type == "needs_human"

0 commit comments

Comments
 (0)