mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-09 19:27:45 +02:00
[eric] browser: repeat-write recipe tier (learn on verified DOM write, replay on repeat; default-off)
This commit is contained in:
@@ -321,6 +321,28 @@ async def execute_browser_tool(
|
||||
return result
|
||||
|
||||
|
||||
async def p_learn_write_recipe(execute_tool, browser_id: str, tab_id: str, current_url: str, payload: str) -> None:
|
||||
"""After a receipt-VERIFIED DOM write, distill the site's own write route into a replayable
|
||||
recipe so the NEXT write on this host can skip the DOM (the repeated-write tier). Inline +
|
||||
bounded (the card is still alive here; a fire-and-forget task would race the finish teardown)
|
||||
and fully fail-open: no routes / no payload leaf / any error just means no recipe learned, and
|
||||
the DOM path stays the default. Gated OFF until soaked (OSW_WRITE_RECIPES=1)."""
|
||||
if os.environ.get("OSW_WRITE_RECIPES", "0") == "0":
|
||||
return
|
||||
try:
|
||||
from backend.apps.agents.browser import browser_write_recipes, browser_skills
|
||||
host = browser_skills.host_of(current_url or "")
|
||||
if not host or len(payload or "") < 4:
|
||||
return
|
||||
listed = await asyncio.wait_for(
|
||||
execute_tool("BrowserListRoutes", {"writes": True}, browser_id, tab_id), timeout=4.0)
|
||||
routes = listed.get("routes") if isinstance(listed, dict) else None
|
||||
if routes and browser_write_recipes.learn_recipe(host, payload, routes):
|
||||
logger.info(f"[write-recipe] learned a replayable {host} write from a verified DOM send")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def p_extract_domain(url: str) -> str | None:
|
||||
"""Extract the apex domain from a URL (acme-corp.notion.so → notion.so).
|
||||
Returns None for non-http URLs."""
|
||||
@@ -1414,6 +1436,7 @@ async def run_browser_agent(
|
||||
done_called = True
|
||||
done_success = True
|
||||
p_aux_c, p_aux_m = await p_get_aux_client()
|
||||
await p_learn_write_recipe(execute_browser_tool, browser_id, tab_id, current_url, p_script["payload"])
|
||||
done_message = (await compose_send_confirmation(p_aux_c, p_aux_m, task, p_script["payload"])
|
||||
or f'Done, I sent "{p_script["payload"]}" for you.')
|
||||
else:
|
||||
@@ -2163,6 +2186,7 @@ async def run_browser_agent(
|
||||
done_called = True
|
||||
done_success = True
|
||||
p_payload = browser_batch_replay.send_payload_from_log(action_log, task)
|
||||
await p_learn_write_recipe(execute_browser_tool, browser_id, tab_id, current_url, p_payload)
|
||||
p_aux_c, p_aux_m = await p_get_aux_client()
|
||||
p_nice = (await compose_send_confirmation(p_aux_c, p_aux_m, task, p_payload)
|
||||
if p_payload else "")
|
||||
@@ -2256,6 +2280,7 @@ async def run_browser_agent(
|
||||
if p_cs.get("sent"):
|
||||
done_called = True
|
||||
done_success = True
|
||||
await p_learn_write_recipe(execute_browser_tool, browser_id, tab_id, current_url, composer_committed_payload)
|
||||
p_aux_c, p_aux_m = await p_get_aux_client()
|
||||
done_message = (await compose_send_confirmation(
|
||||
p_aux_c, p_aux_m, task, composer_committed_payload)
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
"""Learn-on-first-write, replay-on-repeat: the repeated-write half of the API-first tier.
|
||||
|
||||
The first write on a site is unavoidably a DOM drive (a route can only be captured after the
|
||||
site's own UI fires it, proven live in the V.8 X soak). But the moment a DOM write SUCCEEDS with
|
||||
a verified receipt, the mutating route the page fired is a complete recipe: method + URL (with
|
||||
its live queryId) + body, with the user's payload sitting in one JSON leaf. This module persists
|
||||
that recipe with the payload slot replaced by a sentinel, and replays it with a NEW payload on
|
||||
the next write to the same site, skipping the DOM entirely.
|
||||
|
||||
SAFETY (documented in SECURITY.md):
|
||||
- A recipe is learned ONLY from a receipt-verified successful write the user's own task performed,
|
||||
so its provenance satisfies the captured-route wall (the site's UI genuinely fired it); replay
|
||||
seeds route_write's captured set from the recipe itself.
|
||||
- Secret-shaped string leaves in the stored body are redacted at learn time (payload slot
|
||||
excepted); cookies/headers are never stored (route_write borrows them live per call).
|
||||
- Same-origin + OSW_ROUTE_WRITE flag + typed fail-open outcomes all still apply at replay.
|
||||
- Staleness self-heals: a recipe that misses MAX_MISSES times is dropped, and the next
|
||||
successful DOM write learns a fresh one (queryId rotation just re-learns).
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from typeguard import typechecked
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SENTINEL = "__OSW_PAYLOAD__"
|
||||
P_MIN_PAYLOAD_CHARS = 4
|
||||
P_MAX_BODY_CHARS = 32768
|
||||
MAX_MISSES = 3
|
||||
P_MAX_RECIPES_ON_DISK = 200
|
||||
|
||||
# Same secret heuristics as the electron capture (cdp-routes.js), ported so a token-shaped
|
||||
# body value can never be persisted; over-redacting is the safe direction.
|
||||
P_TOKEN_PREFIX = re.compile(r"^(sk-|ghp_|gho_|pk_|xox[bap]-|AIza|eyJ|Bearer )")
|
||||
|
||||
|
||||
@typechecked
|
||||
def looks_secret_value(v: str) -> bool:
|
||||
if not v:
|
||||
return False
|
||||
if P_TOKEN_PREFIX.match(v):
|
||||
return True
|
||||
return len(v) >= 20 and bool(re.search(r"[A-Za-z]", v)) and bool(re.search(r"[0-9]", v)) and not re.search(r"\s", v)
|
||||
|
||||
|
||||
class WriteRecipe(BaseModel):
|
||||
"""One site's proven write call, payload slot replaced by the sentinel."""
|
||||
|
||||
model_config = ConfigDict(validate_assignment=True)
|
||||
|
||||
host: str
|
||||
method: str
|
||||
url_template: str
|
||||
url: str
|
||||
body_template: str
|
||||
payload_path: str
|
||||
learned_at: float
|
||||
wins: int = 0
|
||||
misses: int = 0
|
||||
|
||||
|
||||
@typechecked
|
||||
def p_dir() -> str:
|
||||
from backend.config.paths import DATA_ROOT
|
||||
d = os.path.join(DATA_ROOT, "browser_write_recipes")
|
||||
os.makedirs(d, mode=0o700, exist_ok=True)
|
||||
return d
|
||||
|
||||
|
||||
@typechecked
|
||||
def p_path(host: str) -> str:
|
||||
safe = re.sub(r"[^a-z0-9.-]", "_", host.lower())
|
||||
return os.path.join(p_dir(), f"{safe}.json")
|
||||
|
||||
|
||||
@typechecked
|
||||
def recipe_for(host: str) -> Optional[WriteRecipe]:
|
||||
"""The persisted recipe for this host, or None. Corrupt files read as None (fail-open)."""
|
||||
try:
|
||||
with open(p_path(host)) as f:
|
||||
return WriteRecipe(**json.load(f))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
@typechecked
|
||||
def save_recipe(recipe: WriteRecipe) -> None:
|
||||
"""Atomic write, browser_skills pattern; cap the directory so it can't grow unbounded."""
|
||||
try:
|
||||
d = p_dir()
|
||||
entries = sorted(os.listdir(d), key=lambda f: os.path.getmtime(os.path.join(d, f)))
|
||||
while len(entries) >= P_MAX_RECIPES_ON_DISK:
|
||||
os.remove(os.path.join(d, entries.pop(0)))
|
||||
tmp = p_path(recipe.host) + ".tmp"
|
||||
with open(tmp, "w") as f:
|
||||
json.dump(recipe.model_dump(mode="json"), f)
|
||||
os.replace(tmp, p_path(recipe.host))
|
||||
except Exception as e:
|
||||
logger.info(f"[write-recipe] save failed for {recipe.host}: {e}")
|
||||
|
||||
|
||||
@typechecked
|
||||
def drop_recipe(host: str) -> None:
|
||||
try:
|
||||
os.remove(p_path(host))
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
@typechecked
|
||||
def p_find_payload_leaf(obj: Any, payload: str, path: str = "$") -> Optional[str]:
|
||||
"""JSON path of the leaf whose string value EQUALS the payload (exact, not substring:
|
||||
a substring hit means the site wrapped it and blind substitution would corrupt)."""
|
||||
if isinstance(obj, str):
|
||||
return path if obj == payload else None
|
||||
if isinstance(obj, dict):
|
||||
for k, v in obj.items():
|
||||
hit = p_find_payload_leaf(v, payload, f"{path}.{k}")
|
||||
if hit:
|
||||
return hit
|
||||
return None
|
||||
if isinstance(obj, list):
|
||||
for i, v in enumerate(obj):
|
||||
hit = p_find_payload_leaf(v, payload, f"{path}[{i}]")
|
||||
if hit:
|
||||
return hit
|
||||
return None
|
||||
|
||||
|
||||
@typechecked
|
||||
def p_transform_leaves(obj: Any, payload: str) -> Any:
|
||||
"""Copy with the payload leaf swapped for the sentinel and secret-shaped strings redacted."""
|
||||
if isinstance(obj, str):
|
||||
if obj == payload:
|
||||
return SENTINEL
|
||||
return "<redacted>" if looks_secret_value(obj) else obj
|
||||
if isinstance(obj, dict):
|
||||
return {k: p_transform_leaves(v, payload) for k, v in obj.items()}
|
||||
if isinstance(obj, list):
|
||||
return [p_transform_leaves(v, payload) for v in obj]
|
||||
return obj
|
||||
|
||||
|
||||
@typechecked
|
||||
def learn_recipe(host: str, payload: str, routes: List[Dict[str, Any]]) -> Optional[WriteRecipe]:
|
||||
"""Distill a recipe from the captured mutating routes of a JUST-verified write. Returns the
|
||||
saved recipe, or None when no route's body carries the payload as an exact string leaf
|
||||
(then there is nothing provably replayable, so nothing is stored)."""
|
||||
if len(payload or "") < P_MIN_PAYLOAD_CHARS:
|
||||
return None
|
||||
for r in routes:
|
||||
body = str(r.get("lastBody") or "")
|
||||
method = str(r.get("method") or "").upper()
|
||||
if not body or len(body) > P_MAX_BODY_CHARS or method in ("GET", "HEAD"):
|
||||
continue
|
||||
try:
|
||||
parsed = json.loads(body)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
continue
|
||||
slot = p_find_payload_leaf(parsed, payload)
|
||||
if not slot:
|
||||
continue
|
||||
recipe = WriteRecipe(
|
||||
host=host, method=method,
|
||||
url_template=str(r.get("template") or ""),
|
||||
url=str(r.get("example") or r.get("template") or ""),
|
||||
body_template=json.dumps(p_transform_leaves(parsed, payload)),
|
||||
payload_path=slot, learned_at=time.time(),
|
||||
)
|
||||
save_recipe(recipe)
|
||||
logger.info(f"[write-recipe] learned {host} {method} {recipe.url_template[:80]} slot={slot}")
|
||||
return recipe
|
||||
return None
|
||||
|
||||
|
||||
@typechecked
|
||||
def build_body(recipe: WriteRecipe, payload: str) -> Optional[Dict[str, Any]]:
|
||||
"""The recipe body with the NEW payload in the slot; None when the template holds no
|
||||
sentinel (corrupt or hand-edited = do not replay) or redacted leaves the site requires."""
|
||||
if SENTINEL not in recipe.body_template:
|
||||
return None
|
||||
try:
|
||||
parsed = json.loads(recipe.body_template)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
return None
|
||||
|
||||
def p_sub(obj: Any) -> Any:
|
||||
if isinstance(obj, str):
|
||||
return payload if obj == SENTINEL else obj
|
||||
if isinstance(obj, dict):
|
||||
return {k: p_sub(v) for k, v in obj.items()}
|
||||
if isinstance(obj, list):
|
||||
return [p_sub(v) for v in obj]
|
||||
return obj
|
||||
|
||||
out = p_sub(parsed)
|
||||
return out if isinstance(out, dict) else None
|
||||
|
||||
|
||||
@typechecked
|
||||
async def replay_recipe(recipe: WriteRecipe, payload: str, origin: str) -> Dict[str, Any]:
|
||||
"""Replay the recipe with a new payload via route_write (same-origin + flag + live-borrowed
|
||||
cookies all enforced there). The recipe IS the captured provenance: it was learned from a
|
||||
route the site's UI fired during a receipt-verified write, so it seeds the captured set.
|
||||
Returns {ok, receipt|error}; a miss bumps the staleness counter and MAX_MISSES drops it."""
|
||||
from backend.apps.agents.browser import route_write
|
||||
body = build_body(recipe, payload)
|
||||
if body is None:
|
||||
drop_recipe(recipe.host)
|
||||
return {"ok": False, "error": "recipe template unusable; dropped"}
|
||||
captured = [route_write.CapturedRoute(method=recipe.method, template=recipe.url_template)]
|
||||
import asyncio
|
||||
out = await asyncio.to_thread(
|
||||
route_write.replay_write, recipe.method, recipe.url, body, origin, captured)
|
||||
if out.ok:
|
||||
recipe.wins += 1
|
||||
save_recipe(recipe)
|
||||
return {"ok": True, "receipt": out.receipt, "latency_ms": out.latency_ms}
|
||||
recipe.misses += 1
|
||||
if recipe.misses >= MAX_MISSES:
|
||||
drop_recipe(recipe.host)
|
||||
logger.info(f"[write-recipe] {recipe.host} dropped after {recipe.misses} misses (stale; next DOM win re-learns)")
|
||||
else:
|
||||
save_recipe(recipe)
|
||||
return {"ok": False, "error": out.error}
|
||||
@@ -0,0 +1,97 @@
|
||||
"""Unit tests for the learn-on-first-write / replay-on-repeat recipe module.
|
||||
Network mocked at route_write.issue_request; disk redirected to tmp_path."""
|
||||
|
||||
import json
|
||||
from typing import Any, Dict
|
||||
|
||||
import pytest
|
||||
|
||||
from backend.apps.agents.browser import browser_write_recipes as wr
|
||||
from backend.apps.agents.browser import route_write
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def recipes_tmp(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(wr, "p_dir", lambda: str(tmp_path))
|
||||
|
||||
|
||||
P_X_BODY = {
|
||||
"variables": {"tweet_text": "hello from the test", "dark_request": False,
|
||||
"media": {"media_entities": [], "possibly_sensitive": False}},
|
||||
"features": {"tweetypie_unmention_optimization_enabled": True, "longform_notetweets_consumption_enabled": True},
|
||||
"queryId": "AbCdEf123456",
|
||||
}
|
||||
|
||||
|
||||
def p_routes(body: Dict[str, Any]) -> list:
|
||||
return [
|
||||
{"method": "GET", "template": "https://x.com/i/api/graphql/{id}/HomeTimeline", "example": "", "lastBody": ""},
|
||||
{"method": "POST", "template": "https://x.com/i/api/graphql/{id}/CreateTweet",
|
||||
"example": "https://x.com/i/api/graphql/AbCdEf123456/CreateTweet", "lastBody": json.dumps(body)},
|
||||
]
|
||||
|
||||
|
||||
def test_learn_finds_payload_slot_and_saves():
|
||||
r = wr.learn_recipe("x.com", "hello from the test", p_routes(P_X_BODY))
|
||||
assert r is not None
|
||||
assert r.payload_path == "$.variables.tweet_text"
|
||||
assert wr.SENTINEL in r.body_template
|
||||
assert "hello from the test" not in r.body_template
|
||||
assert wr.recipe_for("x.com") is not None
|
||||
|
||||
|
||||
def test_learn_refuses_substring_and_short_payloads():
|
||||
body = {"variables": {"tweet_text": "prefix hello from the test suffix"}}
|
||||
assert wr.learn_recipe("x.com", "hello from the test", p_routes(body)) is None
|
||||
assert wr.learn_recipe("x.com", "hi", p_routes(P_X_BODY)) is None
|
||||
|
||||
|
||||
def test_learn_redacts_secret_leaves_but_keeps_structure():
|
||||
body = {"variables": {"tweet_text": "hello from the test"}, "csrfish": "a1B2c3D4e5F6g7H8i9J0kk"}
|
||||
r = wr.learn_recipe("x.com", "hello from the test", p_routes(body))
|
||||
parsed = json.loads(r.body_template)
|
||||
assert parsed["csrfish"] == "<redacted>"
|
||||
assert parsed["variables"]["tweet_text"] == wr.SENTINEL
|
||||
|
||||
|
||||
def test_build_body_substitutes_new_payload_only():
|
||||
r = wr.learn_recipe("x.com", "hello from the test", p_routes(P_X_BODY))
|
||||
body = wr.build_body(r, "a brand new tweet")
|
||||
assert body["variables"]["tweet_text"] == "a brand new tweet"
|
||||
assert body["queryId"] == "AbCdEf123456"
|
||||
r.body_template = json.dumps({"variables": {"tweet_text": "no sentinel here"}})
|
||||
assert wr.build_body(r, "x") is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_replay_ok_bumps_wins_and_returns_receipt(monkeypatch):
|
||||
monkeypatch.setenv("OSW_ROUTE_WRITE", "1")
|
||||
r = wr.learn_recipe("x.com", "hello from the test", p_routes(P_X_BODY))
|
||||
monkeypatch.setattr(route_write, "get_session", lambda d: ("auth=1; ct0=abc", "UA"))
|
||||
monkeypatch.setattr(route_write, "issue_request",
|
||||
lambda m, u, b, h: (200, json.dumps({"data": {"rest_id": "999"}})))
|
||||
out = await wr.replay_recipe(r, "new text", "https://x.com")
|
||||
assert out["ok"] is True and out["receipt"] == "999"
|
||||
assert wr.recipe_for("x.com").wins == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_replay_miss_bumps_and_drops_after_cap(monkeypatch):
|
||||
monkeypatch.setenv("OSW_ROUTE_WRITE", "1")
|
||||
wr.learn_recipe("x.com", "hello from the test", p_routes(P_X_BODY))
|
||||
monkeypatch.setattr(route_write, "get_session", lambda d: ("auth=1", "UA"))
|
||||
monkeypatch.setattr(route_write, "issue_request", lambda m, u, b, h: (404, "gone"))
|
||||
for i in range(wr.MAX_MISSES):
|
||||
r = wr.recipe_for("x.com")
|
||||
assert r is not None, f"recipe gone before miss {i + 1}"
|
||||
out = await wr.replay_recipe(r, "t", "https://x.com")
|
||||
assert out["ok"] is False
|
||||
assert wr.recipe_for("x.com") is None # stale recipe self-evicted
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_replay_respects_flag_off(monkeypatch):
|
||||
monkeypatch.delenv("OSW_ROUTE_WRITE", raising=False)
|
||||
r = wr.learn_recipe("x.com", "hello from the test", p_routes(P_X_BODY))
|
||||
out = await wr.replay_recipe(r, "t", "https://x.com")
|
||||
assert out["ok"] is False and "disarmed" in out["error"]
|
||||
+29
-1
@@ -95,9 +95,32 @@ function routeKey(method, template) {
|
||||
return String(method || 'GET').toUpperCase() + ' ' + template;
|
||||
}
|
||||
|
||||
// The mutating body WITH values, but secret-shaped string leaves redacted (belt; the backend
|
||||
// recipe learner redacts again). Only JSON, capped: a write recipe needs the real body (the user's
|
||||
// payload sits in one leaf), which bodyShape (types only) can't provide. Non-JSON / oversized = null.
|
||||
const MAX_BODY_CHARS = 65536;
|
||||
|
||||
function redactBodyValues(postData) {
|
||||
if (!postData || postData.length > MAX_BODY_CHARS) return null;
|
||||
try {
|
||||
const walk = (v) =>
|
||||
typeof v === 'string'
|
||||
? (looksSecretValue(v) ? '<redacted>' : v)
|
||||
: Array.isArray(v)
|
||||
? v.map(walk)
|
||||
: v && typeof v === 'object'
|
||||
? Object.fromEntries(Object.keys(v).map((k) => [k, walk(v[k])]))
|
||||
: v;
|
||||
return JSON.stringify(walk(JSON.parse(postData)));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function makeRouteEntry(request, resourceType) {
|
||||
const method = String(request.method || 'GET').toUpperCase();
|
||||
const template = templateUrl(request.url);
|
||||
const safe = isSafeMethod(method);
|
||||
return {
|
||||
method,
|
||||
template,
|
||||
@@ -105,7 +128,9 @@ function makeRouteEntry(request, resourceType) {
|
||||
resourceType,
|
||||
headers: redactHeaders(request.headers),
|
||||
bodyShape: bodyShape(request.postData),
|
||||
safe: isSafeMethod(method),
|
||||
// Only mutating routes carry a replayable body; a GET's body (if any) is never a write recipe.
|
||||
lastBody: safe ? null : redactBodyValues(request.postData),
|
||||
safe,
|
||||
hits: 1,
|
||||
lastSeen: Date.now(),
|
||||
};
|
||||
@@ -123,6 +148,9 @@ function recordRoute(routesMap, request, resourceType, now = Date.now()) {
|
||||
if (existing) {
|
||||
existing.hits += 1;
|
||||
existing.lastSeen = now;
|
||||
// Refresh the body so a later replay learns from the FRESHEST call (queryId/token rotation
|
||||
// lives in the URL/headers, but a stale body could hold an old nonce); keep the latest.
|
||||
if (entry.lastBody) existing.lastBody = entry.lastBody;
|
||||
} else {
|
||||
routesMap.set(key, entry);
|
||||
if (routesMap.size > MAX_ROUTES_PER_WC) {
|
||||
|
||||
@@ -136,3 +136,30 @@ test('makeRouteEntry carries a redacted example url', () => {
|
||||
assert.ok(e.example.includes('redacted'));
|
||||
assert.ok(!e.example.includes('secretAbc123Long'));
|
||||
});
|
||||
|
||||
test('makeRouteEntry: mutating route keeps a body, GET does not, secrets redacted', () => {
|
||||
const post = R.makeRouteEntry({
|
||||
method: 'POST',
|
||||
url: 'https://x.com/i/api/graphql/AbC123/CreateTweet',
|
||||
headers: { 'x-csrf-token': 'ct0secret' },
|
||||
postData: JSON.stringify({ variables: { tweet_text: 'hello world' }, authToken: 'aB3xK9mQ2pL7wR4tY8nZ' }),
|
||||
}, 'Fetch');
|
||||
assert.equal(post.safe, false);
|
||||
const body = JSON.parse(post.lastBody);
|
||||
assert.equal(body.variables.tweet_text, 'hello world'); // payload survives
|
||||
assert.equal(body.authToken, '<redacted>'); // secret leaf redacted
|
||||
assert.equal(post.headers['x-csrf-token'], '<redacted>'); // header redacted
|
||||
|
||||
const get = R.makeRouteEntry({ method: 'GET', url: 'https://x.com/i/api/graphql/AbC123/Home', headers: {}, postData: '' }, 'XHR');
|
||||
assert.equal(get.lastBody, null); // no body on a safe route
|
||||
});
|
||||
|
||||
test('recordRoute: repeat write refreshes lastBody to the freshest call', () => {
|
||||
const m = new Map();
|
||||
const mk = (text) => ({ method: 'POST', url: 'https://x.com/i/api/graphql/AbC/CreateTweet', headers: {}, postData: JSON.stringify({ variables: { tweet_text: text } }) });
|
||||
R.recordRoute(m, mk('first'), 'Fetch');
|
||||
R.recordRoute(m, mk('second'), 'Fetch');
|
||||
const entry = [...m.values()][0];
|
||||
assert.equal(entry.hits, 2);
|
||||
assert.equal(JSON.parse(entry.lastBody).variables.tweet_text, 'second');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user