Repository navigation
2026 09 30 site data
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: One save_site_data keeps the cookies and storage of every site the session has been to, as one snapshot that replaces the last; every browser opened for the session has all of it back before open_session returns; the admin UI shows where the session has been (History) apart from what it saved (Site data).
Architecture: The record gains history — one entry per origin, newest first — and url becomes history[0].url. A spare background tab, whose every request a BiDi network intercept answers with a marked blank page, can stand on any origin: a save reads each history origin's localStorage there; a restore writes it there before the first page, then sets sessionStorage in the main tab the same way. site_data.snapshot replaces round 1's merge, and the preload script with everything that kept it honest is deleted. A silent reopen's report waits on the record (reopened) until touch hands it to the first result. The admin API serves a History view (the history joined by host with secrets and snapshot counts) beside the Site data view (the snapshot by host).
Tech Stack: as round 1 — Python 3.10–3.14, Selenium 4.49 BiDi (storage, browsing_context, network, script), FastMCP, Starlette, Svelte 5 + vitest.
Spec: docs/superpowers/specs/2026-09-30-site-data-design.md, round 2 — read it before your task; it is the authority. Round 1 (below the rule) is the record of how the code got here; where the two disagree, the spec and this round win.
Branch: site-data-history (checked out). Spike: passed 2026-10-01 in Chrome 152 and Firefox 155 (spec, The spike); $S/spike/spike.py is the throwaway that proved the Selenium calls used in Task 3.
Penpot: file Admin UI, pages Session · History and Session · Site data, version Site data design, round 2 — History rows fold. Tasks 9 and 10 quote its copy exactly; the hosts in it are not to be copied into fixtures.
- Names stay:
save_site_data,restore_site_data,session://site-data,session://site-data/{site}, the hint keysite_data, the tab Site data. New: the record fieldshistoryandreopened, the tab History, the admin routes…/history. -
No backcompat (one user, Dr K): old shapes are purged, never migrated. A record written before round 2 reads as one that has been nowhere — its
urlkey is ignored, its browser, settings and site data stand. Old site-data shapes are read defensively (.get) and replaced whole by the next save. - Plain W3C WebDriver; BiDi only for site data; no CDP. BiDi is reached through
Grid.bidi(session_id)and, for a tab,browser.spare_tab. - Values are never logged and never returned; an httpOnly value reads
"•••"on every surface. Site data and history live on the record in the session store, never on this server's disk. -
Every record write is
store.update(key, fn)(orupsert):fngets the record as it is at write time, only computes, and may run twice (Redis retries it). Slow work — BiDi, the Grid — happens outsidefn. - Errors: a refused request is a
ValueError(400) orerrors.NotFound(404); a dead BiDi channel at save iserrors.BidiUnavailable(503).errors.pydecides; admin handlers answer throughrefused(exc, what). - House style (AGENTS.md): comments earn their lines — the non-obvious why, terse; docstrings in
mcp/tools.pyare prompts; every browser action is a tool and an endpoint (tests/test_surfaces.py); every response shape is declared inspec/schemas.py; UI copy is terse — only the drawn lines. - Never write a real person's name or domain in the repo, fixtures included: hosts are
example.com-style, the user is Dr K. - Commands, from the repo root (
Sis the scratchpad holdingdeps/andtools/):
S=/tmp/claude-1000/-projects-cluster/2222ebcd-ef22-431d-92ea-d22a6044b628/scratchpad
# The unit suite. Baseline before Task 1: 1530 passed. The deselected test fails in the pod
# only, because its subprocess lacks -S; the wiki command below is the real check here.
PYTHONPATH=$S/deps:. python3 -S -m pytest -q -m "not integration" tests \
--deselect tests/test_wiki.py::test_the_generated_pages_are_current
# One file.
PYTHONPATH=$S/deps:. python3 -S -m pytest -q tests/test_history.py
# Lint. If $S/tools/bin/ruff is missing: python3 -m pip install --target $S/tools ruff (CI runs it too).
$S/tools/bin/ruff check kubed tests scripts
# The wiki, the pod-safe way.
PYTHONPATH=$S/deps:. python3 -S scripts/generate_wiki.py --check
# The UI.
cd ui && npx vitest run && npm run -s check && npm run -s lint && cd ..A script run by hand (not -m pytest) needs the repo first — PYTHONPATH=.:$S/deps — or it imports a stale kubed from deps.
- Deleting tests by name. Several tasks remove round 1 tests that only covered what this round deletes. Write this helper once and use it as each task says:
mkdir -p $S && cat > $S/drop.py <<'EOF'
"""Delete top-level functions, classes and assignments from a test module by
name, with their decorators, then any `# ---- section` header left with
nothing under it. Usage: python3 drop.py FILE NAME..."""
import ast
import pathlib
import re
import sys
path, names = pathlib.Path(sys.argv[1]), set(sys.argv[2:])
source = path.read_text()
lines = source.splitlines(keepends=True)
found, spans = set(), []
for node in ast.parse(source).body:
name = getattr(node, "name", None)
if isinstance(node, ast.Assign) and isinstance(node.targets[0], ast.Name):
name = node.targets[0].id
if name in names:
found.add(name)
start = min([node.lineno] + [d.lineno for d in getattr(node, "decorator_list", [])])
spans.append((start - 1, node.end_lineno))
missing = names - found
if missing:
sys.exit(f"not in {path}: {', '.join(sorted(missing))}")
for start, end in sorted(spans, reverse=True):
del lines[start:end]
text = "".join(lines)
text = re.sub(r"(?m)^# ---- [^\n]*\n(?:[ \t]*\n)*(?=# ---- |\Z)", "", text)
text = re.sub(r"\n{4,}", "\n\n\n", text).rstrip() + "\n"
path.write_text(text)
print(f"{path}: dropped {len(found)}")
EOFIt exits non-zero naming any name it did not find; a task's list is exact, so that is a stop-and-look.
-
The wiki is generated from the tool docstrings and the spec. Tasks 4–8 change both, so
generate_wiki.py --checkreports stale pages from Task 4 on. Task 11 regenerates it once; do not regenerate earlier. - Never run the integration tests in the pod, and never against the live Grid inside a task: the live check is the controller's, after Task 11.
- Each new test is broken on purpose once — the step says how — seen to fail, then restored.
- Commit messages end with
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>(the second-min every commit step). Never push.
| File | Responsibility in round 2 |
|---|---|
kubed/selenium_flow/session/store.py |
SessionRecord.history (+ url property, at, history_cleared), SessionRecord.reopened (+ delivered), HISTORY_CAP. |
kubed/selenium_flow/session/sessions.py |
touch records pages and hands over a reopen's report; remember keeps the history; resolve notes the report; settle stores a snapshot; visited. The pending note is gone. |
kubed/selenium_flow/flows/api.py, flows/run.py
|
A run writes its steps' pages in one touch and carries a reopen's report at run level; the per-step arrival hint goes. |
kubed/selenium_flow/core/browser.py |
BIDI_INTERVAL on Grid.bidi; SPARE_MARKER, SPARE_PAGE, ServiceWorkerAnswered, spare_tab. |
kubed/selenium_flow/core/site_data.py |
capture reads every history origin; snapshot replaces merge; restore writes cookies, localStorage, sessionStorage; view/views/site_view by host in history order; history_view, history_hosts; forget covers sessionStorage. Preload code deleted. |
kubed/selenium_flow/core/actions.py |
open_session restores directly; save_site_data reads self.visited(); retire_site_data deleted. |
kubed/selenium_flow/server.py |
Wires actions.visited. |
kubed/selenium_flow/secrets.py |
A bound write carries a reopen's report. |
kubed/selenium_flow/http/admin.py |
GET/DELETE …/history, GET/DELETE …/site-data, DELETE …/site-data/{site}; row fields history_count, history_rev, site_data_count, site_data_rev. |
kubed/selenium_flow/mcp/resources.py, mcp/tools.py, spec/schemas.py
|
The agent surface: listing in history order, the new save docstring, the declared shapes. |
ui/src/admin/HistoryPane.svelte (new), SiteDataPane.svelte, SessionDetail.svelte, Admin.svelte, router.svelte.ts, session.svelte.ts, ui/src/lib/types.ts
|
The two tabs. |
skills/selenium-flow/references/SITE_DATA.md, SESSIONS.md, SKILL.md, wiki/notes/save_site_data.notes.md, AGENTS.md, CHANGELOG.md, wiki/
|
The skill, the docs, the regenerated wiki. |
Tests: tests/test_history.py, tests/test_spare_tab.py, tests/site_data_fakes.py, tests/test_site_data_restore.py, tests/test_site_data_views.py, tests/test_admin_history.py, tests/test_site_data_save.py (all new); tests/test_site_data.py, tests/test_site_data_sessions.py, tests/test_admin_site_data.py, tests/test_site_data_surface.py (cut down or rewritten); ui/src/admin/HistoryPane.test.ts (new), SiteDataPane.test.ts (rewritten) |
|
tests/integration/flows/save-site-data.yaml |
Unchanged: it still reads #paneSiteData section.section, .title and code.key, which Task 9 keeps. |
Files:
- Modify:
kubed/selenium_flow/session/store.py(imports at 27–38; constants at 56–64;SessionRecordat 91–184) - Modify:
kubed/selenium_flow/session/sessions.py(remember702–761,touch763–786) - Modify (mechanical,
url=leaves the constructor):tests/test_admin_files.py,tests/test_kept_files.py,tests/test_files_and_admin.py,tests/test_site_data_surface.py,tests/test_secrets.py,tests/test_site_data.py,tests/test_sessions.py,tests/test_site_data_sessions.py,tests/bench/test_session_store.py - Create:
tests/test_history.py
Interfaces:
-
Consumes:
core.site_data.origin_of(url: str) -> str(exists:location.originform,""for a page without one). -
Produces (every later task relies on these):
store.HISTORY_CAP = 100-
SessionRecord.history: list[dict]— entries{"origin": str, "url": str, "at": float}, newest first, one per origin; default[]; round-tripped byto_json/from_json, a malformed entry dropped on read. -
SessionRecord.url -> str— a property:history[0]["url"], or"". No longer a constructor argument. -
SessionRecord.at(*urls: str | None, now: float | None = None, ttl: float = DEFAULT_TTL_SECONDS) -> SessionRecord— bumps each URL's origin to the top in order; a URL with no origin (or None) records nothing; drops entries older thanttlexcepthistory[0]; caps atHISTORY_CAP. -
SessionManager.touch(name: str, *urls: str | None, browser: str | None = None) -> None— positional single-URL calls keep working (Task 5 makes it returndict | None). -
SessionManager.remember(...)— same signature; the new record keeps the old one's history and recordsurl.
-
Step 1: Write the failing tests —
tests/test_history.py:
"""Where a session has been: the history on its record.
One entry per origin, newest first, and the current page is the top one. Only
a page with an origin enters it, and a URL withheld after a secret write
records nothing (§F1.24). Entries expire with the record's TTL, the top one
excepted."""
import pytest
from kubed.selenium_flow.session.store import (
HISTORY_CAP,
MemoryStore,
RedisStore,
SessionRecord,
)
from .conftest import NAMED, RecordingActions, manager
from .test_sessions import FakeRedis
pytestmark = pytest.mark.unit
DAY = 86400.0
def origins(record):
return [v["origin"] for v in record.history]
def test_a_page_bumps_its_origin_to_the_top_with_its_url_and_time():
record = SessionRecord().at("https://a.test/1", now=10.0).at("https://b.test/", now=20.0)
record = record.at("https://a.test/2", now=30.0)
assert record.history == [
{"origin": "https://a.test", "url": "https://a.test/2", "at": 30.0},
{"origin": "https://b.test", "url": "https://b.test/", "at": 20.0},
]
def test_the_current_url_is_the_top_of_the_history():
assert SessionRecord().url == ""
assert SessionRecord().at("https://a.test/x", now=1.0).url == "https://a.test/x"
def test_a_page_with_no_origin_or_a_withheld_url_records_nothing():
record = SessionRecord().at("https://a.test/x", now=1.0)
for nowhere in (None, "", "about:blank", "data:text/html,hi"):
assert record.at(nowhere, now=2.0).history == record.history
def test_several_pages_are_recorded_in_order():
record = SessionRecord().at("https://a.test/", "https://b.test/", "https://a.test/z", now=5.0)
assert [v["url"] for v in record.history] == ["https://a.test/z", "https://b.test/"]
def test_an_entry_older_than_the_ttl_goes_but_the_top_one_stays():
record = SessionRecord().at("https://old.test/", now=0.0).at("https://mid.test/", now=10.0)
later = record.at(None, now=DAY + 20.0, ttl=DAY)
assert origins(later) == ["https://mid.test"], "both expired; the top is where a reopen goes"
def test_at_most_a_hundred_entries_and_the_oldest_goes_first():
record = SessionRecord()
for i in range(HISTORY_CAP + 5):
record = record.at(f"https://h{i}.test/", now=float(i))
assert len(record.history) == HISTORY_CAP
assert record.history[0]["origin"] == f"https://h{HISTORY_CAP + 4}.test"
assert record.history[-1]["origin"] == "https://h5.test"
@pytest.mark.parametrize(
"store", [MemoryStore(), RedisStore(FakeRedis(), prefix="p:")], ids=lambda s: s.kind
)
def test_both_stores_round_trip_the_history(store):
record = SessionRecord(session_id="s").at("https://a.test/1", now=1.0).at("https://b.test/2", now=2.0)
store.set("k", record)
assert store.get("k").history == record.history
assert store.get("k").url == "https://b.test/2"
def test_a_record_from_before_the_history_reads_as_having_been_nowhere():
old = SessionRecord.from_json('{"session_id": "s", "url": "https://a.test/x"}')
assert (old.session_id, old.history, old.url) == ("s", [], "")
def test_a_malformed_entry_is_dropped_not_fatal():
raw = (
'{"session_id": "s", "history": ['
'{"origin": "https://a.test", "url": "https://a.test/", "at": 1.0}, '
'{"origin": 3}, "x", '
'{"origin": "https://b.test", "url": "https://b.test/", "at": "soon"}]}'
)
assert SessionRecord.from_json(raw).history == [
{"origin": "https://a.test", "url": "https://a.test/", "at": 1.0}
]
assert SessionRecord.from_json('{"session_id": "s", "history": {"a": 1}}').history == []
def test_touch_bumps_the_history_and_a_withheld_url_still_slides_the_ttl():
clock = [1000.0]
store = MemoryStore(ttl=60, clock=lambda: clock[0])
sessions = manager(store=store)
sessions.remember(NAMED, "abc", "https://a.test/")
sessions.touch(NAMED, "https://b.test/")
clock[0] += 50
sessions.touch(NAMED, None)
clock[0] += 50
record = store.get(NAMED)
assert record is not None, "the session expired while it was being used"
assert origins(record) == ["https://b.test", "https://a.test"]
def test_touch_from_a_browser_the_record_no_longer_names_records_nothing():
sessions = manager()
sessions.remember(NAMED, "new", "https://a.test/")
sessions.touch(NAMED, "https://elsewhere.test/", browser="old")
assert sessions.store.get(NAMED).url == "https://a.test/"
def test_opening_another_browser_keeps_where_the_session_has_been():
sessions = manager(RecordingActions())
sessions.remember(NAMED, "one", "https://a.test/")
sessions.remember(NAMED, "two", "https://b.test/", replacing="one")
assert origins(sessions.store.get(NAMED)) == ["https://b.test", "https://a.test"]
def test_ending_a_browser_keeps_the_history():
sessions = manager(RecordingActions())
sessions.remember(NAMED, "one", "https://a.test/")
sessions.end_browser(NAMED)
assert sessions.store.get(NAMED).url == "https://a.test/"- Step 2: Run to verify it fails
Run: PYTHONPATH=$S/deps:. python3 -S -m pytest -q tests/test_history.py
Expected: FAIL — ImportError: cannot import name 'HISTORY_CAP' from 'kubed.selenium_flow.session.store'.
- Step 3: Implement the record —
kubed/selenium_flow/session/store.py.
Add the import after from .. import errors (line 38):
from ..core.site_data import origin_ofAdd after UPDATE_RETRIES = 10 (line 64):
# How many origins a session's history keeps; the oldest goes first.
HISTORY_CAP = 100Replace the SessionRecord docstring and fields (lines 92–114, from """One flow session through the site_data field) with:
"""One flow session: its context, and the browser it currently holds.
``history`` and ``settings`` are the point of storing a record rather than
a bare id. They are what makes a browser replaceable: reopening and
navigating back to the last known page — the top of the history — in the
browser it was using makes a refresh invisible, and makes
``open_session`` with no arguments do the obvious thing after the browser
has gone.
``session_id`` is empty when no browser is attached, which is an ordinary
state rather than a broken one: the Grid reaped it, or an admin ended it.
"""
session_id: str = ""
opened_at: float = 0.0
# What the session was opened with, so a reopen uses the same browser
# rather than a default one.
settings: dict = field(default_factory=dict)
# Where the session has been: one {"origin", "url", "at"} per origin,
# newest first. Written by `at`; the admin History tab reads it.
history: list = field(default_factory=list)
# Cookies and storage the session saved (core/site_data.py). Kept with the
# record so it expires with it; never on this server's disk (with Redis,
# as durable as Redis).
site_data: dict = field(default_factory=dict)
@property
def url(self) -> str:
"""The page the session is on: the top of its history, or ""."""
return self.history[0]["url"] if self.history else ""In from_json, replace the return cls(...) (lines 149–155) with:
return cls(
session_id=str(data.get("session_id") or ""),
opened_at=float(data.get("opened_at", 0.0)),
settings=settings if isinstance(settings, dict) else {},
history=_visits(data.get("history")),
site_data=site_data if isinstance(site_data, dict) else {},
)Replace at (lines 160–162) with:
def at(
self,
*urls: str | None,
now: float | None = None,
ttl: float = DEFAULT_TTL_SECONDS,
) -> SessionRecord:
"""The same record, having landed on ``urls`` in order.
Each one with an origin moves that origin to the top with its URL and
the time. One without — None for a URL withheld after a secret write
(§F1.24), ``about:blank``, ``data:`` — records nothing. Entries older
than ``ttl`` go, except the top one: it is where a reopen goes back to.
"""
now = time.time() if now is None else now
history = list(self.history)
for url in urls:
origin = origin_of(url or "")
if origin:
history = [
{"origin": origin, "url": url, "at": now},
*(v for v in history if v["origin"] != origin),
]
kept = history[:1] + [v for v in history[1:] if v["at"] >= now - ttl]
return replace(self, history=kept[:HISTORY_CAP])Add after the class (before class SessionStore(Protocol):):
def _visits(raw) -> list[dict]:
"""The well-formed entries of a stored history. A record written before
there was one has none: it reads as a session that has been nowhere."""
if not isinstance(raw, list):
return []
return [
{"origin": v["origin"], "url": v["url"], "at": float(v["at"])}
for v in raw
if isinstance(v, dict)
and isinstance(v.get("origin"), str) and v["origin"]
and isinstance(v.get("url"), str) and v["url"]
and isinstance(v.get("at"), (int, float)) and not isinstance(v["at"], bool)
]- Step 4: Implement the session side —
kubed/selenium_flow/session/sessions.py.
In remember, replace the record built in bound (lines 736–742) with:
return SessionRecord(
session_id=session_id,
opened_at=time.time(),
settings=dict(settings or {}),
# Where the session has been survives a new browser.
history=list(r.history) if r is not None else [],
site_data={} if forget_site_data or r is None else dict(r.site_data),
).at(url, ttl=ttl)and add ttl = self.store.ttl as the first line of remember's body, before loser: dict = {}.
Replace touch (lines 763–786) with:
def touch(
self, name: str, *urls: str | None, browser: str | None = None
) -> None:
"""Record where the browser landed, and slide the record's TTL.
Called after an action so a later reopen goes back to the right page —
the top of the history — and so a session in active use does not
expire out of the store underneath the caller. A flow run passes every
page it reached, in order (`flows/api.run_for`).
**No URL still slides the TTL**, and records nothing. The two halves
are separate facts: "the browser is somewhere I should not write down"
is not "this session is idle". A bound write lands on `?q=<the
password>` and its URL is deliberately withheld (§F1.24); withholding
it used to stop the clock, so a flow that logs in every few minutes —
the one thing secrets exist for — expired out of the store while it
was being used.
``browser`` is the one that produced the result. Once the record names
another, this page is not that browser's: the TTL still slides, the
history is left alone (Copilot, #50).
"""
ttl = self.store.ttl
def at(r: SessionRecord) -> SessionRecord:
return r if browser and r.session_id != browser else r.at(*urls, ttl=ttl)
self.store.update(name, at)- Step 5: Rewrite the tests that built a record with
url=— a one-line constructor moves its literal into.at(...);url=""is dropped:
python3 - <<'EOF'
import pathlib
import re
# `url` is no longer a field: the page is the top of the history, which `at`
# writes. A literal url on one line moves into `.at(...)`; an empty one goes.
PATTERN = re.compile(r'SessionRecord\(([^()\n]*?)\burl=(f?"[^"]*")(?:, )?([^()\n]*)\)')
def fix(match):
args = (match[1] + match[3]).strip().removesuffix(",").strip()
call = f"SessionRecord({args})"
return call if match[2] == '""' else f"{call}.at({match[2]})"
for path in pathlib.Path("tests").glob("test_*.py"):
text = path.read_text()
fixed = PATTERN.sub(fix, text).replace(
'replace(r, url="https://b.test/")', 'r.at("https://b.test/")'
)
if fixed != text:
path.write_text(fixed)
print("rewrote", path)
EOFExpected: eight files rewritten (test_admin_files, test_files_and_admin, test_kept_files, test_secrets, test_sessions, test_site_data, test_site_data_sessions, test_site_data_surface).
Then the two multi-line constructions, by hand. In tests/test_sessions.py (the fresh test, lines 1204–1208):
SessionRecord(
session_id="",
url="https://app.test/orders",
settings={"browser": "firefox", "width": 1400, "height": 900},
),becomes
SessionRecord(
session_id="",
settings={"browser": "firefox", "width": 1400, "height": 900},
).at("https://app.test/orders"),and in tests/bench/test_session_store.py (lines 40–45):
SessionRecord(
session_id=f"{index:032x}" if index % 4 else "",
url=f"https://app.example.com/section/{index}?tab=overview",
opened_at=1_760_000_000.0 + index,
settings={"browser": "chrome", "width": 1440, "height": 900},
),becomes
SessionRecord(
session_id=f"{index:032x}" if index % 4 else "",
opened_at=1_760_000_000.0 + index,
settings={"browser": "chrome", "width": 1440, "height": 900},
).at(f"https://app.example.com/section/{index}?tab=overview"),Confirm nothing is left: grep -rn 'SessionRecord(' tests | grep 'url=' prints nothing, and grep -rn -B3 '^\s*url=' tests/*.py tests/bench/*.py | grep SessionRecord prints nothing.
- Step 6: Run to verify it passes
Run: PYTHONPATH=$S/deps:. python3 -S -m pytest -q tests/test_history.py → PASS. Then the whole unit suite (Global Constraints) → PASS, and $S/tools/bin/ruff check kubed tests scripts → clean.
-
Step 7: Break it on purpose — in
SessionRecord.at, changekept = history[:1] + [...]tokept = [v for v in history if v["at"] >= now - ttl]; confirmtest_an_entry_older_than_the_ttl_goes_but_the_top_one_staysfails; restore. -
Step 8: Commit
git add kubed/selenium_flow/session/store.py kubed/selenium_flow/session/sessions.py \
tests/test_history.py tests/test_admin_files.py tests/test_kept_files.py \
tests/test_files_and_admin.py tests/test_site_data_surface.py tests/test_secrets.py \
tests/test_site_data.py tests/test_sessions.py tests/test_site_data_sessions.py \
tests/bench/test_session_store.py
git commit -m "Site data round 2: the history on the record; url is its top entry" \
-m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"Files:
- Modify:
kubed/selenium_flow/flows/api.py(run_for, the touch at 574–589) - Test:
tests/test_history.py(append)
Interfaces:
-
Consumes: Task 1's
SessionManager.touch(name, *urls, browser=None). -
Produces:
flows.api.run_for(...)— unchanged signature and return; its one touch now carries, in order, every step'surlfrom the report (only steps withoktrue — a failed step's URL is the scrubbed one), then the run's ownurlunlessurl_redacted. -
Step 1: Write the failing tests — append to
tests/test_history.py:
# ---- a flow run writes every page it reached, in one update ------------------
def run_reporting(monkeypatch, report):
"""`run_for` around a canned run report: what reaches the history is
decided from the report alone, in one store write."""
from kubed.selenium_flow.flows import api as flowapi
monkeypatch.setattr(flowapi, "run_one", lambda *a, **kw: report)
sessions = manager(RecordingActions())
sessions.open_browser(NAMED, url="https://start.test/")
writes = []
real = sessions.store.update
def counting(key, fn):
writes.append(key)
return real(key, fn)
monkeypatch.setattr(sessions.store, "update", counting)
flowapi.run_for(None, sessions.actions, sessions, NAMED, "login")
return sessions, writes
def test_a_flow_run_records_each_steps_page_in_order_in_one_write(monkeypatch):
sessions, writes = run_reporting(monkeypatch, {
"status": "ok",
"steps": [
{"n": 1, "ok": True, "url": "https://app.test/login"},
{"n": 2, "ok": True, "url": "https://sso.test/auth"},
{"n": 3, "ok": True},
{"n": 4, "ok": True, "url": "https://app.test/home"},
],
"url": "https://app.test/home",
})
assert [v["url"] for v in sessions.store.get(NAMED).history] == [
"https://app.test/home", "https://sso.test/auth", "https://start.test/",
]
assert writes == [NAMED], "one update for the whole run"
def test_a_failed_step_and_a_redacted_end_record_nothing(monkeypatch):
sessions, _ = run_reporting(monkeypatch, {
"status": "failed",
"steps": [
{"n": 1, "ok": True, "url": "https://app.test/login"},
{"n": 2, "ok": False, "url": "https://other.test/?q=[hidden]"},
],
"url": "https://other.test/?q=[hidden]",
"url_redacted": True,
})
assert [v["url"] for v in sessions.store.get(NAMED).history] == [
"https://app.test/login", "https://start.test/",
]
def test_a_run_that_went_nowhere_still_slides_the_ttl(monkeypatch):
sessions, writes = run_reporting(monkeypatch, {"status": "ok", "steps": [{"n": 1, "ok": True}]})
assert writes == [NAMED]
assert sessions.store.get(NAMED).url == "https://start.test/"- Step 2: Run to verify they fail
Run: PYTHONPATH=$S/deps:. python3 -S -m pytest -q tests/test_history.py
Expected: FAIL — the first test's history is ["https://app.test/home", "https://start.test/"] (only the run's last page today), and the second records https://app.test/login nowhere.
- Step 3: Implement — in
kubed/selenium_flow/flows/api.py, replace the comment and touch at the end ofrun_for(lines 574–589, from# One touch for the whole runthroughreturn report) with:
# One touch for the whole run, not one per step: the point of running
# server-side is that the bookkeeping happens once. It carries every page
# the run reached, in order, so the history has each step's site and not
# only the last.
#
# Only pages the report itself shows. A step's `url` is there only when
# the step moved and the page was safe to show, so a failed step's — the
# scrubbed one, a URL that does not exist — is left out. And the run's own
# page is withheld when redaction had to touch it: a submitting bound
# write lands on `?q=<what was typed>`, and `sessions.resolve` would
# reopen the browser there after the Grid reaped it.
#
# The touch happens regardless: it slides the TTL, and a run is the
# clearest evidence there is that a session is in use.
visited = [
step["url"] for step in report.get("steps") or []
if step.get("ok") and step.get("url")
]
if report.get("url") and not report.get("url_redacted"):
visited.append(report["url"])
sessions.touch(session, *visited, browser=resolved)
return report-
Step 4: Run to verify they pass —
PYTHONPATH=$S/deps:. python3 -S -m pytest -q tests/test_history.py tests/test_flowapi.py tests/test_flowrun.py tests/test_site_data_sessions.py→ PASS; ruff clean. -
Step 5: Break it on purpose — drop
step.get("ok") andfrom the comprehension; confirmtest_a_failed_step_and_a_redacted_end_record_nothingfails; restore. -
Step 6: Commit
git add kubed/selenium_flow/flows/api.py tests/test_history.py
git commit -m "Site data round 2: a flow run writes every page it reached, in one update" \
-m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"Files:
- Modify:
kubed/selenium_flow/core/browser.py(constants afterBIDI_TIMEOUT, line 54;Grid.bidi291–314; new code afterclass Grid, beforedef _waited) - Create:
tests/test_spare_tab.py
Interfaces:
-
Produces (Tasks 4 and 8 use them through
site_data):-
browser.BIDI_INTERVAL = 0.005— set on everyGrid.bididriver ascommand_executor.client_config.websocket_interval(Selenium 4.49's own knob; the spike set exactly this). -
browser.SPARE_MARKER = "selenium-flow-spare",browser.SPARE_PAGE = '<!doctype html><meta name="selenium-flow-spare">' -
class browser.ServiceWorkerAnswered(RuntimeError)—str(exc)is the origin. -
browser.spare_tab(bidi, context: str | None = None)— a context manager yieldingrun(origin: str, expression: str) -> object.runnavigates the tab toorigin + "/"(wait="complete"), evaluatesexpressionthere only if the page carries the marker and stands onorigin, and returns its JSON value. RaisesServiceWorkerAnswered(no marker),RuntimeError(the tab is elsewhere, or the script threw), or whatever BiDi raises. Withcontextit intercepts that existing tab (the main one) and leaves it open; without, it opens a background tab and closes it. Teardown always runs and never raises.
-
-
The Selenium calls, as the spike proved them (Chrome 152, Firefox 155):
browsing_context.create(type="tab", background=True);network.add_intercept(phases=["beforeRequestSent"], contexts=[tab])["intercept"];network.add_event_handler("before_request", fn, contexts=[tab])— notbefore_request_sent, which handsfnan event without the request;fngets a plain dict on its own thread;network.provide_response(request=, status_code=200, headers=[…], body={"type": "string", "value": …});browsing_context.navigate(context=, url=, wait="complete");script.evaluate(expression=, target={"context": tab}, await_promise=True)returns{"type": "success", "result": {"type": …, "value": …}}or{"type": "exception", "exceptionDetails": {"text": …}};network.remove_event_handler("before_request", id)— Selenium subscribes once per event with the first handler's contexts and unsubscribes when the last handler goes, so removing it is what lets the next tab subscribe afresh;network.remove_intercept(intercept=);browsing_context.close(context=). Firefox also requests/favicon.ico; it gets the same page, harmlessly. -
Step 1: Write the failing tests —
tests/test_spare_tab.py:
"""The spare tab: a tab whose every request is answered with a marked blank
page, so it can stand on any origin without the site loading (spec round 2,
*The spike*). BiDi is faked module by module; what `spare_tab` sends, and in
which order, is the contract the live spike proved."""
import json
import re
import pytest
from kubed.selenium_flow.core.browser import (
BIDI_INTERVAL,
SPARE_MARKER,
SPARE_PAGE,
Grid,
ServiceWorkerAnswered,
spare_tab,
)
pytestmark = pytest.mark.unit
class FakeTabs:
def __init__(self, log):
self.log, self.at = log, {}
def create(self, type=None, background=None, **_):
self.log.append(("create", type, background))
return "spare-1"
def navigate(self, context=None, url=None, wait=None):
self.log.append(("navigate", context, url, wait))
self.at[context] = url.rstrip("/")
def close(self, context=None):
self.log.append(("close", context))
class FakeNetwork:
def __init__(self, log):
self.log, self.handlers, self.responses = log, {}, []
def add_intercept(self, phases=None, contexts=None, **_):
self.log.append(("intercept", phases, contexts))
return {"intercept": "i-1"}
def remove_intercept(self, intercept=None):
self.log.append(("unintercept", intercept))
def add_event_handler(self, event, callback, contexts=None):
self.log.append(("handler", event, contexts))
self.handlers[7] = callback
return 7
def remove_event_handler(self, event, callback_id):
self.log.append(("unhandler", event, callback_id))
def provide_response(self, **kw):
self.responses.append(kw)
class FakeScript:
"""Answers as the page would: ours, with the marker, or a service
worker's. ``stuck`` leaves the tab on the page it was on before."""
def __init__(self, log, tabs, value=None, workers=(), stuck=False, fail=None):
self.log, self.tabs = log, tabs
self.value, self.workers, self.stuck, self.fail = value, set(workers), stuck, fail
self.expression = None
def evaluate(self, expression=None, target=None, await_promise=None):
self.log.append(("evaluate", target, await_promise))
self.expression = expression
if self.fail:
return {"type": "exception", "exceptionDetails": {"text": self.fail}}
origin = "https://before.test" if self.stuck else self.tabs.at[target["context"]]
page = {"spare": origin not in self.workers, "origin": origin}
if page["spare"]:
page["value"] = self.value
return {"type": "success", "result": {"type": "string", "value": json.dumps(page)}}
class FakeBidi:
def __init__(self, **kw):
self.log = []
self.browsing_context = FakeTabs(self.log)
self.network = FakeNetwork(self.log)
self.script = FakeScript(self.log, self.browsing_context, **kw)
def test_a_spare_tab_stands_on_an_origin_runs_there_and_closes():
bidi = FakeBidi(value={"theme": "dark"})
with spare_tab(bidi) as run:
assert run("https://app.test", "localStorage.length") == {"theme": "dark"}
assert bidi.log == [
("create", "tab", True),
("intercept", ["beforeRequestSent"], ["spare-1"]),
("handler", "before_request", ["spare-1"]),
("navigate", "spare-1", "https://app.test/", "complete"),
("evaluate", {"context": "spare-1"}, True),
("unhandler", "before_request", 7),
("unintercept", "i-1"),
("close", "spare-1"),
]
assert SPARE_MARKER in bidi.script.expression
assert "localStorage.length" in bidi.script.expression
def test_every_blocked_request_is_answered_with_the_marked_page():
bidi = FakeBidi()
with spare_tab(bidi):
answer = bidi.network.handlers[7]
answer({"isBlocked": True, "request": {"request": "r-1", "url": "https://app.test/"}})
answer({"isBlocked": False, "request": {"request": "r-2", "url": "https://app.test/x"}})
answer("not an event")
assert bidi.network.responses == [{
"request": "r-1",
"status_code": 200,
"headers": [{"name": "content-type", "value": {"type": "string", "value": "text/html"}}],
"body": {"type": "string", "value": SPARE_PAGE},
}]
assert SPARE_MARKER in SPARE_PAGE
def test_a_refused_response_never_raises_on_the_event_thread():
bidi = FakeBidi()
def refuse(**kw):
raise RuntimeError("no such request")
bidi.network.provide_response = refuse
with spare_tab(bidi):
bidi.network.handlers[7]({"isBlocked": True, "request": {"request": "r-1"}})
def test_a_service_worker_page_is_refused_before_anything_runs_there():
bidi = FakeBidi(value="read", workers={"https://sw.test"})
with pytest.raises(ServiceWorkerAnswered, match=re.escape("https://sw.test")), spare_tab(bidi) as run:
run("https://sw.test", "localStorage.length")
assert bidi.log[-1] == ("close", "spare-1"), "the tab still closes"
def test_a_tab_that_did_not_move_is_never_read_as_the_new_origin():
bidi = FakeBidi(value="stale", stuck=True)
with pytest.raises(RuntimeError, match=re.escape("not https://app.test")), spare_tab(bidi) as run:
run("https://app.test", "1")
def test_a_script_that_throws_raises_its_text_and_the_tab_still_closes():
bidi = FakeBidi(fail="boom")
with pytest.raises(RuntimeError, match="boom"), spare_tab(bidi) as run:
run("https://app.test", "1")
assert bidi.log[-1] == ("close", "spare-1")
def test_an_existing_tab_is_intercepted_and_left_open():
bidi = FakeBidi(value=1)
with spare_tab(bidi, context="main-1") as run:
run("https://app.test", "1")
kinds = [entry[0] for entry in bidi.log]
assert "create" not in kinds and "close" not in kinds
assert ("intercept", ["beforeRequestSent"], ["main-1"]) in bidi.log
def test_the_bidi_socket_is_polled_every_few_milliseconds():
"""Selenium's 100 ms default put a 100 ms floor under every BiDi call
(spike, 2026-10-01)."""
with Grid("http://grid.example:4444").bidi("abc") as driver:
assert driver.command_executor.client_config.websocket_interval == BIDI_INTERVAL
assert BIDI_INTERVAL <= 0.01- Step 2: Run to verify they fail
Run: PYTHONPATH=$S/deps:. python3 -S -m pytest -q tests/test_spare_tab.py
Expected: FAIL — ImportError: cannot import name 'BIDI_INTERVAL'.
- Step 3: Implement —
kubed/selenium_flow/core/browser.py.
After BIDI_TIMEOUT = 5.0 (line 54) add:
# How often Selenium looks for a BiDi reply. Its 100 ms default is a floor
# under every BiDi command; at 5 ms a spare tab costs 30-140 ms per origin.
BIDI_INTERVAL = 0.005
# What a spare tab is answered with. The marker tells it from a site's own
# page, which a service worker serves before any intercept sees the request.
SPARE_MARKER = "selenium-flow-spare"
SPARE_PAGE = f'<!doctype html><meta name="{SPARE_MARKER}">'In Grid.bidi, replace the docstring and the timeout lines (292–305) so the method reads:
@contextlib.contextmanager
def bidi(self, session_id: str):
"""A reattached driver that speaks BiDi: ``.storage``, ``.network``,
``.browsing_context``, ``.script``.
The socket address is derived, not discovered: the Grid proxies it at
``/session/<id>/se/bidi``.
"""
parts = urlsplit(self.url)
scheme = "wss" if parts.scheme == "https" else "ws"
socket = urlunsplit((scheme, parts.netloc, parts.path.rstrip("/"), "", ""))
driver = self.reconnect(session_id)
driver.caps = {"webSocketUrl": f"{socket}/session/{session_id}/se/bidi"}
# Selenium waits 30 s for a socket that never answers; an open would
# stall that long on a Grid whose BiDi route is down.
driver.command_executor.client_config.websocket_timeout = BIDI_TIMEOUT
driver.command_executor.client_config.websocket_interval = BIDI_INTERVAL
try:
yield driver
finally:
# Selenium opens the socket lazily on first use; closing it is the
# only cleanup, the browser is not ours to quit.
connection = getattr(driver, "_websocket_connection", None)
if connection is not None:
with contextlib.suppress(Exception):
connection.close()After the end of class Grid (before def _waited) add:
class ServiceWorkerAnswered(RuntimeError):
"""A spare tab got the site's own page: its service worker answered before
the intercept could. Nothing is read or written there."""
@contextlib.contextmanager
def spare_tab(bidi, context: str | None = None):
"""A tab whose every request is answered with :data:`SPARE_PAGE`, so it
can stand on any origin without that site loading — Playwright's way to
reach another origin's storage.
Yields ``run(origin, expression)``: stand on ``origin`` and return what
``expression`` evaluates to there, JSON-safe. A page without the marker is
a service worker's (:class:`ServiceWorkerAnswered`) and is never touched.
With ``context``, that existing tab is intercepted instead and left open:
the main tab, for its sessionStorage. Selenium subscribes once per event,
with the first handler's contexts, so the handler is removed on the way
out and the next tab subscribes afresh.
"""
tab = context or bidi.browsing_context.create(type="tab", background=True)
intercept = handler = None
try:
intercept = bidi.network.add_intercept(
phases=["beforeRequestSent"], contexts=[tab]
)["intercept"]
def answer(event):
# On Selenium's own thread, one per event: a refusal shows up as a
# page without the marker, not as an error here.
if not isinstance(event, dict) or not event.get("isBlocked"):
return
with contextlib.suppress(Exception):
bidi.network.provide_response(
request=event["request"]["request"],
status_code=200,
headers=[{
"name": "content-type",
"value": {"type": "string", "value": "text/html"},
}],
body={"type": "string", "value": SPARE_PAGE},
)
# `before_request`, not `before_request_sent`: the other name hands
# the callback an event without the request's fields.
handler = bidi.network.add_event_handler(
"before_request", answer, contexts=[tab]
)
def run(origin: str, expression: str):
bidi.browsing_context.navigate(
context=tab, url=origin + "/", wait="complete"
)
reply = bidi.script.evaluate(
expression=_on_spare(expression),
target={"context": tab},
await_promise=True,
)
if reply.get("type") != "success":
details = reply.get("exceptionDetails") or {}
raise RuntimeError(details.get("text") or "the script failed")
got = json.loads(reply["result"]["value"])
if not got["spare"]:
raise ServiceWorkerAnswered(origin)
if got["origin"] != origin:
# A navigation that did not land must never be read as this
# origin: the tab still holds the last one's page.
raise RuntimeError(f"the spare tab is on {got['origin']}, not {origin}")
return got["value"]
yield run
finally:
if handler is not None:
with contextlib.suppress(Exception):
bidi.network.remove_event_handler("before_request", handler)
if intercept is not None:
with contextlib.suppress(Exception):
bidi.network.remove_intercept(intercept=intercept)
if context is None:
with contextlib.suppress(Exception):
bidi.browsing_context.close(context=tab)
def _on_spare(expression: str) -> str:
"""``expression``, run only on our page, as a JSON string that also says
whose page it was and where."""
return (
"JSON.stringify(document.querySelector('meta[name=\"" + SPARE_MARKER + "\"]')"
" ? {spare: true, origin: location.origin, value: (" + expression + ")}"
" : {spare: false, origin: location.origin})"
)-
Step 4: Run to verify they pass —
PYTHONPATH=$S/deps:. python3 -S -m pytest -q tests/test_spare_tab.py tests/test_site_data.py tests/test_browser_choice.py→ PASS; ruff clean. -
Step 5: Break it on purpose — change
"before_request"inadd_event_handlerto"before_request_sent"; confirmtest_a_spare_tab_stands_on_an_origin_runs_there_and_closesfails; restore. -
Step 6: Commit
git add kubed/selenium_flow/core/browser.py tests/test_spare_tab.py
git commit -m "Site data round 2: the spare tab, and BiDi polled every 5 ms" \
-m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"Files:
- Modify:
kubed/selenium_flow/core/site_data.py— module docstring (line 11–13),MARKER(31–33),merge's session filter (99–105), deletepreload_source(136–152),_add_preload(427–429),restore(492–552),arrive(555–572),retire(575–579),replace(582–604); addSW_REASON,READ_LOCAL,fill,restorable,_why,_restore_cookies, the newrestore. - Modify:
kubed/selenium_flow/core/actions.py—open_session(364–466); deleteretire_site_data(479–492). - Modify:
kubed/selenium_flow/spec/schemas.py—SITE_DATA_HINT(24–67). - Create:
tests/site_data_fakes.py,tests/test_site_data_restore.py - Modify:
tests/test_site_data.py— delete the restore, preload and Actions-open tests (Step 5).
Interfaces:
-
Consumes: Task 3's
browser.spare_tab,browser.ServiceWorkerAnswered. -
Produces:
- The snapshot shape every later task reads and Task 8 writes:
{"cookies": [cookie], "origins": {origin: {"local": {k: v}}}, "session": {"origin": str, "items": {k: v}}, "saved_at": float}. A cookie dict is round 1's:name, value, value_type, domain, path, http_only, secure, same_site, expiry. site_data.SW_REASON = "a service worker answered: save while on this site"-
site_data.READ_LOCAL: str— an expression evaluating to the origin's localStorage as an object. -
site_data.fill(store: str, items: dict) -> str—storeis"localStorage"or"sessionStorage"; the expression sets every item and evaluates to how many. -
site_data.restorable(data: dict) -> bool— cookies, any origin, or session items. -
site_data.restore(bidi, data: dict, now: float) -> dict→{"restored": [host, …] (sorted), "skipped": [item, …], "uri": "session://site-data"}. Never raises. A skip item is{"cookie", "domain", "reason"}(a cookie),{"site", "reason"}(an origin's storage) or{"reason"}(the whole channel).bidimust offer.storage,.browsing_context,.current_window_handle(the main tab's context id, which equals the window handle), and is whatspare_tabdrives. -
Actions.open_session(..., site_data=None)— restores beforedriver.get(url); the result carries"site_data": reportonly whenreport["restored"] or report["skipped"]; never_site_data_pending. - Deleted:
MARKER,preload_source,arrive,retire,replace,_add_preload,Actions.retire_site_data.
- The snapshot shape every later task reads and Task 8 writes:
-
Note: until Task 5 lands,
sessions.pystill has its pending-note code; it receives no_site_data_pendingany more, stores no note, and so a silent reopen's report is not announced. Task 5 restores that, without the note. -
Step 1: Write the shared fakes —
tests/site_data_fakes.py(not collected: notest_prefix):
"""Fakes for the site data tests: a cookie jar, the driver `Grid.bidi`
yields, and the spare tab. Shared by the restore and the save tests, so the
two fake one browser."""
import json
from contextlib import contextmanager
from types import SimpleNamespace
from kubed.selenium_flow.core import site_data as sd
from kubed.selenium_flow.core.browser import ServiceWorkerAnswered
NOW = 1_790_800_000.0
MAIN = "main-1"
def cookie(name, domain, value="v", http_only=False, expiry=None, secure=True):
"""A cookie as the snapshot stores it."""
return {
"name": name, "value": value, "value_type": "string", "domain": domain,
"path": "/", "http_only": http_only, "secure": secure,
"same_site": "lax", "expiry": expiry,
}
def snapshot(cookies=(), origins=None, session=None, saved_at=NOW):
"""A saved snapshot in round 2's shape; ``origins`` maps an origin to its
localStorage."""
return {
"cookies": list(cookies),
"origins": {o: {"local": dict(v)} for o, v in (origins or {}).items()},
"session": dict(session or {}),
"saved_at": saved_at,
}
def bidi_cookie(name, domain, value="v", http_only=False):
"""A cookie as BiDi's storage.getCookies returns it."""
return SimpleNamespace(
name=name, domain=domain, path="/", http_only=http_only, secure=True,
same_site="lax", expiry=None, value=SimpleNamespace(type="string", value=value),
)
class FakeStorage:
"""A jar: ``cookies`` are there from the start; what is set reads back,
except a ``drop``ped name — accepted without an error and never kept, as
Chrome does to SameSite=None without Secure."""
def __init__(self, cookies=(), refuse=(), drop=(), order=None):
self.cookies, self.set = list(cookies), []
self.refuse, self.drop, self.order = set(refuse), set(drop), order
def get_cookies(self, filter=None, partition=None):
held = [
SimpleNamespace(name=c.name, domain=c.domain, path=c.path)
for c in self.set
if c.name not in self.drop and not (c.same_site == "none" and not c.secure)
]
return SimpleNamespace(cookies=self.cookies + held)
def set_cookie(self, cookie=None, partition=None):
if cookie.name in self.refuse:
raise RuntimeError("unable to set cookie")
self.set.append(cookie)
if self.order is not None:
self.order.append("cookie")
class FakeTabs:
def __init__(self):
self.navigated = []
def navigate(self, context=None, url=None, wait=None):
self.navigated.append((context, url))
class FakeBidi:
"""What `Grid.bidi` yields: the jar, the main tab's id, navigation."""
current_window_handle = MAIN
def __init__(self, order=None, **kw):
self.storage = FakeStorage(order=order, **kw)
self.browsing_context = FakeTabs()
def bidi_cm(fake):
"""A stand-in for `Grid.bidi` that yields ``fake``."""
@contextmanager
def cm(session_id):
yield fake
return cm
class FakeSpare:
"""`browser.spare_tab`, faked over two dicts of storage by origin.
An origin in ``refuse`` fails to load, one in ``workers`` is answered by a
service worker, and ``broken`` makes the tab itself fail to open. Each run
is logged as (tab, origin); ``order``, when given, collects the tab too.
"""
def __init__(self, local=None, refuse=(), workers=(), broken=None, order=None):
self.local = {o: dict(v) for o, v in (local or {}).items()}
self.session: dict = {}
self.refuse, self.workers = set(refuse), set(workers)
self.broken, self.order = broken, order
self.runs: list = []
self.tabs: list = []
@contextmanager
def __call__(self, bidi, context=None):
if self.broken:
raise self.broken
tab = context or "spare"
self.tabs.append(tab)
def run(origin, expression):
self.runs.append((tab, origin))
if self.order is not None:
self.order.append(tab)
if origin in self.workers:
raise ServiceWorkerAnswered(origin)
if origin in self.refuse:
raise RuntimeError("the navigation failed")
if expression == sd.READ_LOCAL:
return dict(self.local.get(origin, {}))
store, items = filled(expression)
target = self.session if store == "sessionStorage" else self.local
target.setdefault(origin, {}).update(items)
return len(items)
yield run
def filled(expression):
"""What a `site_data.fill` expression sets, and in which store: it carries
its items as JSON."""
store = "sessionStorage" if "sessionStorage.setItem" in expression else "localStorage"
items = json.loads(expression.split("const items = ", 1)[1].split("; for ", 1)[0])
return store, items- Step 2: Write the failing tests —
tests/test_site_data_restore.py:
"""A restore, straight into the browser before its first page: cookies, then
each origin's localStorage in a spare tab, then the saved sessionStorage in
the main tab (spec round 2, *open_session*). The spare tab is faked at
`site_data.spare_tab`; `browser.spare_tab` has its own tests."""
import json
import shutil
import subprocess
import pytest
from kubed.selenium_flow.core import site_data as sd
from .site_data_fakes import (
MAIN,
NOW,
FakeBidi,
FakeSpare,
FakeStorage,
bidi_cm,
cookie,
snapshot,
)
pytestmark = pytest.mark.unit
LIST = "session://site-data"
APP = "https://app.example.com"
@pytest.fixture
def spare(monkeypatch):
fake = FakeSpare()
monkeypatch.setattr(sd, "spare_tab", fake)
return fake
def test_cookies_then_each_origins_storage_then_session_storage(spare):
order = []
spare.order = order
bidi = FakeBidi(order=order)
data = snapshot(
cookies=[cookie("sid", "app.example.com"), cookie("kc", "sso.example.com"),
cookie("old", "app.example.com", expiry=int(NOW) - 1)],
origins={APP: {"theme": "dark"}, "https://sso.example.com": {"kc": "1"}},
session={"origin": APP, "items": {"token": "t"}},
)
report = sd.restore(bidi, data, NOW)
assert [c.name for c in bidi.storage.set] == ["sid", "kc"], "the expired one stays out"
assert spare.local == {APP: {"theme": "dark"}, "https://sso.example.com": {"kc": "1"}}
assert spare.session == {APP: {"token": "t"}}
assert order == ["cookie", "cookie", "spare", "spare", MAIN]
assert spare.tabs == ["spare", MAIN], "one spare tab for every origin, then the main tab"
assert bidi.browsing_context.navigated == [(MAIN, "about:blank")], "never left on the stand-in"
assert report == {"restored": ["app.example.com", "sso.example.com"], "skipped": [], "uri": LIST}
def test_a_cookie_only_snapshot_opens_no_tab(spare):
report = sd.restore(FakeBidi(), snapshot(cookies=[cookie("sid", "app.example.com")]), NOW)
assert spare.tabs == []
assert report == {"restored": ["app.example.com"], "skipped": [], "uri": LIST}
def test_an_origin_that_fails_is_skipped_and_the_rest_go_on(spare):
spare.refuse = {"https://bad.example.com"}
data = snapshot(origins={"https://bad.example.com": {"a": "1"}, "https://ok.example.com": {"b": "2"}})
report = sd.restore(FakeBidi(), data, NOW)
assert spare.local == {"https://ok.example.com": {"b": "2"}}
assert report["restored"] == ["ok.example.com"]
assert report["skipped"] == [{"site": "https://bad.example.com", "reason": "the navigation failed"}]
def test_session_storage_that_fails_is_skipped_not_fatal(spare):
spare.refuse = {APP}
data = snapshot(session={"origin": APP, "items": {"t": "1"}})
assert sd.restore(FakeBidi(), data, NOW) == {
"restored": [], "uri": LIST,
"skipped": [{"site": APP, "reason": "the navigation failed"}],
}
def test_a_tab_that_cannot_open_skips_its_origins_and_the_cookies_stand(spare):
spare.broken = RuntimeError("socket is already closed")
data = snapshot(
cookies=[cookie("sid", "app.example.com")],
origins={APP: {"k": "v"}},
session={"origin": "https://sso.example.com", "items": {"t": "1"}},
)
report = sd.restore(FakeBidi(), data, NOW)
assert report["restored"] == ["app.example.com"], "the cookie came back"
assert report["skipped"] == [
{"site": APP, "reason": "socket is already closed"},
{"site": "https://sso.example.com", "reason": "socket is already closed"},
]
def test_a_dead_channel_is_reported_never_raised(spare):
spare.broken = RuntimeError("socket is already closed")
class Dead:
current_window_handle = MAIN
@property
def storage(self):
raise RuntimeError("socket is already closed")
data = snapshot(cookies=[cookie("sid", "a.example.com")], origins={"https://a.example.com": {"k": "v"}})
report = sd.restore(Dead(), data, NOW)
assert report["restored"] == []
assert {s["reason"] for s in report["skipped"]} == {"socket is already closed"}
def test_a_none_same_site_without_secure_is_restored_as_lax(spare):
"""Chrome reports an unspecified SameSite as "none" and silently refuses
None without Secure: the reopened session came back signed out."""
loose = {**cookie("sid", "app.example.com", secure=False), "same_site": "none"}
tight = {**cookie("tok", "app.example.com", secure=True), "same_site": "none"}
fox = {**cookie("fx", "app.example.com", secure=False), "same_site": "default"}
bidi = FakeBidi()
report = sd.restore(bidi, snapshot(cookies=[loose, tight, fox]), NOW)
assert {c.name: c.same_site for c in bidi.storage.set} == {"sid": "lax", "tok": "none", "fx": "default"}
assert report["skipped"] == [] and report["restored"] == ["app.example.com"]
def test_a_cookie_refused_or_not_kept_is_skipped(spare):
data = snapshot(cookies=[cookie("gone", "only.example.com"), cookie("ok", "app.example.com"),
cookie("bad", "refused.example.com")])
report = sd.restore(FakeBidi(drop={"gone"}, refuse={"bad"}), data, NOW)
assert report["skipped"] == [
{"cookie": "bad", "domain": "refused.example.com", "reason": "unable to set cookie"},
{"cookie": "gone", "domain": "only.example.com", "reason": "the browser did not keep it"},
]
assert report["restored"] == ["app.example.com"], "a host with nothing kept is not restored"
def test_an_unreadable_jar_trusts_the_set(spare):
class Blind(FakeStorage):
def get_cookies(self, filter=None, partition=None):
raise RuntimeError("no read")
bidi = FakeBidi()
bidi.storage = Blind()
report = sd.restore(bidi, snapshot(cookies=[cookie("ok", "app.example.com")]), NOW)
assert report["restored"] == ["app.example.com"] and report["skipped"] == []
def test_one_malformed_cookie_is_skipped_not_the_whole_restore(spare):
good = cookie("ok", "app.example.com")
nameless = {k: v for k, v in cookie("x", "app.example.com").items() if k != "name"}
domainless = {**cookie("nodomain", "app.example.com"), "domain": None}
stringy = cookie("odd", "app.example.com", expiry="tomorrow")
bidi = FakeBidi()
report = sd.restore(bidi, snapshot(cookies=[nameless, domainless, stringy, good]), NOW)
assert [c.name for c in bidi.storage.set] == ["ok"]
# Only string fields ride along: the published shape declares them strings.
assert report["skipped"] == [
{"reason": "a stored cookie with no name or domain", "domain": "app.example.com"},
{"reason": "a stored cookie with no name or domain", "cookie": "nodomain"},
{"reason": "a stored cookie whose expiry is not a time", "cookie": "odd",
"domain": "app.example.com"},
]
def test_a_failure_never_leaks_the_grid_credential(spare):
leak = "http://user:pass@grid:4444"
spare.broken = RuntimeError(f"connect to {leak}/session failed")
bidi = FakeBidi()
def refuse(cookie=None, partition=None):
raise RuntimeError(f"refused by {leak}")
bidi.storage.set_cookie = refuse
data = snapshot(cookies=[cookie("sid", "a.example.com")], origins={"https://a.example.com": {"k": "v"}})
report = sd.restore(bidi, data, NOW)
assert len(report["skipped"]) == 2
assert "user:pass" not in json.dumps(report)
def test_every_skipped_item_restore_emits_is_declared(spare):
from kubed.selenium_flow.spec.schemas import SITE_DATA_HINT
declared = SITE_DATA_HINT["properties"]["skipped"]["items"]
spare.refuse = {"https://bad.example.com"}
data = snapshot(
cookies=[cookie("gone", "a.example.com"), cookie("bad", "b.example.com"), {"name": "x"}],
origins={"https://bad.example.com": {"k": "v"}},
)
report = sd.restore(FakeBidi(drop={"gone"}, refuse={"bad"}), data, NOW)
assert len(report["skipped"]) == 4
for item in report["skipped"]:
assert set(item) <= set(declared["properties"]), item
assert set(declared["required"]) <= set(item)
assert "waiting" not in SITE_DATA_HINT["properties"], "nothing waits: every origin is in place"
@pytest.mark.skipif(shutil.which("node") is None, reason="needs node")
def test_the_storage_scripts_fill_and_read_for_real(tmp_path):
harness = tmp_path / "storage.js"
harness.write_text(
"const store = () => { const s = new Map(); return {\n"
" getItem: (k) => (s.has(k) ? s.get(k) : null), setItem: (k, v) => { s.set(k, String(v)) },\n"
" get length() { return s.size }, key: (i) => [...s.keys()][i] } };\n"
"Object.defineProperty(globalThis, 'localStorage', { value: store(), configurable: true });\n"
"Object.defineProperty(globalThis, 'sessionStorage', { value: store(), configurable: true });\n"
"const [local, session, read] = JSON.parse(process.argv[2]);\n"
"const counts = [eval(local), eval(session)];\n"
"console.log(JSON.stringify({counts, local: eval(read), session: sessionStorage.getItem('t')}));\n",
encoding="utf-8",
)
items = {"theme": "dark", "quote": "a'b\"c</script>"}
scripts = [sd.fill("localStorage", items), sd.fill("sessionStorage", {"t": "1"}), sd.READ_LOCAL]
out = subprocess.run(
["node", str(harness), json.dumps(scripts)], capture_output=True, text=True, check=False
)
assert out.returncode == 0, out.stderr
assert json.loads(out.stdout) == {"counts": [2, 1], "local": items, "session": "1"}
# ---- open_session restores before the first page ------------------------------
class OpenedDriver:
session_id = "new-1"
current_url = APP + "/"
title = "App"
def __init__(self, order):
self.order = order
def get_window_size(self):
return {"width": 1, "height": 2}
def get(self, url):
self.order.append("get")
def opened(actions, monkeypatch, spare, data, **kw):
order = []
spare.order = order
monkeypatch.setattr(actions.grid, "open", lambda name, insecure=False: OpenedDriver(order))
monkeypatch.setattr(actions.grid, "bidi", bidi_cm(FakeBidi(order=order)))
return actions.open_session(url=APP + "/", site_data=data, **kw), order
FULL = snapshot(
cookies=[cookie("sid", "app.example.com")],
origins={APP: {"a": "1"}},
session={"origin": APP, "items": {"t": "1"}},
)
def test_open_session_restores_everything_before_the_first_page(actions, monkeypatch, spare):
result, order = opened(actions, monkeypatch, spare, FULL)
assert order == ["cookie", "spare", MAIN, "get"]
assert result["site_data"] == {"restored": ["app.example.com"], "skipped": [], "uri": LIST}
assert "_site_data_pending" not in result
def test_an_insecure_browser_gets_no_saved_site_data(actions, monkeypatch, spare):
"""A browser that accepts any certificate would hand saved cookies for
every site to whoever sits in the middle (security review, #49)."""
result, order = opened(actions, monkeypatch, spare, FULL, insecure=True)
assert order == ["get"], "nothing set, no tab"
assert result["site_data"] == {
"restored": [], "skipped": [{"reason": "an insecure browser gets no saved site data"}],
"uri": LIST,
}
def test_open_session_says_nothing_without_site_data(actions, monkeypatch, spare):
for empty in (None, {}, snapshot()):
result, order = opened(actions, monkeypatch, spare, empty)
assert order == ["get"]
assert "site_data" not in result
def test_a_restore_with_nothing_to_say_carries_no_hint(actions, monkeypatch, spare):
data = snapshot(cookies=[cookie("old", "app.example.com", expiry=int(NOW) - 1)])
result, order = opened(actions, monkeypatch, spare, data)
assert order == ["get"] and "site_data" not in result- Step 3: Run to verify they fail
Run: PYTHONPATH=$S/deps:. python3 -S -m pytest -q tests/test_site_data_restore.py
Expected: FAIL — AttributeError: module ... has no attribute 'spare_tab' (from the fixture) and READ_LOCAL.
- Step 4: Implement —
kubed/selenium_flow/core/site_data.py.
In the module docstring, replace the sentence Storage is kept per origin, keyed by the page's own ``location.origin`` because that is the string the preload script compares. with Storage is kept per origin, as ``location.origin`` spells it.
Add after from ..errors import message as failure_text (line 26):
from .browser import ServiceWorkerAnswered, spare_tabDelete the MARKER constant and its two comment lines (31–33). After the BIDI_DOWN constant add:
# Why an origin's storage was not read: its own service worker answered the
# spare tab (spec round 2, *Service workers at save time*).
SW_REASON = "a service worker answered: save while on this site"In merge, the marker filter goes (the marker is gone). Replace
"session": {
k: v for k, v in (captured.get("session") or {}).items()
if not k.startswith(MARKER)
},with
"session": dict(captured.get("session") or {}),Replace preload_source (from def preload_source through its ) closing the return, lines 136–152) with:
# Evaluated in a spare tab standing on an origin: its localStorage, whole.
READ_LOCAL = (
"(() => { const out = {}; for (let i = 0; i < localStorage.length; i++) "
"{ const k = localStorage.key(i); out[k] = localStorage.getItem(k); } "
"return out; })()"
)
def fill(store: str, items: dict) -> str:
"""An expression that sets every item in ``store`` (``localStorage`` or
``sessionStorage``) and evaluates to how many it set."""
return (
"(() => { const items = " + json.dumps(items) + "; "
"for (const [k, v] of Object.entries(items)) " + store + ".setItem(k, v); "
"return Object.keys(items).length; })()"
)
def restorable(data: dict) -> bool:
"""Whether a snapshot holds anything a restore could put back."""
data = data or {}
return bool(
data.get("cookies") or data.get("origins")
or (data.get("session") or {}).get("items")
)
def _why(exc: Exception) -> str:
return SW_REASON if isinstance(exc, ServiceWorkerAnswered) else failure_text(exc)Delete _add_preload (lines 427–429).
Replace everything from def restore( to the end of the file (restore, arrive, retire, replace; lines 492–604) with:
def _restore_cookies(bidi, stored: list, now: float, skipped: list) -> list[dict]:
"""Set every live, usable cookie; return the ones the browser kept. Each
one refused, dropped or unusable is added to ``skipped``."""
from selenium.webdriver.common.bidi.storage import BytesValue, PartialCookie
cookies = live_cookies([c for c in stored if _usable(c)], now)
# One bad entry is skipped, not the whole restore.
skipped.extend(_malformed(c) for c in stored if not _usable(c))
sent = []
for c in cookies:
try:
bidi.storage.set_cookie(PartialCookie(
c["name"],
BytesValue(c.get("value_type") or "string", c.get("value")),
c["domain"], path=c.get("path"), http_only=c.get("http_only"),
secure=c.get("secure"), same_site=_same_site(c),
expiry=c.get("expiry"),
))
sent.append(c)
except Exception as e: # noqa: BLE001 - one refusal must not stop the rest
skipped.append({
"cookie": c.get("name"), "domain": c.get("domain"),
"reason": failure_text(e),
})
# A browser can refuse a cookie without an error; only the jar says.
kept = _kept(bidi, sent)
if kept is None:
kept = sent
skipped.extend(
{"cookie": c.get("name"), "domain": c.get("domain"),
"reason": "the browser did not keep it"}
for c in sent if c not in kept
)
return kept
def restore(bidi, data: dict, now: float) -> dict:
"""Put a snapshot into a browser before its first page: every live
cookie, then each origin's localStorage in a spare tab, then the saved
sessionStorage in the main tab, which is left on about:blank for the
landing. Everything is in place when this returns.
Best effort, and nothing raises: the report names each host that came
back and each item that did not, with why.
"""
skipped: list[dict] = []
kept: list[dict] = []
filled: list[str] = []
try:
kept = _restore_cookies(bidi, data.get("cookies") or [], now, skipped)
local = {
o: e["local"] for o, e in (data.get("origins") or {}).items()
if isinstance(e, dict) and e.get("local")
}
if local:
try:
with spare_tab(bidi) as run:
for origin, items in local.items():
try:
run(origin, fill("localStorage", items))
filled.append(origin)
except Exception as e: # noqa: BLE001 - one origin, not the rest
skipped.append({"site": origin, "reason": _why(e)})
except Exception as e: # noqa: BLE001 - no tab: each origin not yet filled
done = {s.get("site") for s in skipped}
skipped.extend(
{"site": o, "reason": failure_text(e)}
for o in local if o not in filled and o not in done
)
session = data.get("session") or {}
if session.get("origin") and session.get("items"):
try:
main = bidi.current_window_handle
with spare_tab(bidi, context=main) as run:
run(session["origin"], fill("sessionStorage", session["items"]))
# The stand-in page must never be what an open with no url
# leaves on screen; the tab keeps its sessionStorage.
with contextlib.suppress(Exception):
bidi.browsing_context.navigate(
context=main, url="about:blank", wait="complete"
)
filled.append(session["origin"])
except Exception as e: # noqa: BLE001 - the rest stands
skipped.append({"site": session["origin"], "reason": _why(e)})
except Exception as e: # noqa: BLE001 - BiDi failing is a report, not a crash
skipped.append({"reason": failure_text(e)})
return {
"restored": _hosts({"cookies": kept, "origins": {o: {} for o in filled}}),
"skipped": skipped,
"uri": LIST_URI,
}(contextlib is already imported at the top of the module; keep it.)
kubed/selenium_flow/core/actions.py — in open_session, replace from restored = pending = None (line 410) through restored, pending = site_data_module.arrive(restored, pending, current_url) (line 433) with:
report = None
if site_data_module.restorable(site_data or {}):
if insecure:
# It accepts any certificate, so anyone in the middle would get
# every saved cookie. Nothing is set, and nothing is deleted.
report = {
"restored": [], "skipped": [{"reason": INSECURE_SKIP}],
"uri": site_data_module.LIST_URI,
}
else:
with self.grid.bidi(session_id) as bidi:
report = site_data_module.restore(bidi, site_data, time.time())
current_url, title = "about:blank", ""
if url:
driver.get(url)
current_url, title = driver.current_url, driver.titleand replace the tail (lines 463–466)
if restored is not None:
result["site_data"] = restored
result["_site_data_pending"] = pending
return resultwith
if report and (report["restored"] or report["skipped"]):
result["site_data"] = report
return resultUpdate the docstring's last paragraph to: A session's saved site data is restored here — cookies, every origin's storage, the saved sessionStorage — before the first page loads, except into an ``insecure`` browser, which gets none. Delete retire_site_data (lines 479–492) whole.
kubed/selenium_flow/spec/schemas.py — replace SITE_DATA_HINT (lines 24–67) with:
# Present only when a call did something with site data, so it is left out of
# `required` and of every response that never carries it.
SITE_DATA_HINT = {
"type": "object",
"description": (
"Present only when site data was restored, skipped or forgotten in "
"this call."
),
"properties": {
"restored": {
"type": "array",
"items": {"type": "string"},
"description": (
"Each host whose saved cookies or storage are in the browser "
"now (app.example.com). All of it is in place before the call "
"returns."
),
},
"forgotten": {
"type": "integer",
"description": "Sites deleted because restore_site_data was false.",
},
"skipped": {
"type": "array",
"items": {
"type": "object",
"properties": {
"cookie": {"type": "string", "description": "A cookie's name."},
"domain": {"type": "string", "description": "Its domain."},
"site": {
"type": "string",
"description": "An origin whose storage did not come back.",
},
"reason": {"type": "string"},
},
"required": ["reason"],
},
"description": "Items that could not be restored, each with a reason.",
},
"uri": {"type": "string", "description": "Where to read what is saved."},
},
}- Step 5: Delete the round 1 tests this replaces — they test the preload script, the arrival hint and the old
restore, or moved totests/test_site_data_restore.py:
python3 $S/drop.py tests/test_site_data.py \
test_the_restore_marker_is_never_saved \
test_the_preload_script_fills_only_its_own_origin_once_per_tab \
test_restore_sets_live_cookies_and_one_preload_script \
test_a_refused_cookie_is_skipped_not_fatal \
test_restore_with_no_storage_adds_no_preload_script \
test_a_bidi_failure_as_a_whole_is_reported_not_raised \
test_retire_removes_the_script_and_never_raises \
OpenedDriver _open _redirected_to \
test_open_session_restores_before_the_first_page_loads \
test_an_insecure_browser_gets_no_saved_site_data \
test_a_redirect_to_another_saved_origin_restores_that_one \
test_a_redirect_off_the_saved_origins_leaves_the_requested_one_waiting \
test_open_session_says_nothing_without_site_data \
test_a_none_same_site_without_secure_is_restored_as_lax \
test_a_cookie_the_browser_did_not_keep_is_skipped \
test_an_unreadable_jar_trusts_the_set \
test_replace_swaps_the_script_for_the_origins_still_waiting \
test_actions_retire_replaces_and_keeps_the_old_id_when_bidi_is_down \
test_every_skipped_item_restore_emits_is_declared \
test_a_bidi_failure_never_leaks_the_grid_credential_into_the_report \
test_replace_keeps_the_old_id_when_the_remove_fails \
test_replace_answers_empty_when_the_add_fails_after_the_remove \
test_one_malformed_cookie_is_skipped_not_the_whole_restore \
test_a_nonnumeric_expiry_is_skipped_not_the_whole_restore \
test_a_failure_after_the_script_is_added_never_loses_its_id \
PRELOAD_HARNESS \
test_the_preload_script_fills_its_own_origin_once_per_tab_and_no_otherExpected: tests/test_site_data.py: dropped 29. grep -n 'preload\|retire\|arrive\|MARKER' tests/test_site_data.py then lists only FakeScript's two preload methods, which Task 8 deletes with the class.
-
Step 6: Run to verify they pass —
PYTHONPATH=$S/deps:. python3 -S -m pytest -q tests/test_site_data_restore.py tests/test_site_data.py tests/test_site_data_sessions.py tests/test_site_data_surface.py tests/test_openapi.py→ PASS; then the whole unit suite → PASS; ruff clean;grep -rn --include='*.py' 'preload_source\|retire_site_data\|\.arrive(\|\bMARKER\b' kubedprints onlysession/sessions.pylines (its pending code goes in Task 5). -
Step 7: Break it on purpose — in
restore, move the sessionStorage block above the localStorage block; confirmtest_cookies_then_each_origins_storage_then_session_storagefails onorder; restore. -
Step 8: Commit
git add kubed/selenium_flow/core/site_data.py kubed/selenium_flow/core/actions.py \
kubed/selenium_flow/spec/schemas.py tests/site_data_fakes.py \
tests/test_site_data_restore.py tests/test_site_data.py
git commit -m "Site data round 2: restore writes every origin before the first page; the preload script goes" \
-m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"Files:
- Modify:
kubed/selenium_flow/session/store.py—SessionRecordgainsreopened(+from_json,delivered). - Modify:
kubed/selenium_flow/session/sessions.py—resolve(343–370),settle(389–415), replace_site_data_after…_hold_pending(417–616) with_save_site_dataand_restorable,open_browser(657–683),remember(702–761),touch. - Modify:
kubed/selenium_flow/core/site_data.py—mergeno longer carriespending(lines 86–87). - Modify:
kubed/selenium_flow/flows/api.py(run_for's touch),kubed/selenium_flow/flows/run.py(762–768),kubed/selenium_flow/secrets.py(perform_write, 761–762),kubed/selenium_flow/spec/schemas.py(FlowRun, 573–646),kubed/selenium_flow/mcp/tools.py(screenshot comment, 740–741),kubed/selenium_flow/http/admin.py(admin_site_data_forget, 833–868). - Rewrite:
tests/test_site_data_sessions.py. Modify:tests/test_site_data.py,tests/test_admin_site_data.py,tests/test_openapi.py,tests/test_secrets.py.
Interfaces:
-
Consumes: Task 4's
site_data.restorable, the report shape{"restored", "skipped", "uri"}; Task 1'stouch/remember. -
Produces:
-
SessionRecord.reopened: dict—{"browser": session_id, "report": {...}}after a silent reopen, else{};SessionRecord.delivered() -> SessionRecordclears it. -
SessionManager.remember(name, session_id, url="", settings=None, replacing=None, forget_site_data=False, report: dict | None = None) -> str— a non-emptyreportis held on the new record for that browser; any other bind clears what was held. -
SessionManager.touch(name, *urls, browser=None) -> dict | None— returns, and clears, the held report when it is this browser's. -
SessionManager.settle(...)— on a touch, a returned report becomesresult["site_data"]. -
flows.api.run_for(...)— a run whose browser was reopened carries the report as the run's top-levelsite_data; steps never carrysite_data(the arrival hint is gone;FlowRundeclares it at run level). -
secrets.perform_write(...)— a bound write carries the report the same way. - Deleted:
SessionManager._site_data_after,_without,_waiting,_hold_pending; everypendingkey.
-
-
Step 1: Write the failing tests — replace
tests/test_site_data_sessions.pywhole:
"""The session's side of site data: a save stored and never returned, a
restore on open and on a silent reopen, and the one report each makes."""
import json
from dataclasses import replace
import pytest
from kubed.selenium_flow.core import site_data
from tests.conftest import NAMED, RecordingActions, manager
URL = "https://app.example.com/x"
REPORT = {"restored": ["app.example.com"], "skipped": [], "uri": "session://site-data"}
class SiteActions(RecordingActions):
def __init__(self):
super().__init__()
self.site_data_seen = []
def open_session(self, url=None, site_data=None, **kw):
self.site_data_seen.append(site_data)
opened = super().open_session(url=url, **kw)
if site_data:
opened["site_data"] = dict(REPORT)
return opened
def save_site_data(self, session_id, url=None):
return {
"url": URL,
"title": "t",
site_data.CAPTURED: {
"cookies": [{"name": "a", "value": "1", "domain": "app.example.com"}],
"origin": "https://app.example.com",
"local": {"k": "v"},
"session": {},
},
}
def opened_with_save():
m = manager(SiteActions())
m.open_browser(NAMED)
m.act(NAMED, lambda s: m.actions.save_site_data(s))
return m
def reopened(m, **kw):
m.end_browser(NAMED)
return m.open_browser(NAMED, **kw)
def reaped(m):
"""The Grid took the browser: the next call reopens it on its own."""
m.actions.grid.alive.clear()
def test_a_save_is_stored_and_never_returned_raw(named_caller):
m = manager(SiteActions())
m.open_browser(NAMED)
result = m.act(NAMED, lambda s: m.actions.save_site_data(s))
assert site_data.CAPTURED not in result
assert result["saved"]["cookies"] == 1 and result["uri"] == "session://site-data"
assert list(m.store.get(NAMED).site_data["origins"]) == ["https://app.example.com"]
def test_open_restores_what_was_saved_and_says_so(named_caller):
m = opened_with_save()
told = reopened(m)
assert m.actions.site_data_seen[-1]["cookies"]
assert told["site_data"] == REPORT
assert "_site_data_pending" not in told
assert "pending" not in m.store.get(NAMED).site_data
def test_restore_off_deletes_the_snapshot_and_keeps_the_history(named_caller):
m = opened_with_save()
told = reopened(m, restore_site_data=False)
assert m.actions.site_data_seen[-1] is None
assert told["site_data"] == {"forgotten": 1}
record = m.store.get(NAMED)
assert record.site_data == {}
assert "https://app.example.com" in [v["origin"] for v in record.history]
def test_restore_site_data_accepts_a_string_false(named_caller):
m = opened_with_save()
reopened(m, restore_site_data="false")
assert m.store.get(NAMED).site_data == {}
def test_a_failed_open_does_not_erase_what_was_saved(named_caller):
"""Declining a restore deletes only once the clean browser is open: a
transient Grid failure must not cost the saved sign-in (Copilot, #49)."""
m = opened_with_save()
m.end_browser(NAMED)
def refuse(**_):
raise RuntimeError("the grid is full")
m.actions.open_session = refuse
with pytest.raises(RuntimeError):
m.open_browser(NAMED, restore_site_data=False)
assert list(m.store.get(NAMED).site_data["origins"]) == ["https://app.example.com"]
def test_nothing_saved_means_no_hint(named_caller):
m = manager(SiteActions())
assert "site_data" not in m.open_browser(NAMED)
def test_an_insecure_open_keeps_what_was_saved(named_caller):
"""No restore into an insecure browser, but nothing is deleted either:
the next secure browser gets it all back."""
m = opened_with_save()
reopened(m, insecure=True)
assert m.store.get(NAMED).site_data["origins"]
def test_remember_keeps_site_data(named_caller):
m = opened_with_save()
m.open_browser(NAMED)
m.open_browser(NAMED)
assert list(m.store.get(NAMED).site_data["origins"]) == ["https://app.example.com"]
def test_describe_summarises_site_data(named_caller):
m = opened_with_save()
assert m.describe(NAMED)["site_data"] == {"sites": 1, "uri": "session://site-data"}
# ---- a silent reopen says what came back, on the first result after it --------
def test_the_call_that_reopens_a_reaped_browser_says_what_came_back(named_caller):
m = opened_with_save()
reaped(m)
first = m.act(NAMED, lambda s: {"url": "https://elsewhere.example.com/"})
assert m.actions.site_data_seen[-1]["cookies"], "the reopen restored"
assert first["site_data"] == REPORT
assert "site_data" not in m.act(NAMED, lambda s: {"url": "https://elsewhere.example.com/"})
assert m.store.get(NAMED).reopened == {}
def test_a_report_waits_for_a_call_that_finishes(named_caller):
m = opened_with_save()
reaped(m)
def fails(_):
raise RuntimeError("the page broke")
with pytest.raises(RuntimeError):
m.act(NAMED, fails)
assert m.act(NAMED, lambda s: {"url": URL})["site_data"] == REPORT
def test_a_report_is_never_handed_to_a_result_from_another_browser(named_caller):
m = opened_with_save()
reaped(m)
m.resolve(NAMED)
told = {"url": URL}
m.settle(NAMED, told, browser="an-older-browser")
assert "site_data" not in told
assert m.act(NAMED, lambda s: {"url": URL})["site_data"] == REPORT
def test_an_open_drops_a_report_nobody_collected(named_caller):
m = opened_with_save()
reaped(m)
m.resolve(NAMED)
assert m.open_browser(NAMED)["site_data"] == REPORT, "the open's own report"
assert "site_data" not in m.act(NAMED, lambda s: {"url": URL})
# ---- a flow is settled the way a single call is ---------------------------------
class FlowActions(SiteActions):
def navigate(self, session_id, url=None, **kw):
return {"url": url, "title": "w"}
def flow_world(tmp_path, steps):
from kubed.selenium_flow.config import Settings
from kubed.selenium_flow.server import SeleniumMCP
server = SeleniumMCP(Settings(
grid={"url": "http://grid.invalid:4444"}, flow={"data_dir": str(tmp_path)},
))
server.flows.save(NAMED, "login", {"steps": steps})
m = manager(FlowActions())
m.open_browser(NAMED)
return server.flows, m
def run(store, m):
from kubed.selenium_flow.flows import api as flowapi
return flowapi.run_for(store, m.actions, m, NAMED, "login", verbose=True)
def test_a_flow_that_saves_keeps_the_data_and_never_reports_the_capture(
named_caller, tmp_path
):
store, m = flow_world(tmp_path, [{"tool": "save_site_data", "args": {}, "return": True}])
report = run(store, m)
assert list(m.store.get(NAMED).site_data["origins"]) == ["https://app.example.com"]
assert site_data.CAPTURED not in json.dumps(report)
assert report["steps"][0]["result"]["saved"]["cookies"] == 1
def test_a_flow_run_that_reopened_the_browser_says_so_once(named_caller, tmp_path):
store, m = flow_world(tmp_path, [{"tool": "navigate", "args": {"url": "https://w.example.com/"}}])
m.act(NAMED, lambda s: m.actions.save_site_data(s))
reaped(m)
report = run(store, m)
assert report["site_data"] == REPORT
assert "site_data" not in report["steps"][0]
assert "site_data" not in run(store, m)
# ---- a save never reverts what landed while it worked -------------------------
def test_the_capture_is_stripped_even_when_the_store_fails(named_caller):
m = opened_with_save()
result = m.actions.save_site_data("x")
def down(name):
raise ConnectionError("redis is down")
m.store.get = down
with pytest.raises(ConnectionError):
m.settle(NAMED, result, touch=False)
assert site_data.CAPTURED not in result
def save_elsewhere(m, origin):
def save(r):
data = dict(r.site_data)
data["origins"] = {**data.get("origins", {}), origin: {"local": {"k": "1"}}}
return r.with_site_data(data)
return lambda: m.store.update(NAMED, save)
def test_ending_a_browser_keeps_a_save_that_landed_while_it_quit(named_caller):
m = opened_with_save()
real = m.actions.end_browser
def end(session_id):
save_elsewhere(m, "https://late.example.com")()
return real(session_id)
m.actions.end_browser = end
m.end_browser(NAMED)
record = m.store.get(NAMED)
assert not record.attached
assert record.site_data["origins"].get("https://late.example.com") is not None
def test_a_save_from_a_replaced_browser_keeps_nothing(named_caller):
"""open_session(restore_site_data=false) on another request deletes the
data; a save still finishing on the old browser must not bring it back."""
m = manager(SiteActions())
m.open_browser(NAMED)
def save_while_replaced(resolved):
m.store.update(NAMED, lambda r: replace(r, session_id="newer", site_data={}))
return m.actions.save_site_data(resolved)
told = m.act(NAMED, save_while_replaced)
assert site_data.CAPTURED not in told
assert told["saved"]["sites"] == [] and told["saved"]["cookies"] == 0
assert told["saved"]["skipped"][0]["reason"].startswith("another browser took")
assert m.store.get(NAMED).site_data == {}
def test_a_stale_result_does_not_move_the_newer_browsers_page_or_window(named_caller):
"""A result from a browser the record no longer names must not write its
page or size over the newer browser's: a reap would reopen B at A's page
(Copilot, #50). The TTL still slides."""
m = opened_with_save()
m.store.update(NAMED, lambda r: r.at("https://b.test/"))
def meanwhile(resolved):
m.store.update(NAMED, lambda r: replace(r, session_id="newer"))
return {"url": "https://a.test/page", "width": 640, "height": 480}
m.act(NAMED, meanwhile, reshapes=True)
record = m.store.get(NAMED)
assert record.session_id == "newer"
assert record.url == "https://b.test/"
assert record.settings.get("width") != 640
def test_a_result_from_the_browser_held_still_moves_the_page(named_caller):
m = opened_with_save()
m.act(NAMED, lambda s: {"url": "https://a.test/page", "width": 640, "height": 480},
reshapes=True)
record = m.store.get(NAMED)
assert record.url == "https://a.test/page"
assert record.settings["width"] == 640In tests/test_secrets.py, add after test_a_direct_bound_write_still_remembers_an_untouched_page:
async def test_a_bound_write_after_a_silent_reopen_says_what_came_back(
tmp_path, monkeypatch
):
"""A bound write is a call like any other: the first one after a reopen
carries the reopen's report, which `touch` hands over."""
from kubed.selenium_flow.server import SeleniumMCP
from .conftest import NAMED, TOKEN
monkeypatch.delenv("SECRETS_DIRS", raising=False)
monkeypatch.delenv("FLOW_DATA_DIR", raising=False)
make_secret(
tmp_path, "nextcloud", password="hunter2",
**{ALLOWED_URLS: "https://nc.example.com"},
)
server = SeleniumMCP(Settings(
grid={"url": "http://grid.invalid:4444"},
auth={"token": TOKEN},
secrets={"dirs": str(tmp_path)},
))
monkeypatch.setattr(server.sessions, "name", lambda: NAMED)
monkeypatch.setattr(server.sessions, "resolve", lambda name: "browser-1")
monkeypatch.setattr(
server.actions, "page",
lambda sid: {"url": "https://nc.example.com/login", "title": "Log in"},
)
monkeypatch.setattr(
server.actions, "write",
lambda sid, text, **kw: {
"value": None, "url": "https://nc.example.com/home", "title": "Home",
},
)
report = {"restored": ["nc.example.com"], "skipped": [], "uri": "session://site-data"}
monkeypatch.setattr(server.sessions, "touch", lambda name, url, browser=None: report)
write = await server.mcp.get_tool("write")
result = write.fn(
selector={"css": "#password"},
secret={"name": "nextcloud", "key": "password"},
)
assert result["site_data"] == reportIn tests/test_openapi.py, replace test_a_flow_step_declares_the_site_data_it_reports (lines 346–350) with:
async def test_a_flow_run_declares_the_site_data_it_reports(spec):
"""A reopen's report rides on the run; no step carries one any more."""
from kubed.selenium_flow.spec.schemas import SITE_DATA_HINT
run = spec["components"]["schemas"]["FlowRun"]
assert run["properties"]["site_data"] == SITE_DATA_HINT
assert "site_data" not in run["properties"]["steps"]["items"]["properties"]In tests/test_admin_site_data.py, delete the "pending" entry from DATA (its comment line and the two lines of the dict, 38–40) and the assertion assert left["pending"] == {...} with its continuation line (102–103).
And drop the merge test that pinned pending:
python3 $S/drop.py tests/test_site_data.py test_merge_keeps_pending- Step 2: Run to verify they fail
Run: PYTHONPATH=$S/deps:. python3 -S -m pytest -q tests/test_site_data_sessions.py tests/test_openapi.py tests/test_secrets.py
Expected: FAIL — among others test_the_call_that_reopens_a_reaped_browser_says_what_came_back (no site_data: Task 4 left the reopen unannounced), test_a_flow_run_declares_the_site_data_it_reports (KeyError: 'site_data'), test_a_bound_write_after_a_silent_reopen_says_what_came_back (KeyError: 'site_data').
- Step 3: Implement the record —
kubed/selenium_flow/session/store.py. After thesite_datafield add:
# A silent reopen's site data report, held until the first result from
# that browser carries it: {"browser": id, "report": {...}}, or {}.
reopened: dict = field(default_factory=dict)In from_json, read it beside the others — reopened = data.get("reopened") after site_data = data.get("site_data") — and pass reopened=reopened if isinstance(reopened, dict) else {}, as the last argument to cls(...). After with_site_data add:
def delivered(self) -> SessionRecord:
"""The same record, its reopen report handed to a caller."""
return replace(self, reopened={})- Step 4: Implement the session —
kubed/selenium_flow/session/sessions.py.
In resolve, replace from saved = self._restorable(record) (line 350) to the end of the method (return opened["session_id"], line 370) with:
saved = self._restorable(record)
opened = self.actions.open_session(
url=record.url or None,
**({"site_data": saved} if saved else {}),
**(record.settings or {}),
)
# Nobody asked for this reopen, so the first result after it says what
# came back: the record holds the report until `touch` hands it over.
# A reopen alongside this one may bind first; then act on its browser.
return self.remember(
name,
opened["session_id"],
opened.get("url", record.url or ""),
record.settings,
replacing=record.session_id,
report=opened.get("site_data"),
)Replace settle and everything after it up to (not including) def open_browser — that is settle, _site_data_after, _without, _waiting, _restorable, _hold_pending, lines 389–616 — with:
def settle(
self,
name: str,
result,
*,
browser: str | None = None,
reshapes: bool = False,
touch: bool = True,
) -> None:
"""What a finished action means for the record, in place on ``result``.
The one place a result is stripped of its private capture and the
capture stored, so a flow step reaches it the same way a single call
does. ``touch=False`` is for the flow runner, which touches once per
run rather than once per step — and so carries a reopen's report on
the run, not on a step.
``browser`` is the one that produced ``result``: once the record names
another, its page is not recorded and its save is not kept.
"""
if not isinstance(result, dict):
return
if touch:
told = self.touch(name, result.get("url"), browser=browser)
if told:
result["site_data"] = told
if reshapes:
self.reshape(name, result, browser=browser)
self._save_site_data(name, result, browser)
def _save_site_data(
self, name: str, result: dict, producer: str | None = None
) -> None:
"""A captured save, stored on the record and replaced in the result by
a short receipt, so no caller ever sees a value.
Written through ``store.update``, applied to the record as it is then,
and only by the browser that captured it.
"""
# Popped before the store is asked anything, so a store that fails
# cannot leave the capture — every value — in what the caller gets.
captured = result.pop(site_data_module.CAPTURED, None)
if not captured:
return
receipt: dict = {}
def save(r: SessionRecord) -> SessionRecord | None:
if producer is not None and r.session_id != producer:
# Another browser holds the session now — perhaps one opened
# with restore_site_data=false. What this one captured is not
# its to keep (Copilot, #50).
receipt["saved"] = {
"cookies": 0, "sites": [],
"skipped": [{"reason": REPLACED_BEFORE_SAVE}],
}
return None
data, receipt["saved"] = site_data_module.merge(
r.site_data, captured, time.time()
)
return r.with_site_data(data)
self.store.update(name, save)
if "saved" in receipt:
result["saved"] = receipt["saved"]
result["uri"] = site_data_module.LIST_URI
@staticmethod
def _restorable(record: SessionRecord) -> dict:
"""The snapshot a new browser is given, or {} when there is none."""
if not site_data_module.restorable(record.site_data):
return {}
return dict(record.site_data)In open_browser, make the forgotten count safe for a snapshot whose cookies name no host — forgotten = (site_data_module.summary(saved) or {"sites": 0})["sites"] — and delete the line self._hold_pending(name, opened, {"announce": False}).
In remember, add the parameter report: dict | None = None after forget_site_data: bool = False, add to its docstring:
``report`` is a silent reopen's site data report: the record holds it
for this browser until `touch` hands it to the first result. Any other
bind drops whatever an earlier reopen still held.
and add to the SessionRecord(...) built in bound, after site_data=...:
reopened={"browser": session_id, "report": report} if report else {},Replace the end of touch — from ttl = self.store.ttl to self.store.update(name, at) — with:
ttl = self.store.ttl
told: dict = {}
def at(r: SessionRecord) -> SessionRecord:
if browser and r.session_id != browser:
return r
note = r.reopened
ours = note.get("browser") == r.session_id
told["report"] = note.get("report") if ours else None
return r.at(*urls, ttl=ttl).delivered()
self.store.update(name, at)
return told.get("report")change its return annotation to -> dict | None, and append to its docstring: Returns a silent reopen's report the first time this browser is touched after it, and never again.
- Step 5: Implement the rest of the surface
kubed/selenium_flow/core/site_data.py, in merge: delete
if (existing or {}).get("pending"):
data["pending"] = existing["pending"]kubed/selenium_flow/flows/api.py, the last two lines of run_for become:
told = sessions.touch(session, *visited, browser=resolved)
if told:
# The run's browser replaced a reaped one: what came back, once.
report["site_data"] = told
return reportkubed/selenium_flow/flows/run.py, replace
# Before the result is copied for the report: the hook strips the
# private capture from `raw` and may add the site data hint.
if after_step is not None:
after_step(tool, raw)
result = _clean(raw, guarded, hidden)
if isinstance(raw, dict) and raw.get("site_data"):
entry["site_data"] = raw["site_data"]with
# Before the result is copied for the report: the hook strips the
# private capture from `raw`.
if after_step is not None:
after_step(tool, raw)
result = _clean(raw, guarded, hidden)kubed/selenium_flow/secrets.py, the end of perform_write becomes:
safe = None if flowrun.taints(result.get("url"), hidden) else shown.get("url")
told = sessions.touch(name, safe, browser=resolved)
if told:
# The first call after a silent reopen says what came back.
shown["site_data"] = told
return shownkubed/selenium_flow/spec/schemas.py, in FlowRun: delete the step item's "site_data": SITE_DATA_HINT, line, and add after the run's "title": {"type": "string"},:
# Present only when the run's browser was reopened after a reap.
"site_data": SITE_DATA_HINT,kubed/selenium_flow/mcp/tools.py, in screenshot, the comment's last item becomes:
# - `site_data`: the first call after a silent reopen says what came
# back (``sessions.settle``) — dropped here, it is never said at all.kubed/selenium_flow/http/admin.py, in admin_site_data_forget: replace the docstring with
"""Forget one site. Parent-domain cookies stay: other sites use them.
It changes the session's store and nothing else: the history stays,
and a browser open now keeps what it has — only the next one opened
comes back without it. A save after this saves the site again.
"""and delete the pending block inside forget (the comment # Pending origins are restores not yet consumed;… and the pending = left.get("pending") if that follows, lines 851–860), leaving return record.with_site_data(left).
-
Step 6: Run to verify they pass —
PYTHONPATH=$S/deps:. python3 -S -m pytest -q tests/test_site_data_sessions.py tests/test_sessions.py tests/test_secrets.py tests/test_openapi.py tests/test_admin_site_data.py tests/test_history.py tests/test_screenshot_saving.py tests/test_flowrun.py→ PASS; then the whole suite → PASS; ruff clean;grep -rnw --include='*.py' 'pending\|_hold_pending\|retire_site_data' kubedprints nothing. -
Step 7: Break it on purpose — in
touch, returnr.at(*urls, ttl=ttl)without.delivered(); confirmtest_the_call_that_reopens_a_reaped_browser_says_what_came_backfails on its second call; restore. -
Step 8: Commit
git add kubed/selenium_flow/session/store.py kubed/selenium_flow/session/sessions.py \
kubed/selenium_flow/core/site_data.py kubed/selenium_flow/flows/api.py \
kubed/selenium_flow/flows/run.py kubed/selenium_flow/secrets.py \
kubed/selenium_flow/spec/schemas.py kubed/selenium_flow/mcp/tools.py \
kubed/selenium_flow/http/admin.py tests/test_site_data_sessions.py tests/test_site_data.py \
tests/test_admin_site_data.py tests/test_openapi.py tests/test_secrets.py
git commit -m "Site data round 2: no pending note; a reopen's report waits on the record for the first result" \
-m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"Files:
- Modify:
kubed/selenium_flow/core/site_data.py—_Jar.__init__(177–186),_site(244–270),_rows(288–329),view,views,site_view(332–351),forget(359–378); addhistory_hosts. (matching_secretsstays for Task 7.) - Modify:
kubed/selenium_flow/http/admin.py— the session row (596–610, 629–630),admin_site_data(809–825), newadmin_site_data_clear. - Modify:
kubed/selenium_flow/mcp/resources.py—SITE_DESCRIPTION,_saved,site_listing,one_site. - Modify:
kubed/selenium_flow/spec/schemas.py—SITE_DATA_SCHEMAS(997–1100). - Create:
tests/test_site_data_views.py. Rewrite:tests/test_admin_site_data.py. Modify:tests/test_site_data.py(drop the view tests),tests/test_site_data_surface.py(one new test).
Interfaces:
-
Consumes: the snapshot shape (Task 4);
SessionRecord.history(Task 1). -
Produces:
-
site_data.history_hosts(history) -> list[str]— the hosts of a history, most recent first, each once. -
site_data.view(data: dict, history=()) -> dict→{"sites": [row], "saved_at": float | None, "uri": "session://site-data"}; row{"site", "uri", "cookies": int, "storage": [{"origin", "local_storage": int, "session_storage": int}]}(storageis[]for a cookie-only host). One row per host the snapshot holds data for — its storage hosts (sessionStorage included when it has items) and the cookie domains no storage host covers. Hosts inhistoryfirst, most recent first, then the rest alphabetically. No secrets, nosavedflag: every row has something saved. -
site_data.views(data, history=()) -> (listing, details);site_data.site_view(data, site) -> dict | None; detail{"site", "uri", "cookies": [cookie view], "storage": [{"origin", "local_storage": {...}, "session_storage": {...}}], "own_cookies": [name], "kept_shared": [{"name", "domain", "path"}]}. -
site_data.forget(data, host)— as before, and the saved sessionStorage goes too when its origin is onhost;saved_atstays (forgetting is not a save). -
GET {prefix}/admin/sessions/{key}/site-data→{"key", **view(record.site_data, record.history), "details"};DELETE {prefix}/admin/sessions/{key}/site-data(Clear) →{"cleared": [host, …]}and the snapshot becomes{};DELETE …/site-data/{site}unchanged (404 when nothing of its own). - Session row:
site_data_count = len(view["sites"]),site_data_rev = json.dumps([saved_at, [hosts]])— a save moves the time, a Forget or Clear moves the hosts. -
session://site-dataandGET {prefix}/site-datalist in history order;session://site-data/{site}unchanged.
-
-
Step 1: Write the failing tests —
tests/test_site_data_views.py:
"""The snapshot by host: the Site data tab, session://site-data, and Forget.
Pure, over round 2's snapshot shape."""
import pytest
from kubed.selenium_flow.core import site_data as sd
from .site_data_fakes import NOW, cookie, snapshot
pytestmark = pytest.mark.unit
APP = "https://app.example.com"
def visit(url, at=NOW):
return {"origin": sd.origin_of(url), "url": url, "at": at}
def test_one_row_per_host_with_counts_and_both_storages_in_full():
data = snapshot(
cookies=[cookie("sid", "app.example.com", value="secret", http_only=True),
cookie("ab", ".example.com", value="b1"), cookie("kc", "sso.example.com")],
origins={APP: {"theme": "dark", "tour": "1"}},
session={"origin": APP, "items": {"token": "t"}},
)
listing = sd.view(data)
assert listing["saved_at"] == NOW and listing["uri"] == "session://site-data"
rows = {r["site"]: r for r in listing["sites"]}
assert rows["app.example.com"] == {
"site": "app.example.com", "uri": "session://site-data/app.example.com", "cookies": 2,
"storage": [{"origin": APP, "local_storage": 2, "session_storage": 1}],
}
assert rows["sso.example.com"]["storage"] == [], "cookies only"
one = sd.site_view(data, "app.example.com")
by_name = {c["name"]: c for c in one["cookies"]}
assert by_name["sid"]["value"] == sd.MASK, "httpOnly is masked"
assert by_name["ab"]["value"] == "b1" and by_name["ab"]["shared"] is True
assert one["storage"] == [
{"origin": APP, "local_storage": {"theme": "dark", "tour": "1"},
"session_storage": {"token": "t"}},
]
assert sd.site_view(data, "nope.test") is None
def test_hosts_in_the_history_come_first_then_the_rest_alphabetically():
data = snapshot(cookies=[cookie("c", "cdn.test"), cookie("a", "ads.test"),
cookie("k", "app.test"), cookie("g", "grafana.test")])
history = [visit("https://grafana.test/d"), visit("https://app.test/x"),
visit("https://never-saved.test/")]
assert [r["site"] for r in sd.view(data, history)["sites"]] == [
"grafana.test", "app.test", "ads.test", "cdn.test",
]
assert [r["site"] for r in sd.view(data)["sites"]] == [
"ads.test", "app.test", "cdn.test", "grafana.test",
]
def test_history_hosts_are_unique_and_newest_first():
history = [visit("https://a.test/1"), visit("http://a.test:8080/"), visit("https://b.test/")]
assert sd.history_hosts(history) == ["a.test", "b.test"]
assert sd.history_hosts([]) == []
def test_empty_session_storage_is_no_row():
data = snapshot(session={"origin": "https://app.test", "items": {}})
assert sd.view(data)["sites"] == []
assert sd.summary(data) is None
def test_session_storage_alone_makes_a_row():
data = snapshot(session={"origin": "https://app.test", "items": {"t": "1"}})
assert sd.view(data)["sites"][0]["storage"] == [
{"origin": "https://app.test", "local_storage": 0, "session_storage": 1},
]
assert sd.summary(data) == {"sites": 1, "uri": "session://site-data"}
def test_one_host_on_two_ports_keeps_each_origins_storage_apart():
data = snapshot(
origins={"http://localhost:3000": {"k": "a"}, "http://localhost:8080": {"k": "b"}},
session={"origin": "http://localhost:8080", "items": {"s": "1"}},
)
assert sd.view(data)["sites"][0]["storage"] == [
{"origin": "http://localhost:3000", "local_storage": 1, "session_storage": 0},
{"origin": "http://localhost:8080", "local_storage": 1, "session_storage": 1},
]
assert sd.site_view(data, "localhost")["storage"] == [
{"origin": "http://localhost:3000", "local_storage": {"k": "a"}, "session_storage": {}},
{"origin": "http://localhost:8080", "local_storage": {"k": "b"}, "session_storage": {"s": "1"}},
]
def test_a_parent_cookie_with_no_host_gets_its_own_row():
data = snapshot(cookies=[cookie("ab", ".example.com")])
assert [r["site"] for r in sd.view(data)["sites"]] == ["example.com"]
def test_rows_do_not_depend_on_the_order_the_cookies_came_in():
a = [cookie("ab", ".example.com"), cookie("sid", "app.example.com")]
first = sd.view(snapshot(cookies=a))
assert [r["site"] for r in first["sites"]] == ["app.example.com", "example.com"]
assert first == sd.view(snapshot(cookies=list(reversed(a))))
def test_a_sites_own_dotted_cookie_is_not_shared_but_a_parents_is():
data = snapshot(cookies=[cookie("own", ".app.example.com"), cookie("parent", ".example.com"),
cookie("plain", "app.example.com")], origins={APP: {"a": "1"}})
shown = {c["name"]: c["shared"] for c in sd.site_view(data, "app.example.com")["cookies"]}
assert shown == {"own": False, "parent": True, "plain": False}
def test_the_site_view_names_goes_and_stays_by_the_forget_rule():
data = snapshot(cookies=[cookie("own", "app.example.com"), cookie("dot", ".app.example.com"),
cookie("ab", ".example.com")])
one = sd.site_view(data, "app.example.com")
assert one["own_cookies"] == ["own", "dot"]
assert one["kept_shared"] == [{"name": "ab", "domain": ".example.com", "path": "/"}]
parent = sd.site_view(data, "example.com")
assert parent["own_cookies"] == ["ab"] and parent["kept_shared"] == []
def test_two_shared_cookies_of_one_name_stay_apart():
data = snapshot(cookies=[cookie("sid", ".example.com"), cookie("sid", ".example.org"),
cookie("sid", "app.example.com")])
assert sd.site_view(data, "app.example.com")["kept_shared"] == [
{"name": "sid", "domain": ".example.com", "path": "/"}
]
both = snapshot(cookies=[cookie("sid", ".example.com"), cookie("sid", ".example.org")],
origins={"https://a.example.com": {"k": "1"}, "https://a.example.org": {"k": "1"}})
assert sd.site_view(both, "a.example.com")["kept_shared"][0]["domain"] == ".example.com"
assert sd.site_view(both, "a.example.org")["kept_shared"][0]["domain"] == ".example.org"
def test_forget_takes_the_hosts_storage_and_own_cookies_and_keeps_the_rest():
data = snapshot(
cookies=[cookie("sid", "app.example.com"), cookie("dot", ".app.example.com"),
cookie("ab", ".example.com"), cookie("kc", "sso.example.com")],
origins={APP: {"a": "1"}, "https://sso.example.com": {"b": "2"}},
session={"origin": APP, "items": {"t": "1"}},
)
left, removed = sd.forget(data, "app.example.com")
assert [c["name"] for c in left["cookies"]] == ["ab", "kc"]
assert list(left["origins"]) == ["https://sso.example.com"]
assert left["session"] == {}
assert left["saved_at"] == NOW, "forgetting is not a save"
assert removed == {
"site": "app.example.com", "cookies": ["sid", "dot"], "origins": [APP],
"kept_shared": [{"name": "ab", "domain": ".example.com", "path": "/"}],
}
def test_forget_of_the_session_storage_host_alone_names_its_origin():
left, removed = sd.forget(snapshot(session={"origin": "https://app.test", "items": {"t": "1"}}),
"app.test")
assert left["session"] == {} and removed["origins"] == ["https://app.test"]
def test_forget_leaves_session_storage_on_another_host():
data = snapshot(cookies=[cookie("sid", "app.test")], session={"origin": "https://sso.test", "items": {"t": "1"}})
left, _ = sd.forget(data, "app.test")
assert left["session"] == {"origin": "https://sso.test", "items": {"t": "1"}}
def test_forgetting_a_parent_only_row_removes_its_dotted_cookie():
data = snapshot(cookies=[cookie("shared", ".example.com")])
left, removed = sd.forget(data, "example.com")
assert removed["cookies"] == ["shared"] and removed["kept_shared"] == []
assert sd.view(left)["sites"] == []
def test_summary_is_none_when_empty():
assert sd.summary({}) is None
assert sd.summary(snapshot(cookies=[cookie("sid", "app.test")])) == {
"sites": 1, "uri": "session://site-data",
}
def test_a_jar_of_many_domains_is_not_rescanned_per_host(monkeypatch):
"""Every host used to rescan the whole jar, and every detail rebuilt the
listing: cookies x hosts x hosts. One grouping serves all of them."""
calls = {"n": 0}
real = {name: getattr(sd, name) for name in ("_covers", "_own")}
def counting(name):
def wrapped(*a):
calls["n"] += 1
return real[name](*a)
return wrapped
for name in real:
monkeypatch.setattr(sd, name, counting(name))
cookies = [cookie("c", f"h{i}.example{i}.com") for i in range(500)]
listing, details = sd.views(snapshot(cookies=cookies))
assert len(listing["sites"]) == 500 and set(details) == {r["site"] for r in listing["sites"]}
assert calls["n"] <= 4 * len(cookies)
assert sd.view(snapshot(cookies=cookies)) == listingReplace tests/test_admin_site_data.py whole:
"""The admin API over a session's snapshot: the Site data tab's routes and
the two fields each session row carries for it.
Only what the snapshot holds is listed — no secrets, which are History's —
hosts the session went to first; an httpOnly value is never shown; Forget
and Clear change the snapshot alone, never the history or the browser.
"""
import json
import pytest
from starlette.testclient import TestClient
from kubed.selenium_flow.config import Settings
from kubed.selenium_flow.core import site_data
from kubed.selenium_flow.server import SeleniumMCP
from kubed.selenium_flow.session.store import SessionRecord
from .conftest import TOKEN
pytestmark = pytest.mark.unit
KEY = "desktop"
AUTH = {"Authorization": f"Bearer {TOKEN}"}
DATA = {
"cookies": [
{"name": "sid", "value": "RAW-SECRET", "domain": "app.example.com",
"path": "/", "http_only": True},
{"name": "theme", "value": "dark", "domain": "app.example.com", "path": "/"},
{"name": "shared", "value": "s", "domain": ".example.com", "path": "/"},
{"name": "ad", "value": "1", "domain": "ads.example.net", "path": "/"},
],
"origins": {"https://app.example.com": {"local": {"a": "1"}}},
"session": {"origin": "https://app.example.com", "items": {"s": "1"}},
"saved_at": 10.0,
}
def visited():
return (SessionRecord(session_id="")
.at("https://mail.example.org/", now=5.0)
.at("https://app.example.com/x", now=6.0))
@pytest.fixture
def server():
s = SeleniumMCP(Settings(
grid={"url": "http://grid.invalid:4444"},
auth={"token": TOKEN},
secrets={"entries": {"mail": {
"description": "the mailbox",
"allowed_urls": ["https://mail.example.org"],
"keys": {"user": {"value": "me"}},
}}},
))
s.sessions.store.set(KEY, visited().with_site_data(DATA))
return s
@pytest.fixture
def client(server):
return TestClient(server.mcp.http_app(), headers=AUTH)
def url(suffix=""):
return f"/admin/sessions/{KEY}/site-data{suffix}"
def row(client):
rows = client.get("/admin/sessions").json()["sessions"]
return next(r for r in rows if r["key"] == KEY)
def test_the_list_is_the_snapshot_by_host_history_hosts_first_and_no_secrets(client):
body = client.get(url()).json()
assert body["key"] == KEY and body["saved_at"] == 10.0
assert [s["site"] for s in body["sites"]] == ["app.example.com", "ads.example.net"]
assert set(body["details"]) == {"app.example.com", "ads.example.net"}
assert "mail.example.org" not in json.dumps(body), "a secret never makes a row here"
assert "secrets" not in json.dumps(body)
def test_details_mask_http_only_values_and_show_both_storages(client):
detail = client.get(url()).json()["details"]["app.example.com"]
assert {c["name"]: c["value"] for c in detail["cookies"]} == {
"sid": "•••", "theme": "dark", "shared": "s",
}
assert detail["storage"] == [{"origin": "https://app.example.com",
"local_storage": {"a": "1"}, "session_storage": {"s": "1"}}]
assert "RAW-SECRET" not in client.get(url()).text
def test_a_key_with_no_record_has_nothing_saved(client):
assert client.get("/admin/sessions/nobody/site-data").json() == {
"key": "nobody", "sites": [], "saved_at": None, "uri": "session://site-data",
"details": {},
}
def test_forget_takes_one_host_and_leaves_shared_cookies_and_the_history(client, server):
gone = client.delete(url("/app.example.com")).json()["forgotten"]
assert gone == {
"site": "app.example.com", "cookies": ["sid", "theme"],
"origins": ["https://app.example.com"],
"kept_shared": [{"name": "shared", "domain": ".example.com", "path": "/"}],
}
record = server.sessions.store.get(KEY)
assert [c["name"] for c in record.site_data["cookies"]] == ["shared", "ad"]
assert record.site_data["origins"] == {} and record.site_data["session"] == {}
assert record.history == visited().history, "History is not Site data's to touch"
second = client.delete(url("/app.example.com"))
assert second.status_code == 404
assert second.json() == {"error": "no saved site data for app.example.com"}
def test_clear_deletes_the_snapshot_and_keeps_the_history(client, server):
assert client.delete(url()).json() == {"cleared": ["app.example.com", "ads.example.net"]}
record = server.sessions.store.get(KEY)
assert record.site_data == {}
assert record.history == visited().history
assert client.get(url()).json()["sites"] == []
assert client.delete(url()).json() == {"cleared": []}, "nothing left is not an error"
def test_the_routes_need_the_token(server):
bare = TestClient(server.mcp.http_app())
assert bare.get(url()).status_code == 401
assert bare.delete(url("/app.example.com")).status_code == 401
assert bare.delete(url()).status_code == 401
def test_the_row_counts_hosts_and_its_rev_moves_on_a_save_a_forget_and_a_clear(client, server):
before = row(client)
assert before["site_data_count"] == 2
client.delete(url("/ads.example.net"))
forgot = row(client)
assert forgot["site_data_count"] == 1
assert forgot["site_data_rev"] != before["site_data_rev"]
record = server.sessions.store.get(KEY)
server.sessions.store.set(KEY, record.with_site_data({**record.site_data, "saved_at": 20.0}))
resaved = row(client)
assert resaved["site_data_rev"] != forgot["site_data_rev"], "a re-save of the same hosts moves it"
client.delete(url())
assert row(client)["site_data_count"] == 0
def test_details_say_what_forget_takes_and_what_it_leaves(client):
d = client.get(url()).json()["details"]["app.example.com"]
assert d["own_cookies"] == ["sid", "theme"]
assert d["kept_shared"] == [{"name": "shared", "domain": ".example.com", "path": "/"}]
def test_a_parent_only_row_can_be_forgotten_and_a_covered_host_cannot(server):
server.sessions.store.set(KEY, visited().with_site_data({
"cookies": [{"name": "shared", "value": "s", "domain": ".example.org", "path": "/"}],
"origins": {}, "session": {}, "saved_at": 10.0,
}))
client = TestClient(server.mcp.http_app(), headers=AUTH)
body = client.get(url()).json()
assert [s["site"] for s in body["sites"]] == ["example.org"]
assert body["details"]["example.org"]["own_cookies"] == ["shared"]
assert client.delete(url("/mail.example.org")).status_code == 404, "covered, nothing of its own"
assert client.delete(url("/example.org")).status_code == 200
assert row(client)["site_data_count"] == 0
def test_forget_answers_404_through_the_central_policy(client):
from kubed.selenium_flow import errors
assert errors.status_for(errors.NotFound("x")) == 404
body = client.delete(url("/nothing.example.net"))
assert body.status_code == 404
assert body.json() == {"error": "no saved site data for nothing.example.net"}
def test_the_payload_comes_from_one_grouping_of_the_jar(server, monkeypatch):
cookies = [{"name": "c", "value": "v", "domain": f"h{i}.example{i}.com", "path": "/"}
for i in range(300)]
server.sessions.store.set(KEY, visited().with_site_data({
"cookies": cookies, "origins": {}, "session": {}, "saved_at": 1.0}))
calls = {"n": 0}
real = site_data._Jar.covering
def counting(self, host):
calls["n"] += 1
return real(self, host)
monkeypatch.setattr(site_data._Jar, "covering", counting)
body = TestClient(server.mcp.http_app(), headers=AUTH).get(url()).json()
assert len(body["details"]) >= 300
assert calls["n"] <= len(body["details"]) + 5
def test_forget_keeps_a_save_that_lands_while_it_works(client, server, monkeypatch):
"""The handler used to read the record, work, and set it back whole: a save
landing in between was reverted. The save here runs on another thread the
moment Forget starts computing, and must survive it."""
import threading
store = server.sessions.store
real = site_data.forget
saver = []
def save(r):
origins = {**r.site_data["origins"], "https://late.example.net": {"local": {"b": "2"}}}
return r.with_site_data({**r.site_data, "origins": origins})
def forget(data, host):
if not saver:
saver.append(threading.Thread(target=store.update, args=(KEY, save)))
saver[0].start()
saver[0].join(0.3)
return real(data, host)
monkeypatch.setattr(site_data, "forget", forget)
assert client.delete(url("/app.example.com")).status_code == 200
saver[0].join(5)
assert list(store.get(KEY).site_data["origins"]) == ["https://late.example.net"]Append to tests/test_site_data_surface.py:
def test_the_listing_and_one_site_are_the_declared_shapes():
from kubed.selenium_flow.spec.schemas import SITE_DATA_SCHEMAS
data = {
"cookies": [{"name": "sid", "value": "1", "domain": SITE, "path": "/"}],
"origins": {f"https://{SITE}": {"local": {"k": "v"}}},
"session": {"origin": f"https://{SITE}", "items": {"s": "1"}},
"saved_at": 1.0,
}
declared = SITE_DATA_SCHEMAS["SiteList"]["properties"]
listing = site_data.view(data)
assert set(listing) == set(declared)
assert set(listing["sites"][0]) == set(declared["sites"]["items"]["properties"])
one = site_data.site_view(data, SITE)
assert set(one) == set(SITE_DATA_SCHEMAS["SiteData"]["properties"])and drop the view tests that moved to tests/test_site_data_views.py:
python3 $S/drop.py tests/test_site_data.py \
test_the_view_groups_by_host_and_shares_parent_cookies \
test_one_host_on_two_ports_keeps_each_origins_storage_apart \
test_a_parent_cookie_with_no_host_gets_its_own_site \
test_secrets_are_matched_by_host_and_unleashed_ones_counted \
test_forget_keeps_shared_cookies_and_other_sites \
test_summary_is_none_when_empty \
test_forgetting_a_parent_only_row_removes_its_dotted_cookie \
test_rows_do_not_depend_on_the_order_the_cookies_came_in \
test_saved_counts_only_what_forget_would_remove \
test_the_site_view_names_goes_and_stays_by_the_forget_rule \
test_a_sites_own_dotted_cookie_is_not_shared_but_a_parents_is \
test_two_shared_cookies_of_one_name_stay_apart \
test_a_jar_of_many_domains_is_not_rescanned_per_hostExpected: tests/test_site_data.py: dropped 13.
- Step 2: Run to verify they fail
Run: PYTHONPATH=$S/deps:. python3 -S -m pytest -q tests/test_site_data_views.py tests/test_admin_site_data.py tests/test_site_data_surface.py
Expected: FAIL — AttributeError: ... has no attribute 'history_hosts'; the listing still has saved, secrets, saved_sites; DELETE …/site-data is a 405.
- Step 3: Implement the views —
kubed/selenium_flow/core/site_data.py.
In _Jar.__init__, replace the origins loop (lines 184–186) with:
# host -> origin -> its two storages. sessionStorage is one tab's, for
# the one origin the save was made on; empty, it makes no row.
self.origins: dict[str, dict] = {}
for o, e in (self.data.get("origins") or {}).items():
self.origins.setdefault(host_of(o), {})[o] = {
"local": dict((e or {}).get("local") or {}), "session": {},
}
session = self.data.get("session") or {}
if session.get("origin") and session.get("items"):
entry = self.origins.setdefault(host_of(session["origin"]), {}).setdefault(
session["origin"], {"local": {}, "session": {}}
)
entry["session"] = dict(session["items"])Replace _site (lines 244–270) with:
def _site(jar: _Jar, host: str) -> dict:
origins = jar.origins.get(host, {})
cookies = jar.covering(host)
# Per origin, never merged: the same host on two ports or schemes is two
# origins, each restored with its own storage.
storage = [
{
"origin": o,
"local_storage": origins[o]["local"],
"session_storage": origins[o]["session"],
}
for o in sorted(origins)
]
return {
"site": host,
"uri": site_uri(host),
"_cookies": cookies,
"_own": [c for c in cookies if _own(c.get("domain") or "", host)],
"storage": storage,
}Replace _rows, view, views and site_view (lines 288–351) with:
def history_hosts(history) -> list[str]:
"""The hosts of a history, most recent first, each once."""
hosts = (host_of(v["origin"]) for v in history or ())
return list(dict.fromkeys(h for h in hosts if h))
def _rows(data: dict, history=()) -> tuple[dict, dict]:
"""Every host the snapshot holds data for, once: ``(listing, details)``
from a single grouping of the jar. Hosts the session went to come first,
most recent first, then the rest alphabetically."""
jar = _Jar(data)
hosts = jar.hosts()
stored = set(hosts)
visited = [h for h in history_hosts(history) if h in stored]
ordered = visited + [h for h in hosts if h not in set(visited)]
rows, details = [], {}
for host in ordered:
site = _site(jar, host)
rows.append({
"site": host, "uri": site["uri"], "cookies": len(site["_cookies"]),
"storage": [
{
"origin": e["origin"],
"local_storage": len(e["local_storage"]),
"session_storage": len(e["session_storage"]),
}
for e in site["storage"]
],
})
details[host] = {
"site": host, "uri": site["uri"],
"cookies": [_cookie_view(c, host) for c in site["_cookies"]],
"storage": site["storage"],
# What Forget would do, by the rule Forget itself uses.
"own_cookies": [c["name"] for c in site["_own"]],
"kept_shared": [_identity(c) for c in _shared(site["_cookies"], host)],
}
listing = {"sites": rows, "saved_at": (data or {}).get("saved_at"), "uri": LIST_URI}
return listing, details
def view(data: dict, history=()) -> dict:
"""The listing: one entry per host the snapshot holds data for, counts
only, never a value. Every row has something saved."""
return _rows(data, history)[0]
def views(data: dict, history=()) -> tuple[dict, dict]:
"""``(listing, details)``: the listing and every host in full, from one
pass over the jar."""
return _rows(data, history)
def site_view(data: dict, site: str) -> dict | None:
"""One host in full: cookies (httpOnly masked) and both storages, per
origin."""
return _rows(data)[1].get((site or "").lower())Replace forget (lines 359–378) with:
def forget(data: dict, host: str) -> tuple[dict, dict]:
"""Remove one host from the snapshot: its storage, sessionStorage
included, and the cookies named for it (``host`` or ``.host``). A
parent-domain cookie stays — other hosts use it — and so does
``saved_at``: forgetting is not a save."""
host = (host or "").lower()
jar = _Jar(data)
covering = jar.covering(host)
gone_ids = {id(c) for c in covering if _own(c.get("domain") or "", host)}
stored = data.get("origins") or {}
origins = [o for o in stored if host_of(o) == host]
session = data.get("session") or {}
ours = host_of(session.get("origin") or "") == host
if ours and session.get("items") and session["origin"] not in origins:
origins.append(session["origin"])
left = {
**data,
"cookies": [c for c in jar.cookies if id(c) not in gone_ids],
"origins": {o: e for o, e in stored.items() if host_of(o) != host},
**({"session": {}} if ours else {}),
}
return left, {
"site": host,
"cookies": [c["name"] for c in covering if id(c) in gone_ids],
"origins": origins,
"kept_shared": [_identity(c) for c in _shared(covering, host)],
}- Step 4: Implement the API and the resources
kubed/selenium_flow/http/admin.py — in sessions_payload, replace the saved = site_data.view(...) line and the site_data_rev = json.dumps(...) that follows (lines 596–610) with:
listed = site_data.view(record.site_data, record.history)
# A save moves the time; a Forget or a Clear moves the hosts.
site_data_rev = json.dumps(
[record.site_data.get("saved_at"), [s["site"] for s in listed["sites"]]]
)and the row's "site_data_count": saved["saved_sites"], (line 629) with "site_data_count": len(listed["sites"]),.
Replace admin_site_data (lines 809–825, decorator and route included) with:
@mcp.custom_route(
f"{prefix}/admin/sessions/{{key}}/site-data",
methods=["GET"],
name="admin_site_data",
)
@guarded
async def admin_site_data(request: Request) -> JSONResponse:
"""What a reopened browser gets back: the snapshot by host, each in
full, values masked, the hosts the session went to first. No secrets:
those are History's."""
key = request.path_params["key"]
try:
record = await run_in_threadpool(sessions.store.get, key)
except Exception as exc: # noqa: BLE001 - errors.py says what it means
return refused(exc, f"site data for {key}")
data, history = (record.site_data, record.history) if record else ({}, [])
listed, details = site_data.views(data, history)
return JSONResponse({"key": key, **listed, "details": details})
@mcp.custom_route(
f"{prefix}/admin/sessions/{{key}}/site-data",
methods=["DELETE"],
name="admin_site_data_clear",
)
@guarded
async def admin_site_data_clear(request: Request) -> JSONResponse:
"""Clear: delete the snapshot. The history and the live browser are
untouched; only the next browser opened comes back signed out."""
key = request.path_params["key"]
cleared: dict = {}
def clear(record: SessionRecord) -> SessionRecord:
listed = site_data.view(record.site_data, record.history)
cleared["hosts"] = [s["site"] for s in listed["sites"]]
return record.with_site_data({})
try:
await run_in_threadpool(sessions.store.update, key, clear)
except Exception as exc: # noqa: BLE001 - errors.py says what it means
return refused(exc, f"clearing site data for {key}")
return JSONResponse({"cleared": cleared.get("hosts", [])})(secret_rows is left in place for Task 7.)
kubed/selenium_flow/mcp/resources.py — replace SITE_DESCRIPTION and the three functions at the end (_saved, site_listing, one_site) with:
SITE_DESCRIPTION = (
"What this session's last save_site_data holds: one entry per site with "
"counts, never a value, the sites it went to first. Every browser this "
"session opens has it back before open_session returns.\n\n"
"session://site-data/{site} shows one site in full. Reading this never "
"opens a browser."
)def site_listing(sessions: SessionManager, name: str) -> dict:
"""``session://site-data``. Reads the record; never opens a browser."""
record = sessions.store.get(name)
if record is None:
return site_data.view({})
return site_data.view(record.site_data, record.history)
def one_site(sessions: SessionManager, name: str, site: str) -> dict:
"""``session://site-data/{site}``, or a ValueError that says where to look."""
record = sessions.store.get(name)
found = site_data.site_view(record.site_data if record else {}, site)
if found is None:
raise ValueError(f"no saved data for {site!r}: {site_data.LIST_URI} lists them")
return foundkubed/selenium_flow/spec/schemas.py — replace SITE_DATA_SCHEMAS (from SITE_DATA_SCHEMAS = { to its closing } before _FILE_OPERATIONS) with:
def _storage(kind: str) -> dict:
"""localStorage and sessionStorage per origin of one host: key counts
(``integer``) in a listing, the keys themselves (``object``) in full."""
return {
"type": "array",
"description": (
"Per origin: the same host on another port or scheme is another "
"origin. sessionStorage is the one tab's, for the page the save was "
"made on."
),
"items": {
"type": "object",
"required": ["origin", "local_storage", "session_storage"],
"properties": {
"origin": {"type": "string"},
"local_storage": {"type": kind},
"session_storage": {"type": kind},
},
},
}
SITE_DATA_SCHEMAS = {
"SiteList": {
"type": "object",
"description": (
"session://site-data: one entry per site the last save holds data "
"for, counts only, the sites the session went to first."
),
"properties": {
"sites": {
"type": "array",
"items": {
"type": "object",
"required": ["site", "uri", "cookies", "storage"],
"properties": {
"site": {"type": "string", "description": "A host."},
"uri": {"type": "string"},
"cookies": {
"type": "integer",
"description": (
"Its own cookies and the parent-domain ones "
"that reach it."
),
},
"storage": _storage("integer"),
},
},
},
"saved_at": {
"type": ["number", "null"],
"description": "When the snapshot was saved, in epoch seconds.",
},
"uri": {"type": "string"},
},
},
"SiteData": {
"type": "object",
"description": (
"session://site-data/{site}: one site in full. An httpOnly cookie's "
"value is shown as •••."
),
"properties": {
"site": {"type": "string"},
"uri": {"type": "string"},
"cookies": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": {"type": "string"},
"value": {"type": "string"},
"domain": {"type": "string"},
"path": {"type": "string"},
"expiry": {"type": ["integer", "null"]},
"http_only": {"type": "boolean"},
"secure": {"type": "boolean"},
"same_site": {"type": ["string", "null"]},
"shared": {"type": "boolean"},
},
},
},
"storage": _storage("object"),
"own_cookies": {
"type": "array",
"items": {"type": "string"},
"description": (
"Cookies this site owns (host or .host): what Forget removes."
),
},
"kept_shared": {
"type": "array",
"items": {
"type": "object",
"required": ["name", "domain", "path"],
"properties": {
"name": {"type": "string"},
"domain": {"type": "string"},
"path": {"type": "string"},
},
},
"description": (
"Parent-domain cookies it only sits under: Forget keeps them."
),
},
},
},
}-
Step 5: Run to verify they pass —
PYTHONPATH=$S/deps:. python3 -S -m pytest -q tests/test_site_data_views.py tests/test_admin_site_data.py tests/test_site_data_surface.py tests/test_site_data.py tests/test_site_data_sessions.py tests/test_openapi.py tests/test_resources.py→ PASS; then the whole suite → PASS; ruff clean. -
Step 6: Break it on purpose — in
_rows, setordered = hosts; confirmtest_hosts_in_the_history_come_first_then_the_rest_alphabeticallyandtest_the_list_is_the_snapshot_by_host_history_hosts_first_and_no_secretsfail; restore. -
Step 7: Commit
git add kubed/selenium_flow/core/site_data.py kubed/selenium_flow/http/admin.py \
kubed/selenium_flow/mcp/resources.py kubed/selenium_flow/spec/schemas.py \
tests/test_site_data_views.py tests/test_admin_site_data.py \
tests/test_site_data_surface.py tests/test_site_data.py
git commit -m "Site data round 2: Site data is the snapshot by host, history first; Clear" \
-m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"Files:
- Modify:
kubed/selenium_flow/core/site_data.py— addhistory_viewaftersite_view;matching_secrets(230–241) is reused as is. - Modify:
kubed/selenium_flow/session/store.py—SessionRecord.history_cleared. - Modify:
kubed/selenium_flow/http/admin.py— the session row (beside the Task 6 fields), newadmin_historyandadmin_history_clear(aftersecret_rows). - Create:
tests/test_admin_history.py. Modify:tests/test_site_data_views.py(append).
Interfaces:
-
Consumes: Task 6's
view(data, history)andhistory_hosts;matching_secrets(secrets, host);catalogue.listing()["secrets"]through the existingsecret_rows()(entries carryname, description, keys, allowed_urls, restricted). -
Produces:
-
site_data.history_view(history, data: dict, secrets: list[dict] | None = None) -> dict→{"sites": [row]}; row{"site": host, "url": str, "at": float, "saved": {"cookies": int, "local": int, "session": int} | None, "secrets": [{"name", "description", "keys"}]}. One row per host inhistory_hosts(history)order (the current one first), with the URL and time of that host's most recent entry;savedis the Site data row's counts for that host,Nonewhen it has no row; secrets join a row and never make one. -
SessionRecord.history_cleared() -> SessionRecord— the history cut to its top entry. -
GET {prefix}/admin/sessions/{key}/history→{"key": key, **history_view(...)}, or{"key": key, "sites": []}with no record.DELETEthe same path (Clear) →{"cleared": [host, …]}: every host but the current one; site data untouched. - Session row:
history_count = len(history_hosts(record.history)),history_rev = json.dumps([[v["origin"] for v in record.history], record.history[0]["url"] if record.history else None])— a new site, a return to an older one, or a page within the top site moves it, so History's top row follows the session card's "last page"; only the clock moving does not. (The UI treats the rev as opaque: nothing there changes.)
-
-
Step 1: Write the failing tests — append to
tests/test_site_data_views.py:
# ---- History: the history joined by host -----------------------------------
SECRETS = [
{"name": "app", "description": "the app", "keys": ["user", "pass"],
"allowed_urls": ["https://app.example.com"], "restricted": True},
{"name": "unvisited", "description": "", "keys": ["t"],
"allowed_urls": ["https://never.example.net"], "restricted": True},
{"name": "anywhere", "description": "", "keys": ["t"], "allowed_urls": [],
"restricted": False},
]
def test_the_history_is_one_row_per_host_current_first_with_counts_and_secrets():
history = [
visit("https://app.example.com/x", NOW - 60),
visit("http://app.example.com:8080/dev", NOW - 120),
visit("https://example.com/", NOW - 3600),
]
data = snapshot(cookies=[cookie("sid", "app.example.com"), cookie("ab", ".example.com")],
origins={APP: {"a": "1", "b": "2"}})
assert sd.history_view(history, data, SECRETS) == {"sites": [
{"site": "app.example.com", "url": "https://app.example.com/x", "at": NOW - 60,
"saved": {"cookies": 2, "local": 2, "session": 0},
"secrets": [{"name": "app", "description": "the app", "keys": ["user", "pass"]}]},
{"site": "example.com", "url": "https://example.com/", "at": NOW - 3600,
"saved": None, "secrets": []},
]}
def test_a_secret_never_makes_a_row_and_nothing_visited_is_no_rows():
data = snapshot(cookies=[cookie("t", "never.example.net")])
assert sd.history_view([], data, SECRETS) == {"sites": []}Create tests/test_admin_history.py:
"""The admin API over where a session has been: the History tab's routes
and the two fields each session row carries for it.
One row per host, the current one first, joined with the secrets allowed
there and what the snapshot holds for it; a secret never makes a row; Clear
keeps the current site and touches neither site data nor the browser.
"""
import json
import pytest
from starlette.testclient import TestClient
from kubed.selenium_flow.config import Settings
from kubed.selenium_flow.server import SeleniumMCP
from kubed.selenium_flow.session.store import SessionRecord
from .conftest import TOKEN
pytestmark = pytest.mark.unit
KEY = "desktop"
AUTH = {"Authorization": f"Bearer {TOKEN}"}
NOW = 1_790_800_000.0
SECRETS = {"entries": {
"app": {"description": "the app", "allowed_urls": ["https://app.example.com"],
"keys": {"user": {"value": "u"}, "pass": {"value": "p"}}},
"mail": {"description": "the mailbox", "allowed_urls": ["https://mail.example.org"],
"keys": {"user": {"value": "me"}}},
"unvisited": {"description": "a site never reached",
"allowed_urls": ["https://never.example.net"],
"keys": {"token": {"value": "t"}}},
}}
DATA = {
"cookies": [
{"name": "sid", "value": "1", "domain": "app.example.com", "path": "/", "http_only": True},
{"name": "theme", "value": "dark", "domain": "app.example.com", "path": "/"},
],
"origins": {"https://app.example.com": {"local": {"a": "1", "b": "2"}}},
"session": {},
"saved_at": NOW - 30,
}
def visited():
return (SessionRecord(session_id="")
.at("https://mail.example.org/inbox", now=NOW - 3600)
.at("http://app.example.com:8080/dev", now=NOW - 120)
.at("https://app.example.com/x", now=NOW - 60))
@pytest.fixture
def server():
s = SeleniumMCP(Settings(
grid={"url": "http://grid.invalid:4444"}, auth={"token": TOKEN}, secrets=SECRETS,
))
s.sessions.store.set(KEY, visited().with_site_data(DATA))
return s
@pytest.fixture
def client(server):
return TestClient(server.mcp.http_app(), headers=AUTH)
URL = f"/admin/sessions/{KEY}/history"
def row(client):
rows = client.get("/admin/sessions").json()["sessions"]
return next(r for r in rows if r["key"] == KEY)
def test_one_row_per_host_current_first_with_its_latest_page(client):
body = client.get(URL).json()
assert body["key"] == KEY
assert [(s["site"], s["url"], s["at"]) for s in body["sites"]] == [
("app.example.com", "https://app.example.com/x", NOW - 60),
("mail.example.org", "https://mail.example.org/inbox", NOW - 3600),
]
def test_each_row_carries_what_is_saved_for_its_host(client):
sites = {s["site"]: s for s in client.get(URL).json()["sites"]}
assert sites["app.example.com"]["saved"] == {"cookies": 2, "local": 2, "session": 0}
assert sites["mail.example.org"]["saved"] is None
def test_secrets_join_the_hosts_they_allow_and_never_make_a_row(client):
body = client.get(URL).json()
sites = {s["site"]: s for s in body["sites"]}
app = sites["app.example.com"]["secrets"]
assert [s["name"] for s in app] == ["app"]
assert app[0]["description"] == "the app" and sorted(app[0]["keys"]) == ["pass", "user"]
assert [s["name"] for s in sites["mail.example.org"]["secrets"]] == ["mail"]
assert "never.example.net" not in json.dumps(body)
def test_a_key_with_no_record_has_no_rows(client):
assert client.get("/admin/sessions/nobody/history").json() == {"key": "nobody", "sites": []}
def test_clear_keeps_the_current_site_and_nothing_else(client, server):
assert client.delete(URL).json() == {"cleared": ["mail.example.org"]}
record = server.sessions.store.get(KEY)
assert record.history == visited().history[:1]
assert record.site_data == DATA, "site data is not History's to touch"
assert [s["site"] for s in client.get(URL).json()["sites"]] == ["app.example.com"]
def test_the_routes_need_the_token(server):
bare = TestClient(server.mcp.http_app())
assert bare.get(URL).status_code == 401
assert bare.delete(URL).status_code == 401
def test_the_row_counts_hosts_and_its_rev_follows_the_origins_and_the_top_page(client, server):
before = row(client)
assert before["history_count"] == 2
assert json.loads(before["history_rev"]) == [
["https://app.example.com", "http://app.example.com:8080", "https://mail.example.org"],
"https://app.example.com/x",
]
server.sessions.store.update(KEY, lambda r: r.at("https://mail.example.org/sent", now=NOW))
moved = row(client)
assert moved["history_rev"] != before["history_rev"], "another site on top"
client.delete(URL)
assert row(client)["history_count"] == 1
def test_a_page_within_the_top_site_moves_the_rev_and_the_clock_alone_does_not(client, server):
"""History's top row is the session card's last page: a navigation within
the top site must repaint it, and a call that stays put must not."""
before = row(client)["history_rev"]
server.sessions.store.update(KEY, lambda r: r.at("https://app.example.com/y", now=NOW))
within = row(client)["history_rev"]
assert within != before, "a page within the top site"
server.sessions.store.update(KEY, lambda r: r.at("https://app.example.com/y", now=NOW + 5))
assert row(client)["history_rev"] == within, "only the clock moved"- Step 2: Run to verify they fail
Run: PYTHONPATH=$S/deps:. python3 -S -m pytest -q tests/test_site_data_views.py tests/test_admin_history.py
Expected: FAIL — AttributeError: ... has no attribute 'history_view'; the history routes answer 404; the row has no history_count.
- Step 3: Implement
kubed/selenium_flow/core/site_data.py, after site_view:
def history_view(history, data: dict, secrets: list[dict] | None = None) -> dict:
"""The History tab: one row per host the session landed on, the current
one first, each with its latest URL and when, what the snapshot holds for
it (None when nothing), and the secrets allowed there.
Secrets join a row and never make one: a host the session never reached
is not listed, whatever a secret allows.
"""
saved = {r["site"]: r for r in view(data, history)["sites"]}
latest: dict[str, dict] = {}
for v in history or ():
latest.setdefault(host_of(v["origin"]), v)
rows = []
for host in history_hosts(history):
row = saved.get(host)
rows.append({
"site": host,
"url": latest[host]["url"],
"at": latest[host]["at"],
"saved": {
"cookies": row["cookies"],
"local": sum(e["local_storage"] for e in row["storage"]),
"session": sum(e["session_storage"] for e in row["storage"]),
} if row else None,
"secrets": matching_secrets(secrets, host),
})
return {"sites": rows}kubed/selenium_flow/session/store.py, after with_site_data:
def history_cleared(self) -> SessionRecord:
"""The same record with only its current page left in the history."""
return replace(self, history=self.history[:1])kubed/selenium_flow/http/admin.py — in sessions_payload, after the site_data_rev = … lines Task 6 wrote, add
visited = site_data.history_hosts(record.history)and in the row, after "site_data_rev": site_data_rev,:
"history_count": len(visited),
# The origins in order and the top page: a new site, a
# return to an older one, or a page within the top one
# repaints History, whose top row is the card's last
# page. Only the clock moving does not.
"history_rev": json.dumps([
[v["origin"] for v in record.history],
record.history[0]["url"] if record.history else None,
]),After async def secret_rows() add the two routes:
@mcp.custom_route(
f"{prefix}/admin/sessions/{{key}}/history",
methods=["GET"],
name="admin_history",
)
@guarded
async def admin_history(request: Request) -> JSONResponse:
"""Where this session has been, by host, the current one first: each
joined with the secrets allowed there and what the snapshot holds for
it. Built per request; nothing is stored for it."""
key = request.path_params["key"]
try:
secrets = await secret_rows()
record = await run_in_threadpool(sessions.store.get, key)
except Exception as exc: # noqa: BLE001 - errors.py says what it means
return refused(exc, f"history for {key}")
if record is None:
return JSONResponse({"key": key, "sites": []})
joined = site_data.history_view(record.history, record.site_data, secrets)
return JSONResponse({"key": key, **joined})
@mcp.custom_route(
f"{prefix}/admin/sessions/{{key}}/history",
methods=["DELETE"],
name="admin_history_clear",
)
@guarded
async def admin_history_clear(request: Request) -> JSONResponse:
"""Clear: the history down to the current site. Site data and the live
browser are untouched, and the history expires on its own anyway."""
key = request.path_params["key"]
cleared: dict = {}
def clear(record: SessionRecord) -> SessionRecord:
cleared["hosts"] = site_data.history_hosts(record.history)[1:]
return record.history_cleared()
try:
await run_in_threadpool(sessions.store.update, key, clear)
except Exception as exc: # noqa: BLE001 - errors.py says what it means
return refused(exc, f"clearing the history of {key}")
return JSONResponse({"cleared": cleared.get("hosts", [])})-
Step 4: Run to verify they pass —
PYTHONPATH=$S/deps:. python3 -S -m pytest -q tests/test_site_data_views.py tests/test_admin_history.py tests/test_admin_site_data.py tests/test_files_and_admin.py→ PASS; the whole suite → PASS; ruff clean. -
Step 5: Break it on purpose — in
history_view, iteratesaved(the Site data rows) instead ofhistory_hosts(history); confirmtest_a_secret_never_makes_a_row_and_nothing_visited_is_no_rowsandtest_one_row_per_host_current_first_with_its_latest_pagefail; restore. Then drop the top page's URL fromhistory_rev; confirmtest_a_page_within_the_top_site_moves_the_rev_and_the_clock_alone_does_notfails; restore. -
Step 6: Commit
git add kubed/selenium_flow/core/site_data.py kubed/selenium_flow/session/store.py \
kubed/selenium_flow/http/admin.py tests/test_site_data_views.py tests/test_admin_history.py
git commit -m "Site data round 2: History — where the session has been, joined by host; Clear" \
-m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"Files:
- Modify:
kubed/selenium_flow/core/site_data.py— module docstring (1–17); deletemerge(73–122); addLEFT_OUT,snapshot; rewritecapture(393–424). - Modify:
kubed/selenium_flow/core/actions.py—Actions.__init__(308–326),save_site_data(468–477). - Modify:
kubed/selenium_flow/session/sessions.py—_save_site_data(Task 5) callssnapshot; addvisited. - Modify:
kubed/selenium_flow/server.py— wireactions.visitedafterself.actions.read_file = …(line 184). - Modify:
kubed/selenium_flow/flows/run.py—runand_run(544–591) gainbefore_save; the step loop (thewas_atsetup near 606, the success branch near 788, the call near 759) feeds and calls it. - Modify:
kubed/selenium_flow/flows/api.py—run_one(276–309) passesbefore_saveon;run_forflushes before a save step. - Modify:
kubed/selenium_flow/mcp/tools.py—save_site_datadocstring (307–316). - Modify:
kubed/selenium_flow/spec/schemas.py—RESPONSES["save_site_data"](160–191). - Create:
tests/test_site_data_save.py. Modify:tests/test_site_data.py(drop the merge and capture tests and their fakes),tests/test_site_data_surface.py,tests/test_sessions.py.
Interfaces:
-
Consumes: Task 3's
spare_tab/ServiceWorkerAnswered; Task 4'sREAD_LOCAL,SW_REASON,_why; Task 1'sSessionRecord.history; Task 5's_save_site_data. -
Produces:
-
site_data.capture(bidi, driver, origins=()) -> dict→{"cookies": [cookie], "origin": str, "local": {}, "session": {}, "others": {origin: {local}}, "failed": [{"site", "reason"}]}.originsis the history's, newest first; the page's own origin and anything nothttp(s)are skipped; the rest are read one at a time in one spare tab. An unreadable jar still raisesBidiUnavailable. -
site_data.LEFT_OUT: str— the reason an origin's storage was left out over the cap. -
site_data.snapshot(previous: dict, captured: dict, history, now: float) -> tuple[dict, dict]→(data, receipt):datais the whole new snapshot (Task 4's shape);receiptis{"cookies": int, "sites": [origin], "skipped": [{"site", "reason"}]}. A failed origin keepsprevious's local storage. OverMAX_BYTESthe oldest-visited origins go first, the page's own local then session last; a jar alone over it raisesValueError(400) and nothing is saved. -
Actions.visited: Callable[[], list[str]] | None— the caller's session's history origins, newest first;None(the default) means a save reads only the page. -
SessionManager.visited(name: str) -> list[str]. -
flows.run.run(..., before_save=None)andflows.api.run_one(..., before_save=None)—before_save(pages)is called just before asave_site_datastep acts, with the pages the run has reached so far, in order: exactly the step URLs its report shows (ok steps that moved and were safe to show). -
flows.api.run_for(...)— before a save step it writes those pages to the history in onetouch, so the save reads their localStorage; the end-of-run touch then carries only the pages after the last flush. A run with no save still writes once. - Deleted:
site_data.merge.
-
-
Step 1: Write the failing tests —
tests/test_site_data_save.py:
"""A save: the whole jar, the page's two storages, and the localStorage of
every other origin the session has been to, read in a spare tab — one
snapshot that replaces the last (spec round 2, *save_site_data*)."""
import json
from types import SimpleNamespace
import pytest
from kubed.selenium_flow.core import site_data as sd
from kubed.selenium_flow.session.store import SessionRecord
from .site_data_fakes import (
NOW,
FakeBidi,
FakeSpare,
bidi_cm,
bidi_cookie,
cookie,
snapshot,
)
pytestmark = pytest.mark.unit
APP, SSO, OLD = "https://app.example.com", "https://sso.example.com", "https://old.example.com"
PAGE = {"origin": APP, "local": {"a": "1"}, "session": {"t": "1"}}
def visit(origin, at=NOW):
return {"origin": origin, "url": origin + "/", "at": at}
def captured(origin=APP, local=None, session=None, cookies=None, others=None, failed=None):
return {
"cookies": cookies if cookies is not None else [cookie("sid", "app.example.com")],
"origin": origin, "local": local or {}, "session": session or {},
"others": others or {}, "failed": failed or [],
}
# ---- snapshot: one save replaces the last ------------------------------------
def test_a_save_replaces_the_whole_snapshot():
before = snapshot(cookies=[cookie("old", "old.example.com")], origins={OLD: {"x": "1"}},
session={"origin": OLD, "items": {"s": "1"}}, saved_at=NOW - 60)
data, saved = sd.snapshot(before, captured(local={"theme": "dark"}), [visit(APP)], NOW)
assert data == {
"cookies": [cookie("sid", "app.example.com")],
"origins": {APP: {"local": {"theme": "dark"}}},
"session": {"origin": APP, "items": {}},
"saved_at": NOW,
}
assert saved == {"cookies": 1, "sites": [APP], "skipped": []}
def test_every_origin_read_is_kept_newest_first_and_an_empty_one_left_out():
history = [visit(APP, NOW), visit(SSO, NOW - 10), visit(OLD, NOW - 20)]
others = {OLD: {"o": "1"}, SSO: {"kc": "1"}, "https://empty.example.com": {}}
data, saved = sd.snapshot({}, captured(local={"a": "1"}, others=others), history, NOW)
assert list(data["origins"]) == [APP, SSO, OLD]
assert saved["sites"] == [APP, SSO, OLD]
def test_the_page_session_storage_is_kept_for_its_origin():
data, saved = sd.snapshot({}, captured(session={"token": "t"}), [visit(APP)], NOW)
assert data["session"] == {"origin": APP, "items": {"token": "t"}}
assert data["origins"] == {} and saved["sites"] == [APP], "sessionStorage alone is a site saved"
def test_a_page_with_no_origin_saves_its_cookies_and_the_others():
data, saved = sd.snapshot({}, captured(origin="", others={SSO: {"kc": "1"}}), [visit(SSO)], NOW)
assert data["session"] == {} and list(data["origins"]) == [SSO]
assert saved == {"cookies": 1, "sites": [SSO], "skipped": []}
def test_an_origin_that_could_not_be_read_keeps_its_last_storage():
before = snapshot(origins={SSO: {"kc": "old"}})
failed = [{"site": SSO, "reason": sd.SW_REASON}, {"site": OLD, "reason": "the navigation failed"}]
history = [visit(APP), visit(SSO), visit(OLD)]
data, saved = sd.snapshot(before, captured(local={"a": "1"}, failed=failed), history, NOW)
assert data["origins"] == {APP: {"local": {"a": "1"}}, SSO: {"local": {"kc": "old"}}}
assert saved["sites"] == [APP]
assert saved["skipped"] == failed
def test_over_the_cap_the_origins_visited_longest_ago_go_first():
third = {"blob": "x" * (sd.MAX_BYTES // 3)}
history = [visit(APP, NOW), visit(SSO, NOW - 10), visit(OLD, NOW - 20)]
data, saved = sd.snapshot({}, captured(local=third, others={SSO: third, OLD: third}), history, NOW)
assert len(json.dumps(data)) <= sd.MAX_BYTES
assert list(data["origins"]) == [APP, SSO]
assert saved["skipped"] == [{"site": OLD, "reason": sd.LEFT_OUT}]
def test_the_pages_own_storage_is_the_last_to_go():
big = {"blob": "x" * (sd.MAX_BYTES - 1000)}
history = [visit(APP), visit(SSO, NOW - 10)]
data, saved = sd.snapshot({}, captured(local=big, session=big, others={SSO: {"kc": "1"}}),
history, NOW)
assert data["origins"] == {} and data["session"]["items"] == big
assert [s["site"] for s in saved["skipped"]] == [SSO, APP]
assert saved["sites"] == [APP]
def test_a_cookie_jar_over_the_cap_raises_and_saves_nothing():
big = [cookie("blob", "app.example.com", "x" * (sd.MAX_BYTES + 1))]
with pytest.raises(ValueError, match="the cookie jar is over 1000000 bytes; nothing was saved"):
sd.snapshot({}, captured(cookies=big), [visit(APP)], NOW)
from kubed.selenium_flow import errors
assert errors.status_for(ValueError("x")) == 400
def test_the_record_round_trips_its_snapshot():
data, _ = sd.snapshot({}, captured(local={"a": "1"}, session={"t": "1"}), [visit(APP)], NOW)
record = SessionRecord(session_id="s").at(APP + "/x").with_site_data(data)
again = SessionRecord.from_json(record.to_json())
assert again.site_data == data
assert again.detached().site_data == data, "ending a browser keeps site data"
assert again.at("https://x.example.com/").site_data == data
# ---- capture: what the browser holds -----------------------------------------
class PageDriver:
def __init__(self, dump):
self.dump = dump
def execute_script(self, script, *args):
return self.dump
@pytest.fixture
def spare(monkeypatch):
fake = FakeSpare(local={SSO: {"kc": "1"}, OLD: {}})
monkeypatch.setattr(sd, "spare_tab", fake)
return fake
def test_capture_reads_the_jar_the_page_and_every_other_origin_in_one_tab(spare):
bidi = FakeBidi()
bidi.storage.cookies = [bidi_cookie("sid", "app.example.com", http_only=True),
bidi_cookie("kc", "sso.example.com")]
got = sd.capture(bidi, PageDriver(PAGE), [APP, SSO, OLD])
assert got["cookies"][0] == {
"name": "sid", "value": "v", "value_type": "string", "domain": "app.example.com",
"path": "/", "http_only": True, "secure": True, "same_site": "lax", "expiry": None,
}
assert (got["origin"], got["local"], got["session"]) == (APP, {"a": "1"}, {"t": "1"})
assert got["others"] == {SSO: {"kc": "1"}, OLD: {}}
assert got["failed"] == []
assert spare.tabs == ["spare"], "one tab for every other origin"
assert spare.runs == [("spare", SSO), ("spare", OLD)], "the page's own is read in place"
def test_only_web_origins_are_visited(spare):
sd.capture(FakeBidi(), PageDriver(PAGE), ["chrome://settings", SSO, "file:///tmp"])
assert spare.runs == [("spare", SSO)]
def test_a_service_worker_origin_is_reported_and_never_read(spare):
spare.workers = {SSO}
got = sd.capture(FakeBidi(), PageDriver(PAGE), [APP, SSO, OLD])
assert got["others"] == {OLD: {}}
assert got["failed"] == [{"site": SSO, "reason": "a service worker answered: save while on this site"}]
def test_a_tab_that_cannot_open_fails_every_origin_it_did_not_read(spare):
spare.broken = RuntimeError("socket is already closed")
got = sd.capture(FakeBidi(), PageDriver(PAGE), [APP, SSO, OLD])
assert got["others"] == {}
assert got["failed"] == [
{"site": SSO, "reason": "socket is already closed"},
{"site": OLD, "reason": "socket is already closed"},
]
def test_no_other_origin_means_no_tab(spare):
got = sd.capture(FakeBidi(), PageDriver(PAGE), [APP])
assert spare.tabs == [] and got["others"] == {}
def test_a_save_on_a_page_with_no_storage_keeps_the_cookies(spare):
bidi = FakeBidi()
bidi.storage.cookies = [bidi_cookie("sid", "app.example.com")]
got = sd.capture(bidi, PageDriver({"origin": "", "local": {}, "session": {}}))
data, saved = sd.snapshot({}, got, [], NOW)
assert saved == {"cookies": 1, "sites": [], "skipped": []}
assert data["origins"] == {} and data["session"] == {}
# ---- the action, and the server that hands it the history --------------------
def page():
return SimpleNamespace(execute_script=lambda *a: dict(PAGE), current_url=APP + "/", title="App")
def test_save_site_data_reads_the_origins_the_session_has_been_to(actions, monkeypatch, spare):
monkeypatch.setattr(actions.grid, "reconnect", lambda sid: page())
monkeypatch.setattr(actions.grid, "bidi", bidi_cm(FakeBidi()))
actions.visited = lambda: [APP, SSO]
got = actions.save_site_data("sid")
assert got["url"] == APP + "/" and got["title"] == "App"
assert got[sd.CAPTURED]["others"] == {SSO: {"kc": "1"}}
def test_save_site_data_without_a_history_reads_only_the_page(actions, monkeypatch, spare):
monkeypatch.setattr(actions.grid, "reconnect", lambda sid: page())
monkeypatch.setattr(actions.grid, "bidi", bidi_cm(FakeBidi()))
assert actions.visited is None
assert actions.save_site_data("sid")[sd.CAPTURED]["others"] == {}
assert spare.tabs == []
def test_one_save_through_the_server_keeps_every_site_the_session_went_to(monkeypatch, spare):
"""The server hands `Actions` the caller's history: one save on the
second app keeps the first's storage too (spec round 2)."""
from starlette.testclient import TestClient
from kubed.selenium_flow.config import Settings
from kubed.selenium_flow.core.browser import Grid
from kubed.selenium_flow.server import SeleniumMCP
from kubed.selenium_flow.session.sessions import SessionManager
from .conftest import NAMED, TOKEN
monkeypatch.setattr(Grid, "reconnect", lambda self, sid: page())
monkeypatch.setattr(Grid, "bidi", lambda self, sid: bidi_cm(FakeBidi())(sid))
monkeypatch.setattr(SessionManager, "resolve", lambda self, name: "live-id")
server = SeleniumMCP(Settings(grid={"url": "http://grid.invalid:4444"}, auth={"token": TOKEN}))
server.sessions.store.set(
NAMED, SessionRecord(session_id="live-id").at(SSO + "/", now=NOW - 60).at(APP + "/x", now=NOW)
)
response = TestClient(server.mcp.http_app()).post(
"/browser/save-site-data", headers={"Authorization": f"Bearer {TOKEN}"},
params={"session": NAMED}, json={},
)
assert response.status_code == 200, response.json()
assert response.json()["saved"]["sites"] == [APP, SSO]
stored = server.sessions.store.get(NAMED).site_data
assert stored["origins"] == {APP: {"local": {"a": "1"}}, SSO: {"local": {"kc": "1"}}}
assert stored["session"] == {"origin": APP, "items": {"t": "1"}}
# ---- a save mid-flow sees the sites the run reached before it -----------------
class Tab:
"""One browser tab: where it is, and that page's own storage."""
title = "t"
def __init__(self):
self.current_url = "about:blank"
def get(self, url):
self.current_url = url
def execute_script(self, script, *args):
here = sd.origin_of(self.current_url)
return {"origin": here, "local": {"page": here}, "session": {}}
def test_a_save_mid_flow_reads_the_sites_the_run_reached_before_it(
named_caller, tmp_path, monkeypatch, spare
):
"""The run writes its pages once, at the end; a save step in the middle
still has to read the sites before it, so they go in first."""
from kubed.selenium_flow.config import Settings
from kubed.selenium_flow.core.browser import Grid
from kubed.selenium_flow.flows import api as flowapi
from kubed.selenium_flow.server import SeleniumMCP
from .conftest import NAMED
a, b, c = "https://a.example.com", "https://b.example.com", "https://c.example.com"
spare.local = {a: {"a": "1"}}
tab = Tab()
monkeypatch.setattr(Grid, "reconnect", lambda self, sid: tab)
monkeypatch.setattr(Grid, "is_alive", lambda self, sid: True)
monkeypatch.setattr(Grid, "bidi", lambda self, sid: bidi_cm(FakeBidi())(sid))
server = SeleniumMCP(Settings(
grid={"url": "http://grid.invalid:4444"}, flow={"data_dir": str(tmp_path)},
))
server.flows.save(NAMED, "trip", {"steps": [
{"tool": "navigate", "args": {"url": a + "/"}},
{"tool": "navigate", "args": {"url": b + "/"}},
{"tool": "save_site_data", "args": {}, "return": True},
{"tool": "navigate", "args": {"url": c + "/"}},
]})
server.sessions.store.set(NAMED, SessionRecord(session_id="live-id"))
touched = []
real = server.sessions.touch
def touch(name, *urls, browser=None):
touched.append(urls)
return real(name, *urls, browser=browser)
monkeypatch.setattr(server.sessions, "touch", touch)
report = flowapi.run_for(server.flows, server.actions, server.sessions, NAMED, "trip")
assert spare.runs == [("spare", a)], "a is read in the spare tab; b is the page"
assert report["steps"][2]["result"]["saved"]["sites"] == [b, a]
record = server.sessions.store.get(NAMED)
assert record.site_data["origins"] == {b: {"local": {"page": b}}, a: {"local": {"a": "1"}}}
assert len(touched) == 2 and touched[0] == (a + "/", b + "/"), "flushed before the save, in order"
assert a + "/" not in touched[1] and b + "/" not in touched[1], "the end writes only what came after"
assert [v["origin"] for v in record.history] == [c, b, a]Drop the round 1 merge and capture tests, and the fakes only they used:
python3 $S/drop.py tests/test_site_data.py \
captured \
test_a_save_replaces_cookies_and_adds_its_origin \
test_a_page_with_no_origin_saves_cookies_only \
test_a_save_over_the_cap_keeps_cookies_and_skips_that_storage \
test_the_record_round_trips_its_site_data \
FakeStorage FakeScript FakeBidi bidi_cookie PageDriver bidi_cm \
test_capture_reads_every_cookie_and_this_page_storage \
test_save_site_data_returns_the_capture_privately \
test_a_save_on_a_page_with_no_storage_keeps_the_cookies \
test_the_cap_is_on_what_is_stored_and_the_oldest_other_origins_make_room \
test_a_cookie_jar_over_the_cap_raises_and_saves_nothingExpected: tests/test_site_data.py: dropped 16. Then replace that module's docstring with:
"""Site data's groundwork: origins as `location.origin` spells them, the
record field, the page's storage read, and the BiDi socket. A save, a
restore and the views each have their own module."""In tests/test_site_data_surface.py, replace saved_record (it built its data with merge) with:
def saved_record():
data = {
"cookies": [
{"name": "sid", "value": "s3cret", "domain": SITE, "http_only": True},
{"name": "theme", "value": "dark", "domain": SITE},
],
"origins": {f"https://{SITE}": {"local": {"k": "v"}}},
"session": {"origin": f"https://{SITE}", "items": {}},
"saved_at": 1000.0,
}
return SessionRecord(site_data=data).at(f"https://{SITE}/x")and in test_the_published_kept_shared_is_the_shape_returned, replace the data, _ = site_data.merge(...) call with:
data = {
"cookies": [{"name": "ab", "value": "1", "domain": ".example.com", "path": "/",
"http_only": False, "secure": True, "same_site": "lax", "expiry": None}],
"origins": {"https://app.example.com": {"local": {"k": "v"}}},
"session": {},
"saved_at": 0.0,
}In tests/test_sessions.py, test_a_losing_clean_open_erases_nothing_from_the_winner built its data with merge; replace the from kubed.selenium_flow.core import site_data import and the data, _ = site_data.merge(...) call with:
data = {
"cookies": [{"name": "sid", "value": "1", "domain": "app.test"}],
"origins": {}, "session": {}, "saved_at": 1000.0,
}- Step 2: Run to verify they fail
Run: PYTHONPATH=$S/deps:. python3 -S -m pytest -q tests/test_site_data_save.py
Expected: FAIL — AttributeError: ... has no attribute 'snapshot'; capture takes no origins; Actions has no visited; the mid-flow save reads no other site (spare.runs == []).
- Step 3: Implement the snapshot —
kubed/selenium_flow/core/site_data.py.
Replace the module docstring (lines 1–17) with:
"""Site data: the cookies and storage a session keeps for the sites it uses.
A session outlives its browser, and used to come back signed out: the page it
was on survived a reap, the sign-in did not. An agent saves when it knows the
browser is worth keeping — after a sign-in is confirmed — and every browser
opened for the session gets it back (spec 2026-09-30, rounds 1 and 2).
Saved, not captured: every call paying for a snapshot was rejected, and a reap
gives no warning, so only an explicit save is dependable.
**A save is a snapshot.** It replaces the last one whole: the jar, the
localStorage of every origin the session has been to, and the sessionStorage
of the page it is on. Other origins are reached through a spare tab
(`browser.spare_tab`), which is also how a restore writes them back before the
first page loads.
**A site is a host.** Cookies carry a domain and no scheme or port, so the view
groups by host. Storage is kept per origin, as ``location.origin`` spells it.
Values are credentials. They live in the session store and nowhere else, are
never logged, and an httpOnly cookie's value is never shown on any surface.
"""Replace merge (from def merge( through its return data, {...}) with:
# Why an origin's storage was left out of a snapshot.
LEFT_OUT = f"left out: the snapshot would pass {MAX_BYTES} bytes"
def snapshot(previous: dict, captured: dict, history, now: float) -> tuple[dict, dict]:
"""One save, replacing the last whole: nothing is merged, so a sign-out
saved after a sign-in is what comes back.
``captured`` is what :func:`capture` read. ``origins`` keeps the
localStorage of every origin read that has any, newest first — the page,
then the history. ``session`` is the page's sessionStorage, for its
origin. An origin that could not be read keeps its storage from
``previous``, so a blip does not sign the agent out. Over
:data:`MAX_BYTES` the origins visited longest ago go first and the page's
own storage last; a jar that alone passes it is a ValueError, and nothing
is saved.
Returns ``(data, receipt)``; the receipt names what was saved, never a
value.
"""
here = captured.get("origin") or ""
read = dict(captured.get("others") or {})
if here:
read[here] = captured.get("local") or {}
skipped = [dict(f) for f in captured.get("failed") or []]
last = (previous or {}).get("origins") or {}
kept = {
f["site"]: (last.get(f["site"]) or {}).get("local")
for f in skipped if f.get("site")
}
newest = (here, *(v["origin"] for v in history or ()), *read, *kept)
origins = {}
for o in dict.fromkeys(o for o in newest if o):
local = read.get(o) or kept.get(o)
if local:
origins[o] = {"local": dict(local)}
items = dict(captured.get("session") or {})
data = {
"cookies": list(captured.get("cookies") or []),
"origins": origins,
"session": {"origin": here, "items": items} if here else {},
"saved_at": now,
}
if _size({**data, "origins": {}, "session": {}}) > MAX_BYTES:
# The jar is the one thing a save cannot leave out.
raise ValueError(
f"the cookie jar is over {MAX_BYTES} bytes; nothing was saved"
)
while _size(data) > MAX_BYTES:
if data["origins"]:
gone = list(data["origins"])[-1]
data["origins"] = {o: e for o, e in data["origins"].items() if o != gone}
else:
gone, data["session"] = data["session"]["origin"], {}
skipped.append({"site": gone, "reason": LEFT_OUT})
sites = [o for o in data["origins"] if o in read]
if data["session"].get("items") and here not in sites:
sites.insert(0, here)
return data, {"cookies": len(data["cookies"]), "sites": sites, "skipped": skipped}Replace capture (from def capture( through its return {...}) with:
def capture(bidi, driver, origins=()) -> dict:
"""The whole cookie jar (BiDi sees httpOnly ones), the page's own two
storages, and the localStorage of every other origin in ``origins`` — the
history's, newest first — read one at a time in a spare tab.
The browser answered classic WebDriver to get here, so a jar that cannot
be read is the BiDi channel failing: :class:`BidiUnavailable`, a 503, and
nothing is saved. An origin that cannot be read is named in ``failed``;
the snapshot keeps its storage from the last save.
"""
from selenium.common import WebDriverException
from selenium.webdriver.common.bidi.storage import CookieFilter
from websocket import WebSocketException
try:
jar = bidi.storage.get_cookies(CookieFilter()).cookies
except (WebDriverException, WebSocketException, OSError) as e:
# Only a channel that did not answer is a 503. Anything else - an
# unexpected return shape, a bug here - stays a 500, as errors.py
# decides for every failure it does not recognise (Copilot, #50).
raise BidiUnavailable(BIDI_DOWN) from e
cookies = [
{
"name": c.name, "value": c.value.value, "value_type": c.value.type,
"domain": c.domain, "path": c.path, "http_only": c.http_only,
"secure": c.secure, "same_site": c.same_site, "expiry": c.expiry,
}
for c in jar
]
page = driver.execute_script(READ_STORAGE) or {}
here = page.get("origin") or ""
# Only http(s) can be stood on; the page's own origin is read in place.
wanted = [
o for o in dict.fromkeys(origins)
if o != here and o.startswith(("http://", "https://"))
]
others: dict[str, dict] = {}
failed: list[dict] = []
if wanted:
try:
with spare_tab(bidi) as run:
for origin in wanted:
try:
found = run(origin, READ_LOCAL)
others[origin] = found if isinstance(found, dict) else {}
except Exception as e: # noqa: BLE001 - one origin keeps its last storage
failed.append({"site": origin, "reason": _why(e)})
except Exception as e: # noqa: BLE001 - no tab: each origin not read keeps its last
done = set(others) | {f["site"] for f in failed}
failed.extend(
{"site": o, "reason": failure_text(e)} for o in wanted if o not in done
)
return {
"cookies": cookies,
"origin": here,
"local": page.get("local") or {},
"session": page.get("session") or {},
"others": others,
"failed": failed,
}- Step 4: Implement the wiring
kubed/selenium_flow/core/actions.py — at the end of Actions.__init__, after self.read_file = read_file:
# Where the caller's session has been, newest first, so one save reads
# every site's localStorage and not only the page's. A function for
# the reason `keep` is one: which session is calling is not this
# layer's to see. Absent, a save reads only the page it is on.
self.visited = Noneand replace save_site_data with:
def save_site_data(self, session_id: str, url=None) -> dict:
"""Capture the browser's cookies, the page's storage, and the
localStorage of every other origin the session has been to.
The capture rides back under a private key; the session manager stores
it and no caller sees it.
"""
driver = self._at(session_id, url)
origins = self.visited() if self.visited is not None else []
with self.grid.bidi(session_id) as bidi:
captured = site_data_module.capture(bidi, driver, origins)
return {**browser.page_state(driver), site_data_module.CAPTURED: captured}kubed/selenium_flow/session/sessions.py — in _save_site_data, the merge call becomes:
data, receipt["saved"] = site_data_module.snapshot(
r.site_data, captured, r.history, time.time()
)and add after context:
def visited(self, name: str) -> list[str]:
"""The origins this session has been to, newest first."""
record = self.store.get(name)
return [v["origin"] for v in record.history] if record else []kubed/selenium_flow/server.py — after the self.actions.read_file = … assignment (line 184):
# And where the caller's session has been, so one save reads every
# site's storage. Wired here for the same reason: which session is
# calling is a question about the caller.
self.actions.visited = lambda: self.sessions.visited(self.sessions.name())kubed/selenium_flow/flows/run.py — beside OUT_OF_TIME add
# The step that reads every site in the session's history (`before_save`).
SAVE_SITE_DATA = "save_site_data"add before_save=None, as the last parameter of run, pass it on as the last argument of the _run(...) call, and add before_save as the last parameter of _run. Add to run's docstring, after the before_step paragraph:
``before_save`` is called with the pages this run has reached so far, in
order — exactly the step URLs its report shows — just before a
``save_site_data`` step acts. A save reads every site in the session's
history, and the history is otherwise written once, after the run.
In _run, after was_at: str | None = None:
# The pages the report shows, in order, for `before_save`.
reached: list[str] = []in the success branch, record each page the report shows:
if went_to and went_to != was_at:
if not redacted_url and "url" not in guarded:
entry["url"] = went_to
reached.append(went_to)
was_at = went_toand between before_step(dict(entry), total) and raw = method(session_id, **kwargs):
if tool == SAVE_SITE_DATA and before_save is not None:
before_save(list(reached))kubed/selenium_flow/flows/api.py — run_one takes before_save=None after stop=None and passes before_save=before_save to flowrun.run(...). In run_for, insert before report = run_one(:
# A save reads the localStorage of every site in the history, and the run
# writes its pages once, at the end: so just before a save step the pages
# reached so far go in, in one write, and the end writes only the rest.
flushed = {"pages": 0, "told": None}
def before_save(pages):
told = sessions.touch(session, *pages[flushed["pages"]:], browser=resolved)
flushed["pages"] = len(pages)
flushed["told"] = flushed["told"] or toldpass before_save=before_save, to run_one(...) after before_step=before_step,, and end the function with:
visited = [
step["url"] for step in report.get("steps") or []
if step.get("ok") and step.get("url")
][flushed["pages"]:]
if report.get("url") and not report.get("url_redacted"):
visited.append(report["url"])
# A flush may already have handed over a reopen's report.
told = sessions.touch(session, *visited, browser=resolved) or flushed["told"]
if told:
# The run's browser replaced a reaped one: what came back, once.
report["site_data"] = told
return reportkubed/selenium_flow/mcp/tools.py — the save_site_data docstring becomes (a prompt; keep the wording):
"""Save this session's site data: every cookie the browser holds, the
localStorage of every site this session has been to, and the sessionStorage of
the page it is on. Call it right after a sign-in is confirmed, and after
changing a setting you want kept; never before checking you landed, or you keep
a failed sign-in.
One save covers every site the sign-in touched. Each save replaces the last, so
a save after signing out saves you signed out. Every browser opened for this
session has it back before open_session returns, the one that replaces a reaped
browser included. Values are never returned; session://site-data lists what is
saved.
"""kubed/selenium_flow/spec/schemas.py — RESPONSES["save_site_data"] becomes:
"save_site_data": _page(
saved={
"type": "object",
"description": "What this save kept, in place of the last. Never a value.",
"properties": {
"cookies": {
"type": "integer",
"description": "Cookies saved, across every site.",
},
"sites": {
"type": "array",
"items": {"type": "string"},
"description": "Origins whose storage this save read and kept.",
},
"skipped": {
"type": "array",
"items": {
"type": "object",
"properties": {
"site": {"type": "string"},
"reason": {"type": "string"},
},
"required": ["reason"],
},
"description": (
"Origins not read this time, each keeping its last saved "
"storage, or left out over the size cap; each with why."
),
},
},
},
uri={
"type": "string",
"description": "session://site-data, which lists what is saved.",
},
),-
Step 5: Run to verify they pass —
PYTHONPATH=$S/deps:. python3 -S -m pytest -q tests/test_site_data_save.py tests/test_site_data.py tests/test_site_data_sessions.py tests/test_site_data_surface.py tests/test_sessions.py tests/test_surfaces.py tests/test_history.py tests/test_flowrun.py tests/test_flowapi.py tests/test_run_progress.py→ PASS; the whole suite → PASS; ruff clean;grep -rn 'merge(' kubed tests --include='*.py' | grep site_dataprints nothing. -
Step 6: Break it on purpose — in
snapshot, dropor kept.get(o)fromlocal = read.get(o) or kept.get(o); confirmtest_an_origin_that_could_not_be_read_keeps_its_last_storagefails; restore. Then, inserver.py, comment out thevisitedwiring; confirmtest_one_save_through_the_server_keeps_every_site_the_session_went_tofails; restore. Then, inflows/run.py, drop thebefore_save(list(reached))call; confirmtest_a_save_mid_flow_reads_the_sites_the_run_reached_before_itfails; restore. -
Step 7: Commit
git add kubed/selenium_flow/core/site_data.py kubed/selenium_flow/core/actions.py \
kubed/selenium_flow/session/sessions.py kubed/selenium_flow/server.py \
kubed/selenium_flow/flows/run.py kubed/selenium_flow/flows/api.py \
kubed/selenium_flow/mcp/tools.py kubed/selenium_flow/spec/schemas.py \
tests/test_site_data_save.py tests/test_site_data.py tests/test_site_data_surface.py \
tests/test_sessions.py
git commit -m "Site data round 2: one save is a snapshot of every site the session went to" \
-m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"Files:
- Modify:
ui/src/lib/types.ts(SiteRow…SiteDataPayload, lines 109–140) - Rewrite:
ui/src/admin/SiteDataPane.svelte,ui/src/admin/SiteDataPane.test.ts - Modify:
ui/src/admin/SessionDetail.svelte(siteDataTotal, lines 245–247)
The drawing is the spec for the look: Penpot Admin UI, page Session · Site data, boards site-data, site-data-forgotten, site-data-cleared, overlay-confirm-site-forget, overlay-confirm-site-clear. Its copy, exactly:
- above the rows: a pill
saved 2m agoon the left, Clear (danger) on the right — only when something is saved; - a row head: its origin (the host when it has none or several), grey counts
2 cookies · 2 local · 0 session(a cookie-only host:3 cookies), Forget (danger) on every row; the first row open, the rest shut; - expanded as round 1:
Cookies,Local storage,Session storage(nonewhen empty), per origin; - empty:
Nothing saved — an agent calls save_site_data after signing in.(save_site_datain mono), no pill, no Clear; - Forget's confirm: title
Forget site data;<origin> — a reopened browser comes back signed out here.;goes <names · …>, then onestays <name>, shared with <domain>line per kept shared cookie; buttonsCancel/Forget; - Clear's confirm: title
Clear site data;3 sites — a reopened browser comes back signed out.;goes <host · host · …>; buttonsCancel/Clear. - Gone from round 1: secrets on this tab,
Nothing saved. It stays listed because a secret is allowed here.,nothing saved. - The tab counts the hosts listed. Keep
#paneSiteData section.section,.titleandcode.key: the integration flow reads them.
Interfaces:
-
Consumes:
GET /admin/sessions/{key}/site-data→{key, sites: [{site, uri, cookies, storage: [{origin, local_storage, session_storage}]}], saved_at, uri, details: {host: {site, uri, cookies: [cookie], storage: [{origin, local_storage: {}, session_storage: {}}], own_cookies, kept_shared}}};DELETE …/site-data/{site};DELETE …/site-data(Task 6). -
Produces:
SiteRow,SiteDetail,SiteDataPayloadas below (Task 10 readsSiteRow);SiteDataPaneprops unchanged. -
Step 1: Write the failing tests — replace
ui/src/admin/SiteDataPane.test.tswhole:
import { fireEvent, render, screen, within } from '@testing-library/svelte'
import { afterEach, expect, test, vi } from 'vitest'
import { deferred, fakeFetch } from '../test/helpers'
import { createApi } from './api'
import { resetFolds } from './folds.svelte'
import { Live } from './live.svelte'
import SessionDetail from './SessionDetail.svelte'
const api = createApi({ base: '', token: () => 't', onUnauthorized: () => {} })
afterEach(() => resetFolds())
const cookie = (name: string, value: string, extra = {}) =>
({ name, value, domain: 'the-internet.herokuapp.com', path: '/', expiry: null, http_only: false, secure: false, same_site: null, shared: false, ...extra })
const ORIGIN = 'https://the-internet.herokuapp.com'
const GRAFANA = 'https://grafana.example.com'
const detail = {
site: 'the-internet.herokuapp.com', uri: 'session://site-data/the-internet.herokuapp.com',
cookies: [
cookie('rack.session', '•••', { http_only: true, secure: true }),
cookie('optimizelyEndUserId', 'oeu1', { domain: '.herokuapp.com', expiry: 1790800000, shared: true }),
],
storage: [{ origin: ORIGIN, local_storage: { theme: 'dark', 'tour-seen': 'true' } as Record<string, string>, session_storage: {} as Record<string, string> }],
own_cookies: ['rack.session'], kept_shared: [{ name: 'optimizelyEndUserId', domain: '.herokuapp.com', path: '/' }],
}
const counted = (origin: string, local: number, session = 0) => ({ origin, local_storage: local, session_storage: session })
const SITES = {
key: 'k', saved_at: Date.now() / 1000 - 120, uri: 'session://site-data',
sites: [
{ site: 'the-internet.herokuapp.com', uri: '', cookies: 2, storage: [counted(ORIGIN, 2)] },
{ site: 'grafana.example.com', uri: '', cookies: 5, storage: [counted(GRAFANA, 12)] },
{ site: 'keycloak.example.com', uri: '', cookies: 3, storage: [] },
],
details: {
'the-internet.herokuapp.com': detail,
'grafana.example.com': { ...detail, site: 'grafana.example.com', cookies: [], storage: [{ origin: GRAFANA, local_storage: {}, session_storage: {} }], own_cookies: [], kept_shared: [] },
'keycloak.example.com': { site: 'keycloak.example.com', uri: '', cookies: [cookie('KC', 'k', { domain: 'keycloak.example.com' })], storage: [], own_cookies: ['KC'], kept_shared: [] },
},
}
const row = { key: 'k', name: 'mine', live: true, attached: true, session_id: 'b1', files_rev: 1, flows_rev: 1, files_count: 0, site_data_count: 3, site_data_rev: 'r1' }
const base = {
'GET /admin/sessions/k/files': { body: { session: row, downloads: [], screenshots: [], files: [], browser: true } },
'GET /admin/sessions/k/flows': { body: { enabled: true, flows: [], rev: 1, session: 'k' } },
}
function setup(routes = {}) {
const net = fakeFetch({ ...base, 'GET /admin/sessions/k/site-data': { body: SITES }, ...routes })
const live = new Live(api, '')
const r = render(SessionDetail, { key: 'k', tab: 'site-data', flow: undefined, api, live, root: '' })
return { ...r, ...net, live }
}
const sections = (c: HTMLElement) => [...c.querySelectorAll('#paneSiteData section.section')] as HTMLElement[]
const loaded = (c: HTMLElement, n = 3) => vi.waitFor(() => expect(sections(c)).toHaveLength(n))
const modal = () => document.querySelector('.modal') as HTMLElement
const pairs = () => [...modal().querySelectorAll('.pair')].map((p) => p.textContent)
test('the subtab counts the hosts, and rows come in order with their counts', async () => {
const { container } = setup()
expect(screen.getByRole('tab', { name: /Site data/ })).toHaveAttribute('aria-selected', 'true')
await loaded(container)
expect(container.querySelector('#siteDataTotal')).toHaveTextContent('3')
const [a, b, c] = sections(container)
expect(a.querySelector('.title')).toHaveTextContent(ORIGIN)
expect(a.querySelector('.head')).toHaveTextContent('2 cookies · 2 local · 0 session')
expect(b.querySelector('.head')).toHaveTextContent('5 cookies · 12 local · 0 session')
expect(c.querySelector('.title')).toHaveTextContent('keycloak.example.com')
expect(c.querySelector('.head')).toHaveTextContent('3 cookies')
expect(c.querySelector('.head')).not.toHaveTextContent('local')
expect(a.querySelector('.head .pill')).toBeNull()
})
test('one saved pill and Clear sit above the rows', async () => {
const { container } = setup()
await loaded(container)
const bar = container.querySelector('#paneSiteData .bar') as HTMLElement
expect(bar.querySelector('.pill')).toHaveTextContent('saved 2m ago')
expect(within(bar).getByRole('button', { name: 'Clear' })).toHaveClass('danger')
})
test('the first row is open, the others shut', async () => {
const { container } = setup()
await loaded(container)
const [a, b, c] = sections(container)
expect(a.querySelector('.title')).toHaveAttribute('aria-expanded', 'true')
expect(b.querySelector('.title')).toHaveAttribute('aria-expanded', 'false')
expect(c.querySelector('.title')).toHaveAttribute('aria-expanded', 'false')
})
test('expanded: cookies with dots for httpOnly, flags, expiry; storage; none', async () => {
const { container } = setup()
await loaded(container)
const a = within(sections(container)[0])
const rack = a.getByText('rack.session').closest('.line') as HTMLElement
expect(rack).toHaveTextContent('•••')
expect(rack).toHaveTextContent('the-internet.herokuapp.com · session')
expect([...rack.querySelectorAll('.pill')].map((p) => p.textContent)).toEqual(['httpOnly', 'secure'])
const opt = a.getByText('optimizelyEndUserId').closest('.line') as HTMLElement
expect(opt).toHaveTextContent(new Date(1790800000 * 1000).toLocaleDateString())
expect(opt.querySelector('.pill.shared')).toHaveTextContent('shared')
expect(a.getByText('theme').closest('.line')).toHaveTextContent('dark')
// One line each, the whole value on hover: a long cookie wrapped to seven.
const value = opt.querySelector('.value') as HTMLElement
expect(value).toHaveClass('clip')
expect(value).toHaveAttribute('title', value.textContent!)
expect(a.getByText('Session storage').nextElementSibling!).toHaveTextContent('none')
})
test('the shared pill follows the payload: a site\'s own dotted cookie has none', async () => {
const own = cookie('own', 'o', { domain: '.the-internet.herokuapp.com', shared: false })
const d = { ...detail, cookies: [own, ...detail.cookies.slice(1)] }
const { container } = setup({ 'GET /admin/sessions/k/site-data': { body: { ...SITES, details: { ...SITES.details, [detail.site]: d } } } })
await loaded(container)
const a = within(sections(container)[0])
expect(a.getByText('own').closest('.line')!.querySelector('.pill.shared')).toBeNull()
expect(a.getByText('optimizelyEndUserId').closest('.line')!.querySelector('.pill.shared')).not.toBeNull()
})
test('every row has Forget, and the tab shows no secrets and no "nothing saved"', async () => {
const { container } = setup()
await loaded(container)
for (const s of sections(container)) expect(within(s).getByRole('button', { name: 'Forget' })).toBeInTheDocument()
await fireEvent.click(sections(container)[2].querySelector('.title')!)
const pane = container.querySelector('#paneSiteData') as HTMLElement
expect(pane.querySelector('.line.secret')).toBeNull()
expect(pane).not.toHaveTextContent('nothing saved')
expect(pane).not.toHaveTextContent('It stays listed because a secret is allowed here.')
})
test('nothing saved: the drawn line, no pill, no Clear, a count of 0', async () => {
const none = { key: 'k', saved_at: null, uri: 'session://site-data', sites: [], details: {} }
const { container } = setup({ 'GET /admin/sessions/k/site-data': { body: none } })
await vi.waitFor(() => expect(container.querySelector('#paneSiteData > p')).not.toBeNull())
const line = container.querySelector('#paneSiteData > p')!
expect(line).toHaveTextContent('Nothing saved — an agent calls save_site_data after signing in.')
expect(line.querySelector('code')).toHaveTextContent('save_site_data')
expect(container.querySelector('#paneSiteData .bar')).toBeNull()
expect(container.querySelector('#siteDataTotal')).toHaveTextContent('0')
})
test('Forget confirms with goes and stays, then DELETEs the site and reloads', async () => {
const { container, calls } = setup({ 'DELETE /admin/sessions/k/site-data/the-internet.herokuapp.com': { body: { forgotten: {} } } })
await loaded(container)
await fireEvent.click(within(sections(container)[0]).getByRole('button', { name: 'Forget' }))
expect(modal().querySelector('.head')).toHaveTextContent('Forget site data')
expect(modal()).toHaveTextContent(ORIGIN + ' — a reopened browser comes back signed out here.')
expect(pairs()).toEqual([
'goesrack.session · theme · tour-seen',
'staysoptimizelyEndUserId, shared with .herokuapp.com',
])
expect(calls.filter((c) => c.method === 'DELETE')).toHaveLength(0)
const gets = () => calls.filter((c) => c.method === 'GET' && c.path.endsWith('/site-data')).length
const before = gets()
await fireEvent.click(within(modal()).getByRole('button', { name: 'Forget' }))
await vi.waitFor(() => expect(calls.filter((c) => c.method === 'DELETE').map((c) => c.path)).toEqual(['/admin/sessions/k/site-data/the-internet.herokuapp.com']))
await vi.waitFor(() => expect(gets()).toBe(before + 1))
})
test('Clear confirms with the hosts, then DELETEs the snapshot and reloads', async () => {
const { container, calls } = setup({ 'DELETE /admin/sessions/k/site-data': { body: { cleared: [] } } })
await loaded(container)
await fireEvent.click(within(container.querySelector('#paneSiteData .bar') as HTMLElement).getByRole('button', { name: 'Clear' }))
expect(modal().querySelector('.head')).toHaveTextContent('Clear site data')
expect(modal()).toHaveTextContent('3 sites — a reopened browser comes back signed out.')
expect(pairs()).toEqual(['goesthe-internet.herokuapp.com · grafana.example.com · keycloak.example.com'])
const gets = () => calls.filter((c) => c.method === 'GET' && c.path.endsWith('/site-data')).length
const before = gets()
await fireEvent.click(within(modal()).getByRole('button', { name: 'Clear' }))
await vi.waitFor(() => expect(calls.filter((c) => c.method === 'DELETE').map((c) => c.path)).toEqual(['/admin/sessions/k/site-data']))
await vi.waitFor(() => expect(gets()).toBe(before + 1))
})
test('Cancel forgets and clears nothing', async () => {
const { container, calls } = setup()
await loaded(container)
await fireEvent.click(within(sections(container)[1]).getByRole('button', { name: 'Forget' }))
await fireEvent.click(within(modal()).getByRole('button', { name: 'Cancel' }))
await fireEvent.click(within(container.querySelector('#paneSiteData .bar') as HTMLElement).getByRole('button', { name: 'Clear' }))
await fireEvent.click(within(modal()).getByRole('button', { name: 'Cancel' }))
expect(calls.some((c) => c.method === 'DELETE')).toBe(false)
})
test('a pushed site_data_rev change reloads; the same rev does not', async () => {
const { container, calls, live } = setup()
await loaded(container)
const gets = () => calls.filter((c) => c.method === 'GET' && c.path.endsWith('/site-data')).length
expect(gets()).toBe(1)
const push = (rev: string) => { live.data = { sessions: [{ ...row, site_data_rev: rev }] } }
push('r1')
await new Promise((r) => setTimeout(r, 10))
expect(gets()).toBe(1)
push('r2')
await vi.waitFor(() => expect(gets()).toBe(2))
})
test('a failed load is retried by the next push at the same rev', async () => {
let n = 0
const { container, live } = setup({
'GET /admin/sessions/k/site-data': () => (++n <= 2 ? { status: 500, body: { error: 'flaky' } } : { body: SITES }),
})
await vi.waitFor(() => expect(screen.getByText('flaky')).toBeInTheDocument())
live.data = { sessions: [{ ...row, site_data_rev: 'r1' }] }
await vi.waitFor(() => expect(n).toBe(2))
await new Promise((r) => setTimeout(r, 10))
live.data = { sessions: [{ ...row, site_data_rev: 'r1', files_count: 1 }] }
await loaded(container)
expect(n).toBe(3)
})
test('an error shows in the pane, Loading… before', async () => {
const d = deferred<{ status: number; body: unknown }>()
const { container } = setup({ 'GET /admin/sessions/k/site-data': () => d.promise })
expect(within(container.querySelector('#paneSiteData') as HTMLElement).getByText('Loading…')).toBeInTheDocument()
d.resolve({ status: 500, body: { error: 'nope' } })
await vi.waitFor(() => expect(screen.getByText('nope')).toHaveClass('error'))
})
test('a parent-only row: its dotted cookie goes', async () => {
const PARENT = cookie('shared', 's', { domain: '.example.com', shared: false })
const data = {
key: 'k', saved_at: Date.now() / 1000 - 60, uri: '',
sites: [{ site: 'example.com', uri: '', cookies: 1, storage: [] }],
details: { 'example.com': { site: 'example.com', uri: '', cookies: [PARENT], storage: [], own_cookies: ['shared'], kept_shared: [] } },
}
const { container } = setup({ 'GET /admin/sessions/k/site-data': { body: data } })
await loaded(container, 1)
await fireEvent.click(within(sections(container)[0]).getByRole('button', { name: 'Forget' }))
expect(pairs()).toEqual(['goesshared'])
})
test('after Forget the tab counts the reloaded payload, before any push', async () => {
let n = 0
const after = { ...SITES, sites: SITES.sites.slice(1) }
const { container } = setup({
'GET /admin/sessions/k/site-data': () => ({ body: ++n === 1 ? SITES : after }),
'DELETE /admin/sessions/k/site-data/the-internet.herokuapp.com': { body: { forgotten: {} } },
})
await loaded(container)
expect(container.querySelector('#siteDataTotal')).toHaveTextContent('3')
await fireEvent.click(within(sections(container)[0]).getByRole('button', { name: 'Forget' }))
await fireEvent.click(within(modal()).getByRole('button', { name: 'Forget' }))
await loaded(container, 2)
expect(container.querySelector('#siteDataTotal')).toHaveTextContent('2')
})
test('every id in the pane is unique, and each fold controls its own body', async () => {
const { container } = setup()
await loaded(container)
const ids = [...container.querySelectorAll('#paneSiteData [id]')].map((e) => e.id)
expect(new Set(ids).size).toBe(ids.length)
for (const s of sections(container)) {
const body = s.querySelector('.title')!.getAttribute('aria-controls')!
expect(body).not.toBe(s.id)
expect(s.querySelector('.body')!.id).toBe(body)
}
})
test('two shared cookies of one name both stay, each under its own domain', async () => {
const sid = (domain: string, path = '/') => ({ name: 'sid', domain, path })
const data = {
key: 'k', saved_at: Date.now() / 1000, uri: '',
sites: [{ site: 'app.example.com', uri: '', cookies: 2, storage: [counted('https://app.example.com', 1)] }],
details: {
'app.example.com': {
site: 'app.example.com', uri: '', cookies: [],
storage: [{ origin: 'https://app.example.com', local_storage: { a: '1' }, session_storage: {} }],
own_cookies: [], kept_shared: [sid('.example.com'), sid('.example.org', '/app')],
},
},
}
const { container } = setup({ 'GET /admin/sessions/k/site-data': { body: data } })
await loaded(container, 1)
await fireEvent.click(within(sections(container)[0]).getByRole('button', { name: 'Forget' }))
expect(pairs()).toEqual([
'goesa',
'stayssid, shared with .example.com',
'stayssid, shared with .example.org /app',
])
})
test('one host on two ports: a storage group per origin, each labelled, the host as the title', async () => {
const dev = (port: number, local: Record<string, string>) => ({ origin: 'http://localhost:' + port, local_storage: local, session_storage: {} })
const data = {
key: 'k', saved_at: Date.now() / 1000, uri: '',
sites: [{ site: 'localhost', uri: '', cookies: 0, storage: [counted('http://localhost:3000', 1), counted('http://localhost:8080', 1)] }],
details: { localhost: { site: 'localhost', uri: '', cookies: [], own_cookies: [], kept_shared: [], storage: [dev(3000, { k: 'a' }), dev(8080, { k: 'b' })] } },
}
const { container } = setup({ 'GET /admin/sessions/k/site-data': { body: data } })
await loaded(container, 1)
const [s] = sections(container)
expect(s.querySelector('.title')).toHaveTextContent('localhost')
expect(s.querySelector('.head')).toHaveTextContent('0 cookies · 2 local · 0 session')
expect([...s.querySelectorAll('h3')].map((h) => h.textContent)).toEqual([
'Cookies',
'Local storage · http://localhost:3000', 'Session storage · http://localhost:3000',
'Local storage · http://localhost:8080', 'Session storage · http://localhost:8080',
])
const values = [...s.querySelectorAll('.line')].filter((l) => l.querySelector('code.key')?.textContent === 'k').map((l) => l.querySelector('.value')!.textContent)
expect(values).toEqual(['a', 'b'])
})- Step 2: Run to verify they fail
Run: cd ui && npx vitest run src/admin/SiteDataPane.test.ts
Expected: FAIL — no .bar, the counts still end in · N secret(s), the cookie-only row reads nothing saved…, no Clear.
- Step 3: Implement
ui/src/lib/types.ts — replace from /** One origin of a host… (line 109) through SiteDataPayload (line 140) with:
/** One origin of a host: the same host on another port or scheme is another. */
export interface SiteStorage<T = Record<string, string>> {
origin: string
local_storage: T
session_storage: T
}
/** A host the snapshot holds data for: counts only. */
export interface SiteRow {
site: string
uri?: string
cookies: number
storage: SiteStorage<number>[]
}
/** A parent-domain cookie Forget leaves, named whole: two can share a name. */
export interface SharedCookie { name: string; domain: string; path: string }
export interface SiteDetail extends Omit<SiteRow, 'cookies' | 'storage'> {
cookies: SiteCookie[]
storage: SiteStorage[]
/** What Forget removes and what it leaves, by the server's own rule. */
own_cookies: string[]
kept_shared: SharedCookie[]
}
/** What a reopened browser gets back: the last save, by host. */
export interface SiteDataPayload {
key: string
sites: SiteRow[]
saved_at: number | null
uri?: string
details: Record<string, SiteDetail>
}(SiteSecret above it stays: Task 10's History uses it.)
ui/src/admin/SiteDataPane.svelte — replace whole:
<script lang="ts">
import { untrack } from 'svelte'
import { ago } from '../lib/format'
import type { SiteCookie, SiteDetail, SiteRow, SiteStorage } from '../lib/types'
import { sessionPath, type Api } from './api'
import { folds } from './folds.svelte'
import type { ModalSpec } from './modal'
import Section from './Section.svelte'
import type { SessionModel } from './session.svelte'
let { m, api, ask, refuseIfGone, isGone, hidden }: {
m: SessionModel
api: Api
ask: <T>(spec: ModalSpec<T>) => void
refuseIfGone: () => void
isGone: () => boolean
hidden: boolean
} = $props()
const data = $derived(m.siteData)
// Ends in `Section`: Section derives its body's id by replacing that.
const idOf = (r: SiteRow) => 'site-' + m.key + ':' + r.site + 'Section'
// The first row opens, the rest stay shut — once; after that the fold is
// the reader's.
$effect.pre(() => {
const sites = data?.sites ?? []
untrack(() => sites.forEach((r, i) => { folds[idOf(r)] ??= i === 0 }))
})
const plural = (n: number, one: string, many = one + 's') => n + ' ' + (n === 1 ? one : many)
const sum = (r: SiteRow, k: 'local_storage' | 'session_storage') => r.storage.reduce((n, e) => n + e[k], 0)
// A cookie-only host reads "3 cookies"; one with storage gives every count.
const counts = (r: SiteRow) => [
plural(r.cookies, 'cookie'),
...(r.storage.length ? [sum(r, 'local_storage') + ' local', sum(r, 'session_storage') + ' session'] : []),
].join(' · ')
// The origin when the host has one, the host when it has none or several.
const titleOf = (r: SiteRow) => (r.storage.length === 1 ? r.storage[0].origin : r.site)
const keysOf = (d: SiteDetail | undefined) =>
[...new Set((d?.storage ?? []).flatMap((e) => [...Object.keys(e.local_storage), ...Object.keys(e.session_storage)]))]
// A host with no storage still shows both groups, as "none".
const groups = (d: SiteDetail): SiteStorage[] =>
d.storage.length ? d.storage : [{ origin: '', local_storage: {}, session_storage: {} }]
const expires = (c: SiteCookie) => (c.expiry ? new Date(c.expiry * 1000).toLocaleDateString() : 'session')
interface Forget { origin: string; goes: string[]; stays: { key: string; label: string }[] }
function forget(r: SiteRow) {
const d: SiteDetail | undefined = data?.details[r.site]
// Which cookies go, and which stay, is the server's call (Forget's own
// rule); each staying cookie arrives with its own domain and path.
ask<Forget>({
title: 'Forget site data',
body: forgetBody,
data: {
origin: titleOf(r),
goes: [...(d?.own_cookies ?? []), ...keysOf(d)],
stays: (d?.kept_shared ?? []).map((c) => ({
key: [c.name, c.domain, c.path].join('|'),
label: c.name + ', shared with ' + c.domain + (c.path && c.path !== '/' ? ' ' + c.path : ''),
})),
},
confirm: 'Forget', danger: true,
onconfirm: async () => {
refuseIfGone()
await api(sessionPath(m.key, '/site-data/' + encodeURIComponent(r.site)), 'DELETE')
if (!isGone()) void m.loadSiteData()
},
})
}
function clear() {
ask<string[]>({
title: 'Clear site data',
body: clearBody,
data: (data?.sites ?? []).map((r) => r.site),
confirm: 'Clear', danger: true,
onconfirm: async () => {
refuseIfGone()
await api(sessionPath(m.key, '/site-data'), 'DELETE')
if (!isGone()) void m.loadSiteData()
},
})
}
</script>
{#snippet forgetBody(f: Forget)}
<p>{f.origin} — a reopened browser comes back signed out here.</p>
<div class="pairs fate-list">
{#if f.goes.length}<div class="pair"><span class="pk">goes</span><span class="pv">{f.goes.join(' · ')}</span></div>{/if}
{#each f.stays as s (s.key)}<div class="pair"><span class="pk">stays</span><span class="pv">{s.label}</span></div>{/each}
</div>
{/snippet}
{#snippet clearBody(hosts: string[])}
<p>{plural(hosts.length, 'site')} — a reopened browser comes back signed out.</p>
<div class="pairs fate-list">
<div class="pair"><span class="pk">goes</span><span class="pv">{hosts.join(' · ')}</span></div>
</div>
{/snippet}
<!-- Which origin a group is, only when the host has more than one. -->
{#snippet from(d: SiteDetail, e: SiteStorage)}
{#if d.storage.length > 1}<span class="origin">{' · ' + e.origin}</span>{/if}
{/snippet}
{#snippet kv(entries: [string, string][])}
{#each entries as [k, v] (k)}
<div class="line"><code class="key">{k}</code><span class="value clip" title={v}>{v}</span></div>
{:else}
<div class="line"><span class="value muted">none</span></div>
{/each}
{/snippet}
<div id="paneSiteData" {hidden}>
{#if m.siteDataError}
<div class="empty error">{m.siteDataError}</div>
{:else if !data}
<div class="empty">Loading…</div>
{:else if !data.sites.length}
<p class="small muted">Nothing saved — an agent calls <code>save_site_data</code> after signing in.</p>
{:else}
<div class="row bar">
{#if data.saved_at}<span class="pill">saved {ago(data.saved_at * 1000)}</span>{/if}
<span class="grow"></span>
<button id="clearSiteData" class="danger" onclick={clear}>Clear</button>
</div>
{#each data.sites as r (r.site)}
{@const d = data.details[r.site]}
<Section id={idOf(r)} title={titleOf(r)}>
{#snippet summary()}<span class="small muted">{counts(r)}</span>{/snippet}
{#snippet actions()}<button class="danger" onclick={() => forget(r)}>Forget</button>{/snippet}
{#if d}
<h3>Cookies</h3>
{#each d.cookies as c (c.name + c.domain + c.path)}
<div class="line cookie">
<code class="key">{c.name}</code>
<span class="value clip" title={c.value}>{c.value}</span>
<span class="small muted">{c.domain} · {expires(c)}</span>
<span class="flags">
{#if c.http_only}<span class="pill">httpOnly</span>{/if}
{#if c.secure}<span class="pill">secure</span>{/if}
{#if c.shared}<span class="pill shared">shared</span>{/if}
</span>
</div>
{:else}
<div class="line"><span class="value muted">none</span></div>
{/each}
{#each groups(d) as e (e.origin)}
<h3>Local storage{@render from(d, e)}</h3>
{@render kv(Object.entries(e.local_storage))}
<h3>Session storage{@render from(d, e)}</h3>
{@render kv(Object.entries(e.session_storage))}
{/each}
{/if}
</Section>
{/each}
{/if}
</div>
<style>
.bar { margin-bottom: 12px; }
h3 { margin: 14px 0 4px; font-size: 11px; font-weight: 400; text-transform: uppercase; letter-spacing: 0.04em; color: var(--muted); }
.line { display: grid; grid-template-columns: 240px 1fr auto; align-items: center; gap: 8px; padding: 6px 0; border-top: 1px solid var(--line); }
.line.cookie { grid-template-columns: 240px 1fr auto auto; }
h3 .origin { text-transform: none; letter-spacing: 0; }
.key { font-size: 12px; overflow-wrap: anywhere; }
.value { overflow-wrap: anywhere; min-width: 0; }
/* One line per entry; the whole value is on hover. A pending-events cookie
wrapped to seven lines and pushed its neighbours apart. */
.value.clip { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.flags { display: inline-flex; gap: 4px; justify-content: flex-end; }
.pill.shared { color: var(--accent); border-color: currentColor; }
.fate-list { padding: 8px 10px; border-radius: 8px; background: color-mix(in srgb, var(--ink) 5%, transparent); }
.fate-list .pk { width: 48px; }
</style>ui/src/admin/SessionDetail.svelte — replace the siteDataTotal comment and line (245–247) with:
// The hosts listed, from the loaded payload rather than the last pushed
// row: a Forget reloads the pane before the next push brings the row up.
const siteDataTotal = $derived(m.siteDataError || !m.siteData ? '' : String(m.siteData.sites.length))-
Step 4: Run to verify they pass —
cd ui && npx vitest run && npm run -s check && npm run -s lint→ PASS, 0 errors, 0 warnings;npm --prefix ui run build && npm --prefix ui run sizesucceeds — the admin surface within its 45 KB gzipped budget (output is gitignored). -
Step 5: Break it on purpose — in
counts, drop ther.storage.length ?condition (always add local and session); confirmthe subtab counts the hosts, and rows come in order with their countsfails on the cookie-only row; restore. -
Step 6: Commit
git add ui/src/lib/types.ts ui/src/admin/SiteDataPane.svelte ui/src/admin/SiteDataPane.test.ts \
ui/src/admin/SessionDetail.svelte
git commit -m "Site data round 2: the Site data tab is the snapshot — one saved time, Forget and Clear" \
-m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"Files:
- Create:
ui/src/admin/HistoryPane.svelte,ui/src/admin/HistoryPane.test.ts - Modify:
ui/src/lib/types.ts(SessionRow, lines 21–40; newSavedCounts,HistoryRow,HistoryPayload) - Modify:
ui/src/admin/router.svelte.ts(whole file, 59 lines),ui/src/admin/router.test.ts - Modify:
ui/src/admin/session.svelte.ts(fields 48–63,#showRow188–196,dispose199–205; newloadHistory),ui/src/admin/session.test.ts - Modify:
ui/src/admin/SessionDetail.svelte(imports, props 16–23, totals 244–248, tabs 284–288, panes 300),ui/src/admin/Admin.svelte(line 101) - Modify:
ui/src/admin/SiteDataPane.svelte(thesiteprop: open and scroll to a linked host),ui/src/admin/SiteDataPane.test.ts(one base route)
The drawing is the spec for the look: Penpot Admin UI, page Session · History, boards history, history-cleared, overlay-confirm-history-clear. Its copy, exactly:
- the subtabs:
Files · Flows · Site data · History, each with its count; History counts the hosts listed; - above the rows, on the right, Clear (danger) — only while there is more than the current site;
- one collapsible row per host, folding like Site data's rows (Dr K: consistent with Site data): the current site on top, then most recent first; the top row opens, the rest stay shut — once, after that the fold is the reader's; no marker on the top row. Its head: the caret, the latest URL in bold,
1m ago · 1 secretin grey (the secret count left out when there are none), and a pill with the saved counts for that host, zero counts left out (2 cookies · 2 local), which opens that host in Site data; no pill when nothing is saved there; - opened, one line per secret allowed on that host:
🔑 name, its description, one pill per key — the Secrets tab's line. A row with no secrets has nothing to open: no caret (its place kept), not a button; - Clear's confirm: title
Clear history;History only: site data and the browser are untouched.;goes <host · host · …>;stays <current host>, the current site; buttonsCancel/Clear. - Not drawn: a session that has been nowhere. Use the session card's own words,
Nowhere yet., as the pane's empty line.
Interfaces:
-
Consumes:
GET /admin/sessions/{key}/history→{key, sites: [{site, url, at, saved: {cookies, local, session} | null, secrets: [{name, description, keys}]}]};DELETEthe same; the session row'shistory_count,history_rev(Task 7). -
Produces:
-
router:type Tab = 'files' | 'flows' | 'site-data' | 'history'; a session route carriessite: string | undefined(the fourth hash segment on Site data) besideflow(now only on Flows);hashes.history(key),hashes.site(key, host)→#/sessions/<key>/site-data/<host>. -
SessionModel.history: HistoryPayload | null,historyError,loadHistory(); a pushed row reloads it whenhistory_revmoves. -
SessionDetailtakessite?: stringand passes it toSiteDataPane, which opens that host's row and scrolls to it.
-
-
Step 1: Write the failing tests
ui/src/admin/HistoryPane.test.ts:
import { fireEvent, render, screen, within } from '@testing-library/svelte'
import { afterEach, beforeEach, expect, test, vi } from 'vitest'
import { deferred, fakeFetch } from '../test/helpers'
import { createApi } from './api'
import { resetFolds } from './folds.svelte'
import { Live } from './live.svelte'
import SessionDetail from './SessionDetail.svelte'
const api = createApi({ base: '', token: () => 't', onUnauthorized: () => {} })
beforeEach(() => { history.replaceState(null, '', '/#/sessions/k/history') })
afterEach(() => resetFolds())
const now = Date.now() / 1000
const HISTORY = {
key: 'k',
sites: [
{ site: 'the-internet.herokuapp.com', url: 'https://the-internet.herokuapp.com/secure', at: now - 60,
saved: { cookies: 2, local: 2, session: 0 },
secrets: [{ name: 'the-internet', description: 'Public demo login', keys: ['username', 'password'] }] },
{ site: 'grafana.example.com', url: 'https://grafana.example.com/d/selenium-grid', at: now - 3600,
saved: { cookies: 5, local: 12, session: 0 },
secrets: [{ name: 'grafana', description: 'LDAP sign-in for Grafana', keys: ['username', 'password'] }] },
{ site: 'example.com', url: 'https://example.com/', at: now - 3 * 3600, saved: null, secrets: [] },
{ site: 'admin.example.com', url: 'https://admin.example.com/flow/', at: now - 5 * 3600, saved: null,
secrets: [{ name: 'selenium-admin', description: 'Admin token for this server', keys: ['token'] }] },
],
}
const counted = (origin: string, local: number) => ({ origin, local_storage: local, session_storage: 0 })
const empty = (site: string) => ({ site, uri: '', cookies: [], storage: [], own_cookies: [], kept_shared: [] })
const SITE_DATA = {
key: 'k', saved_at: now - 120, uri: '',
sites: [
{ site: 'the-internet.herokuapp.com', uri: '', cookies: 2, storage: [counted('https://the-internet.herokuapp.com', 2)] },
{ site: 'grafana.example.com', uri: '', cookies: 5, storage: [counted('https://grafana.example.com', 12)] },
],
details: { 'the-internet.herokuapp.com': empty('the-internet.herokuapp.com'), 'grafana.example.com': empty('grafana.example.com') },
}
const row = { key: 'k', name: 'mine', live: true, attached: true, session_id: 'b1', files_rev: 1, flows_rev: 1, files_count: 0, history_count: 4, history_rev: 'h1' }
function setup(routes = {}) {
const net = fakeFetch({
'GET /admin/sessions/k/files': { body: { session: row, downloads: [], screenshots: [], files: [], browser: true } },
'GET /admin/sessions/k/flows': { body: { enabled: true, flows: [], rev: 1, session: 'k' } },
'GET /admin/sessions/k/site-data': { body: SITE_DATA },
'GET /admin/sessions/k/history': { body: HISTORY },
...routes,
})
const live = new Live(api, '')
const r = render(SessionDetail, { key: 'k', tab: 'history', flow: undefined, api, live, root: '' })
return { ...r, ...net, live }
}
const visits = (c: HTMLElement) => [...c.querySelectorAll('#paneHistory section.section')] as HTMLElement[]
const loaded = (c: HTMLElement, n = 4) => vi.waitFor(() => expect(visits(c)).toHaveLength(n))
const modal = () => document.querySelector('.modal') as HTMLElement
test('History is the fourth subtab and counts the hosts listed', async () => {
const { container } = setup()
const tabs = [...container.querySelectorAll('#sessionTabs button')].map((b) => b.firstChild!.textContent!.trim())
expect(tabs).toEqual(['Files', 'Flows', 'Site data', 'History'])
expect(screen.getByRole('tab', { name: /History/ })).toHaveAttribute('aria-selected', 'true')
await loaded(container)
expect(container.querySelector('#historyTotal')).toHaveTextContent('4')
})
test('the current site is on top: its URL, when, and what is saved there', async () => {
const { container } = setup()
await loaded(container)
const [a, b, c] = visits(container)
expect(a.querySelector('.title')).toHaveTextContent('https://the-internet.herokuapp.com/secure')
expect(a.querySelector('.head')).toHaveTextContent('1m ago · 1 secret')
expect(a.querySelector('.head .pill')).toHaveTextContent('2 cookies · 2 local')
expect(b.querySelector('.head')).toHaveTextContent('1h ago · 1 secret')
expect(b.querySelector('.head .pill')).toHaveTextContent('5 cookies · 12 local')
expect(c.querySelector('.title')).toHaveTextContent('https://example.com/')
expect(c.querySelector('.head')).not.toHaveTextContent('secret')
expect(c.querySelector('.head .pill')).toBeNull()
})
test('rows fold like Site data: the top one opens to its secrets, the rest stay shut', async () => {
const { container } = setup()
await loaded(container)
const [a, b, c] = visits(container)
expect(a.querySelector('.title')).toHaveAttribute('aria-expanded', 'true')
const line = a.querySelector('.line.secret')!
expect(line).toHaveTextContent('🔑 the-internet')
expect(line).toHaveTextContent('Public demo login')
expect([...line.querySelectorAll('.pill')].map((p) => p.textContent)).toEqual(['username', 'password'])
expect(b.querySelector('.title')).toHaveAttribute('aria-expanded', 'false')
expect(b.querySelector('.line.secret')).toBeNull()
await fireEvent.click(b.querySelector('.title')!)
await vi.waitFor(() => expect(b.querySelector('.line.secret')).toHaveTextContent('🔑 grafana'))
// Nothing to open: no caret button at all.
expect(c.querySelector('[aria-expanded]')).toBeNull()
expect(c.querySelector('.line.secret')).toBeNull()
})
test('the saved pill opens that host in Site data, unfolded', async () => {
const { container, rerender } = setup()
await loaded(container)
await fireEvent.click(visits(container)[1].querySelector('.head .pill')!)
expect(location.hash).toBe('#/sessions/k/site-data/grafana.example.com')
await rerender({ tab: 'site-data', site: 'grafana.example.com' })
const grafana = () => [...container.querySelectorAll('#paneSiteData section.section')]
.find((s) => s.querySelector('.title')!.textContent!.includes('grafana')) as HTMLElement
await vi.waitFor(() => expect(grafana().querySelector('.title')).toHaveAttribute('aria-expanded', 'true'))
})
test('Clear is offered only while there is more than the current site', async () => {
const { container, unmount } = setup()
await loaded(container)
expect(container.querySelector('#clearHistory')).not.toBeNull()
unmount()
const again = setup({ 'GET /admin/sessions/k/history': { body: { key: 'k', sites: HISTORY.sites.slice(0, 1) } } })
await loaded(again.container, 1)
expect(again.container.querySelector('#clearHistory')).toBeNull()
})
test('Clear confirms what goes and what stays, then DELETEs the history and reloads', async () => {
const { container, calls } = setup({ 'DELETE /admin/sessions/k/history': { body: { cleared: [] } } })
await loaded(container)
await fireEvent.click(container.querySelector('#clearHistory')!)
expect(modal().querySelector('.head')).toHaveTextContent('Clear history')
expect(modal()).toHaveTextContent('History only: site data and the browser are untouched.')
expect([...modal().querySelectorAll('.pair')].map((p) => p.textContent)).toEqual([
'goesgrafana.example.com · example.com · admin.example.com',
'staysthe-internet.herokuapp.com, the current site',
])
const gets = () => calls.filter((c) => c.method === 'GET' && c.path.endsWith('/history')).length
const before = gets()
await fireEvent.click(within(modal()).getByRole('button', { name: 'Clear' }))
await vi.waitFor(() => expect(calls.filter((c) => c.method === 'DELETE').map((c) => c.path)).toEqual(['/admin/sessions/k/history']))
await vi.waitFor(() => expect(gets()).toBe(before + 1))
})
test('a pushed history_rev change reloads; the same rev does not', async () => {
const { container, calls, live } = setup()
await loaded(container)
const gets = () => calls.filter((c) => c.method === 'GET' && c.path.endsWith('/history')).length
expect(gets()).toBe(1)
live.data = { sessions: [{ ...row, history_rev: 'h1' }] }
await new Promise((r) => setTimeout(r, 10))
expect(gets()).toBe(1)
live.data = { sessions: [{ ...row, history_rev: 'h2' }] }
await vi.waitFor(() => expect(gets()).toBe(2))
})
test('Loading… first, an error in the pane, and nowhere yet when empty', async () => {
const d = deferred<{ status: number; body: unknown }>()
const { container, unmount } = setup({ 'GET /admin/sessions/k/history': () => d.promise })
expect(within(container.querySelector('#paneHistory') as HTMLElement).getByText('Loading…')).toBeInTheDocument()
d.resolve({ status: 500, body: { error: 'nope' } })
await vi.waitFor(() => expect(screen.getByText('nope')).toHaveClass('error'))
unmount()
const none = setup({ 'GET /admin/sessions/k/history': { body: { key: 'k', sites: [] } } })
await vi.waitFor(() => expect(none.container.querySelector('#paneHistory')).toHaveTextContent('Nowhere yet.'))
})Append to ui/src/admin/router.test.ts:
test('History is a tab, and a Site data host rides in the hash', () => {
expect(parse('#/sessions/k/history')).toEqual({ view: 'session', key: 'k', tab: 'history', flow: undefined, site: undefined })
expect(parse('#/sessions/k/site-data/app.example.com')).toEqual({ view: 'session', key: 'k', tab: 'site-data', flow: undefined, site: 'app.example.com' })
expect(parse('#/sessions/k/files/x')).toEqual({ view: 'session', key: 'k', tab: 'files', flow: undefined, site: undefined })
expect(hashes.history('a b')).toBe('#/sessions/a%20b/history')
expect(parse(hashes.site('a b', 'h.example.com'))).toEqual({ view: 'session', key: 'a b', tab: 'site-data', flow: undefined, site: 'h.example.com' })
})Append to ui/src/admin/session.test.ts:
test('a pushed row refetches the history only when history_rev moves', async () => {
const { calls } = fakeFetch({
'GET /admin/sessions/k/history': { body: { key: 'k', sites: [] } },
'GET /admin/sessions/k/files': { body: files() },
'GET /admin/sessions/k/flows': { body: { enabled: true, flows: [], rev: 1 } },
})
const m = new SessionModel('k', api)
const loads = () => calls.filter((c) => c.path === '/admin/sessions/k/history').length
const push = (rev: string) => m.onPushed({ sessions: [{ key: 'k', live: true, session_id: 'b1', files_rev: 1, flows_rev: 1, history_rev: rev }] }, () => null, () => {}, () => {})
push('h1')
expect(loads()).toBe(1)
push('h1')
expect(loads()).toBe(1)
push('h2')
expect(loads()).toBe(2)
})In ui/src/admin/session.test.ts, the two tests a pushed row refetches files only when the stamp moves (F8) and a pushed row refetches flows only when flows_rev moves (F8, W7) (lines 66–87) count fetches, and a history load that 404s is retried on every push. In both, the opening of the fakeFetch call
fakeFetch({ 'GET /admin/sessions/k/site-data': { body: { sites: [], details: {} } }, becomes
fakeFetch({ 'GET /admin/sessions/k/site-data': { body: { sites: [], details: {} } }, 'GET /admin/sessions/k/history': { body: { key: 'k', sites: [] } }, In ui/src/admin/SiteDataPane.test.ts, add the history route to base, so the History pane loads quietly beside it:
'GET /admin/sessions/k/history': { body: { key: 'k', sites: [] } },- Step 2: Run to verify they fail
Run: cd ui && npx vitest run src/admin/HistoryPane.test.ts src/admin/router.test.ts src/admin/session.test.ts
Expected: FAIL — no #paneHistory, no History subtab, hashes.history is not a function, no history load on push.
- Step 3: Implement
ui/src/lib/types.ts — in SessionRow, after site_data_rev?: string | null:
history_count?: number | null
history_rev?: string | nulland append:
/** What the snapshot holds for one host, as History's pill counts it. */
export interface SavedCounts { cookies: number; local: number; session: number }
/** A host the session landed on: its latest page, and what can be used there. */
export interface HistoryRow {
site: string
url: string
at: number
saved: SavedCounts | null
secrets: SiteSecret[]
}
export interface HistoryPayload { key: string; sites: HistoryRow[] }ui/src/admin/router.svelte.ts — replace whole:
/* The hash, not the history API: every other path under this mount is a real
endpoint, and a history route would need a catch-all that answers a mistyped
API call with HTML. A hash never reaches the server. */
export type Tab = 'files' | 'flows' | 'site-data' | 'history'
export type Route =
| { view: 'list' }
| { view: 'console' }
| { view: 'secrets' }
| { view: 'settings' }
| { view: 'session'; key: string; tab: Tab; flow: string | undefined; site: string | undefined }
const TABS: readonly string[] = ['flows', 'site-data', 'history']
export function parse(hash: string): Route {
const [view, key, tab, rest] = hash.replace(/^#\/?/, '').split('/')
if (view === 'console') return { view: 'console' }
if (view === 'secrets') return { view: 'secrets' }
if (view === 'settings') return { view: 'settings' }
if (view === 'sessions' && key) {
// parse() runs at module load: a bad pasted `%` must not stop the app booting.
try {
const picked = (TABS.includes(tab) ? tab : 'files') as Tab
const named = rest ? decodeURIComponent(rest) : undefined
return {
view: 'session',
key: decodeURIComponent(key),
tab: picked,
// The fourth segment is a flow on Flows and a host on Site data.
flow: picked === 'flows' ? named : undefined,
site: picked === 'site-data' ? named : undefined,
}
} catch {
return { view: 'list' }
}
}
return { view: 'list' }
}
const enc = encodeURIComponent
export const hashes = {
list: '#/',
console: '#/console',
secrets: '#/secrets',
settings: '#/settings',
session: (key: string) => '#/sessions/' + enc(key),
flows: (key: string) => '#/sessions/' + enc(key) + '/flows',
siteData: (key: string) => '#/sessions/' + enc(key) + '/site-data',
site: (key: string, host: string) => '#/sessions/' + enc(key) + '/site-data/' + enc(host),
history: (key: string) => '#/sessions/' + enc(key) + '/history',
flow: (key: string, name: string) => '#/sessions/' + enc(key) + '/flows/' + enc(name),
}
export const router = $state<{ route: Route }>({ route: parse(location.hash) })
export const go = (hash: string) => { location.hash = hash }
/* In the address bar and the route, without a history entry and without
hashchange: picking through a dozen flows should not take a dozen Backs. */
export function replace(hash: string) {
history.replaceState(null, '', hash)
router.route = parse(hash)
}
/* The route, read from the address bar: at mount, and on every hashchange
(Admin's `<svelte:window onhashchange>`). */
export function sync() {
router.route = parse(location.hash)
}ui/src/admin/session.svelte.ts — add HistoryPayload to the type import; after siteDataError add:
history = $state.raw<HistoryPayload | null>(null)
historyError = $state<string | null>(null)after #shownSiteData add:
#shownHistory: string | null | undefined = undefined
#historyLoads = new Latest()after loadSiteData add:
loadHistory(): Promise<void> {
if (this.#disposed) return Promise.resolve()
return this.#historyLoads.run(
(signal) => this.#api<HistoryPayload>(sessionPath(this.key, '/history'), 'GET', undefined, signal),
(data) => { this.history = data; this.historyError = null },
(e) => {
this.historyError = e.message
// Unshown again, so the next push at the same rev tries once more.
this.#shownHistory = undefined
},
)
}in #showRow, after the site data check:
if ((row.history_rev ?? null) !== this.#shownHistory) {
this.#shownHistory = row.history_rev ?? null
void this.loadHistory()
}and in dispose, this.#historyLoads.abort() after this.#siteLoads.abort().
ui/src/admin/HistoryPane.svelte:
<script lang="ts">
import { untrack } from 'svelte'
import { ago } from '../lib/format'
import type { HistoryRow, SavedCounts } from '../lib/types'
import { sessionPath, type Api } from './api'
import { folds } from './folds.svelte'
import type { ModalSpec } from './modal'
import { go, hashes } from './router.svelte'
import Section from './Section.svelte'
import type { SessionModel } from './session.svelte'
let { m, api, ask, refuseIfGone, isGone, hidden }: {
m: SessionModel
api: Api
ask: <T>(spec: ModalSpec<T>) => void
refuseIfGone: () => void
isGone: () => boolean
hidden: boolean
} = $props()
const data = $derived(m.history)
// Ends in `Section`: Section derives its body's id by replacing that.
const idOf = (r: HistoryRow) => 'visit-' + m.key + ':' + r.site + 'Section'
// The top row opens, the rest stay shut — once; after that the fold is the
// reader's, as on Site data.
$effect.pre(() => {
const sites = data?.sites ?? []
untrack(() => sites.forEach((r, i) => { folds[idOf(r)] ??= i === 0 }))
})
const plural = (n: number, one: string) => n + ' ' + one + (n === 1 ? '' : 's')
// Zero counts are left out: "2 cookies · 2 local".
const savedText = (s: SavedCounts) => [
s.cookies ? plural(s.cookies, 'cookie') : '',
s.local ? s.local + ' local' : '',
s.session ? s.session + ' session' : '',
].filter(Boolean).join(' · ')
const when = (r: HistoryRow) =>
[ago(r.at * 1000), r.secrets.length ? plural(r.secrets.length, 'secret') : ''].filter(Boolean).join(' · ')
interface Clear { goes: string[]; stays: string }
function clear() {
const sites = data?.sites ?? []
ask<Clear>({
title: 'Clear history',
body: clearBody,
data: { goes: sites.slice(1).map((r) => r.site), stays: sites[0]?.site ?? '' },
confirm: 'Clear', danger: true,
onconfirm: async () => {
refuseIfGone()
await api(sessionPath(m.key, '/history'), 'DELETE')
if (!isGone()) void m.loadHistory()
},
})
}
</script>
{#snippet clearBody(c: Clear)}
<p>History only: site data and the browser are untouched.</p>
<div class="pairs fate-list">
<div class="pair"><span class="pk">goes</span><span class="pv">{c.goes.join(' · ')}</span></div>
<div class="pair"><span class="pk">stays</span><span class="pv">{c.stays}, the current site</span></div>
</div>
{/snippet}
{#snippet about(r: HistoryRow)}
{@const saved = r.saved ? savedText(r.saved) : ''}
<span class="small muted">{when(r)}</span>
{#if saved}
<button class="pill link" onclick={() => go(hashes.site(m.key, r.site))}>{saved}</button>
{/if}
{/snippet}
<div id="paneHistory" {hidden}>
{#if m.historyError}
<div class="empty error">{m.historyError}</div>
{:else if !data}
<div class="empty">Loading…</div>
{:else if !data.sites.length}
<div class="empty">Nowhere yet.</div>
{:else}
{#if data.sites.length > 1}
<div class="row bar">
<span class="grow"></span>
<button id="clearHistory" class="danger" onclick={clear}>Clear</button>
</div>
{/if}
{#each data.sites as r (r.site)}
{#if r.secrets.length}
<Section id={idOf(r)} title={r.url}>
{#snippet summary()}{@render about(r)}{/snippet}
{#each r.secrets as s (s.name)}
<div class="line secret">
<span class="key"><span>🔑</span> <code>{s.name}</code></span>
<span class="value">{s.description ?? ''}</span>
<span class="flags">{#each s.keys as k (k)}<span class="pill">{k}</span>{/each}</span>
</div>
{/each}
</Section>
{:else}
<!-- Nothing to open: the head alone, the caret's place kept so the
URLs line up. -->
<section class="section" id={idOf(r)}>
<div class="head">
<span class="title"><span class="caret gap" aria-hidden="true">▾</span>{r.url}</span>
{@render about(r)}
</div>
</section>
{/if}
{/each}
{/if}
</div>
<style>
.bar { margin-bottom: 12px; }
.caret.gap { visibility: hidden; }
.pill.link { font: inherit; font-size: 12px; background: none; cursor: pointer; }
.pill.link:hover { color: var(--accent); border-color: currentColor; }
.line { display: grid; grid-template-columns: 240px 1fr auto; align-items: center; gap: 8px; padding: 6px 0; border-top: 1px solid var(--line); }
.key { font-size: 12px; overflow-wrap: anywhere; }
.value { overflow-wrap: anywhere; min-width: 0; }
.flags { display: inline-flex; gap: 4px; justify-content: flex-end; }
.fate-list { padding: 8px 10px; border-radius: 8px; background: color-mix(in srgb, var(--ink) 5%, transparent); }
.fate-list .pk { width: 48px; }
</style>Check in the browser (Task 11's live check) that the plain <span class="title"> of a row with nothing to open matches the <button class="title"> beside it; if app.css styles only the button, add .section .head span.title to the same rule.
ui/src/admin/SessionDetail.svelte:
- imports: add
import HistoryPane from './HistoryPane.svelte'(afterFlowsPane), and changeimport { go, hashes, replace } from './router.svelte'toimport { go, hashes, replace, type Tab } from './router.svelte'; - the props (lines 16–23) become:
let { key, tab, flow, site, api, live, root }: {
key: string
tab: Tab
flow: string | undefined
/** A Site data host linked from History. */
site?: string
api: Api
live: Live
root: string
} = $props()- after
siteDataTotal:
const historyTotal = $derived(m.historyError || !m.history ? '' : String(m.history.sites.length))- after the
tabSiteDatabutton:
<button id="tabHistory" role="tab" aria-selected={tab === 'history'} onclick={() => go(hashes.history(key))}>History <span id="historyTotal" class="count">{historyTotal}</span></button>- the pane line
<SiteDataPane … />becomes, with History after it:
<SiteDataPane {m} {api} {ask} {refuseIfGone} isGone={() => destroyed} {site} hidden={tab !== 'site-data'} />
<HistoryPane {m} {api} {ask} {refuseIfGone} isGone={() => destroyed} hidden={tab !== 'history'} />ui/src/admin/Admin.svelte — line 101 becomes:
<SessionDetail key={route.key} tab={route.tab} flow={route.flow} site={route.site} {api} {live} root={ROOT} />ui/src/admin/SiteDataPane.svelte:
-
import { untrack } from 'svelte'becomesimport { tick, untrack } from 'svelte'; - the props become:
let { m, api, ask, refuseIfGone, isGone, site, hidden }: {
m: SessionModel
api: Api
ask: <T>(spec: ModalSpec<T>) => void
refuseIfGone: () => void
isGone: () => boolean
/** A host linked from History: opened and brought into view. */
site?: string
hidden: boolean
} = $props()- after the first-row
$effect.pre:
// A link from History opens its host and brings it into view, once per link.
let linked: string | undefined
$effect.pre(() => {
const r = (data?.sites ?? []).find((x) => x.site === site)
if (!r || site === linked) return
linked = site
untrack(() => { folds[idOf(r)] = true })
void tick().then(() => document.getElementById(idOf(r))?.scrollIntoView?.({ block: 'start' }))
})- the whole
{#each data.sites as r (r.site)}block becomes (the<Section>and its content are Task 9's, now inside a key):
{#each data.sites as r (r.site)}
{@const d = data.details[r.site]}
<!-- Remounted when it becomes the linked row: a Section reads its fold
once, at mount. -->
{#key r.site === site}
<Section id={idOf(r)} title={titleOf(r)}>
{#snippet summary()}<span class="small muted">{counts(r)}</span>{/snippet}
{#snippet actions()}<button class="danger" onclick={() => forget(r)}>Forget</button>{/snippet}
{#if d}
<h3>Cookies</h3>
{#each d.cookies as c (c.name + c.domain + c.path)}
<div class="line cookie">
<code class="key">{c.name}</code>
<span class="value clip" title={c.value}>{c.value}</span>
<span class="small muted">{c.domain} · {expires(c)}</span>
<span class="flags">
{#if c.http_only}<span class="pill">httpOnly</span>{/if}
{#if c.secure}<span class="pill">secure</span>{/if}
{#if c.shared}<span class="pill shared">shared</span>{/if}
</span>
</div>
{:else}
<div class="line"><span class="value muted">none</span></div>
{/each}
{#each groups(d) as e (e.origin)}
<h3>Local storage{@render from(d, e)}</h3>
{@render kv(Object.entries(e.local_storage))}
<h3>Session storage{@render from(d, e)}</h3>
{@render kv(Object.entries(e.session_storage))}
{/each}
{/if}
</Section>
{/key}
{/each}-
Step 4: Run to verify they pass —
cd ui && npx vitest run && npm run -s check && npm run -s lint→ PASS, 0 errors, 0 warnings;npm --prefix ui run build && npm --prefix ui run sizesucceeds — the admin surface within its 45 KB gzipped budget (39.0 KB after Task 10 in a dry run). -
Step 5: Break it on purpose — in
SiteDataPane.svelte, remove the{#key r.site === site}wrapper (keep the Section); confirmthe saved pill opens that host in Site data, unfoldedfails; restore. Then inHistoryPane.sveltechangedata.sites.length > 1todata.sites.length > 0; confirmClear is offered only while there is more than the current sitefails; restore. Then changei === 0tofalsein the fold effect; confirmrows fold like Site data: the top one opens to its secrets, the rest stay shutfails; restore. -
Step 6: Commit
git add ui/src/lib/types.ts ui/src/admin/HistoryPane.svelte ui/src/admin/HistoryPane.test.ts \
ui/src/admin/router.svelte.ts ui/src/admin/router.test.ts ui/src/admin/session.svelte.ts \
ui/src/admin/session.test.ts ui/src/admin/SessionDetail.svelte ui/src/admin/Admin.svelte \
ui/src/admin/SiteDataPane.svelte ui/src/admin/SiteDataPane.test.ts
git commit -m "Site data round 2: the History tab — where the session has been, joined by host" \
-m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"Files:
- Rewrite:
skills/selenium-flow/references/SITE_DATA.md,wiki/notes/save_site_data.notes.md - Modify:
skills/selenium-flow/SKILL.md(line 169),skills/selenium-flow/references/SESSIONS.md(lines 69–73),AGENTS.md(the### Site datasection, lines 398–410),CHANGELOG.md([Unreleased]only) - Regenerate:
wiki/*.md(a submodule: committed insidewiki/first, then the pointer)
Interfaces:
-
Consumes: everything above — the docs describe what Tasks 1–10 built, in the voice of the other references: terse, example-led.
-
Produces: no code.
tests/test_skill.py,tests/test_guidance.py,tests/test_readme.pyand the wiki check keep passing. The integration flowtests/integration/flows/save-site-data.yamlis unchanged and still valid (tests/test_flowdoc.pychecks it). -
Step 1: Rewrite the skill's reference —
skills/selenium-flow/references/SITE_DATA.md, whole:
# Site data — stay signed in across a reaped browser
A new browser starts empty. `save_site_data` keeps this one's cookies and the
storage of every site this session has been to, and every browser opened for the
session afterwards has them back — the one that replaces a reaped browser, or the
one after `end_browser()`.
## When to save
After a sign-in you have **confirmed**, never before: check the page shows you are
in, then save once.
```
write(url="https://app.example.com/login", selector={"css": "#user"}, text="a@example.com")
write(selector={"css": "#pw"}, secret={"name": "app", "key": "password"})
interact(action="click", selector={"css": "button[type=submit]"})
assert(script="return !!document.querySelector('#avatar')") # am I in?
save_site_data()
```
One save covers every site the sign-in touched — the identity provider it went
through, and an app you signed in to earlier in the session. Save again after a
setting you want kept.
Each save **replaces** the last. A save after signing out saves you signed out.
The result names what was kept, never a value:
```json
{"saved": {"cookies": 14,
"sites": ["https://app.example.com", "https://sso.example.com"],
"skipped": []},
"uri": "session://site-data"}
```
`skipped` names a site whose storage was not read this time; it keeps what the
last save had for it. "a service worker answered: save while on this site" means
the site's own worker answered first: `navigate` there and save again, and it is
read from the page itself.
## What comes back
Everything is in place before `open_session` returns: the cookies, every site's
localStorage, and the sessionStorage of the page you saved on. `open_session`
says which sites, and never a value:
```json
{"site_data": {"restored": ["app.example.com", "sso.example.com"], "skipped": [],
"uri": "session://site-data"}}
```
No `site_data` key means nothing was saved. When the Grid reaps your browser, the
next call reopens it, restores the same way, and that call's result carries this
report once.
`skipped` names a cookie or a site that did not come back, and why. "the browser
did not keep it" means the browser took a cookie without an error and dropped it;
its host is then not in `restored`.
## Restored but still signed out
The site ended its own session — it expired or was revoked. Sign in again, then
`save_site_data()` again; nothing else is wrong.
## Start as a new user
```
open_session(restore_site_data=false)
```
Opens with nothing **and deletes** what was saved, so the next open is empty too.
Where the session has been is kept.
## Debugging with execute_script
Set or clear a flag to see what a reopened browser keeps:
```
execute_script(script="localStorage.setItem('seen', '1')")
save_site_data()
end_browser()
open_session()
execute_script(script="return localStorage.getItem('seen')") # "1"
```
`document.cookie` shows what the page can see. `session://site-data/app.example.com`
shows what was saved, including httpOnly cookies the page cannot — their values
read `•••`, here and in the admin UI.
## Limits
- IndexedDB is not saved (Firebase Auth keeps its sign-in there).
- sessionStorage belongs to one tab, so only the page you save on keeps it.
- A browser opened with `insecure=true` gets none of it back: it accepts any
certificate, so saved cookies could reach whoever sits in the middle. What was
saved is kept for the next secure browser.
- A script cannot set an httpOnly cookie; it comes back through the browser's own
channel.
- Stored on the session and expires with it. Never on this server's disk; with the
Redis store it is as durable as Redis.
- A site that stores what you typed makes it readable in site data — a token typed
through a secret and kept in localStorage shows in `session://site-data/{site}`.
httpOnly cookies stay masked.- Step 2: The other skill pages
skills/selenium-flow/SKILL.md, the tool-table row for save_site_data (line 169) becomes:
| `save_site_data` | keep the cookies and storage of every site you have been to, so a replacement comes back signed in | `url` |skills/selenium-flow/references/SESSIONS.md, the paragraph at lines 69–73 becomes:
A refresh restores what you saved with `save_site_data` too — cookies and every
site's storage, so you are still signed in — and the call that noticed says so
in `site_data` (`skill://selenium-flow/references/SITE_DATA.md`). What it cannot
restore is in-page state the URL does not capture — scroll position, an open
dropdown, an unsubmitted form — and anything you never saved. If a long pause is
coming before a step that depends on unsaved state, do that step first.- Step 3: The wiki's note —
wiki/notes/save_site_data.notes.md, whole:
## When to save
Save once a sign-in is confirmed, not before: check the page shows you are in,
then call `save_site_data`. One save keeps every cookie, the localStorage of
every site the session has been to — read in a hidden tab that never loads the
site — and the sessionStorage of the page it is on. Each save replaces the last,
so a save after signing out saves you signed out.
## What restore does
Every browser opened for the session has it all back before `open_session`
returns, including the one that replaces a reaped browser: cookies first, then
each site's storage, then the page's sessionStorage, then the first page.
`open_session` names the sites in `site_data`; after a silent reopen, the call
that noticed carries that report once. `open_session(restore_site_data=false)`
opens with nothing and deletes what was saved, which is how you start as a new
user; where the session has been is kept.
## Limits
IndexedDB is not saved, and sessionStorage only for the page saved on. A site
whose service worker answers the hidden tab is not read there: it keeps its last
saved storage, and a save made while on it reads it directly. An httpOnly cookie
cannot be set from a script, so it is restored through the browser's own channel
and shown as `•••` on every surface. Saved data lives with the session's record
and expires with it: never on this server's disk, and with the Redis store as
durable as Redis.- Step 4: AGENTS.md — replace the
### Site datasection (from its heading through the bullet that endsfnonly computes, and may run twice.`, lines 398–410) with:
### Site data
- Saved **explicitly** by `save_site_data`, never captured per call. A save is a **snapshot**
that replaces the last whole (`site_data.snapshot`): the jar, the localStorage of every origin
in the record's `history`, and the page's sessionStorage. An origin that cannot be read keeps
its last storage; over 1 MB the oldest-visited origins go first.
- `SessionRecord.history` is where the session has been: one entry per origin, newest first,
written by `touch` (a flow run: once at the end, every step's page in order — and, before a
`save_site_data` step, the pages reached so far, so the save reads them). `record.url` is
`history[0].url`. A withheld URL (§F1.24) or a page with no origin records nothing.
- Other origins are reached through `browser.spare_tab`: a background tab whose requests a BiDi
intercept answers with a marked blank page, so the site never loads. A save reads there; a
restore writes there, then sets sessionStorage in the main tab the same way, all before the
first page. A page without the marker is a service worker's and is never read. No CDP.
- Kept on `SessionRecord` in the store, never on this server's disk (with the Redis store it is
as durable as Redis), never logged — `main.py` holds Selenium's wire loggers at INFO for that;
it expires with the record. httpOnly values are shown as `•••` on every surface.
- An action returns the capture under `CAPTURED`; `SessionManager.settle` stores it and strips
it, so it is never returned. `act` and every flow step go through `settle`.
- A silent reopen's report waits on `SessionRecord.reopened` until `touch` hands it to the first
result from that browser — for a flow, to the run.
- The admin tabs keep the two apart: History (the history joined by host with secrets and the
snapshot's counts) and Site data (the snapshot). Forget and both Clears change the store
only — never the live browser, never the other tab's data.
- Every record write goes through `store.update(key, fn)`, which applies `fn` to the record as
it is at write time (memory: a per-key lock; Redis: `WATCH`/`MULTI`/`EXEC`, retried, then
`StoreConflict`). A whole record read before slow work and `set` after reverts a browser
opened or a save made meanwhile. Do slow work outside `fn`; `fn` only computes, and may run
twice.- Step 5: CHANGELOG.md — under
## [Unreleased]only (round 1 has not been released, so its lines are still ours to correct). Replace
- `save_site_data` keeps a session's cookies and storage, so a reopened browser comes back signed in.with
- `save_site_data` keeps the cookies and storage of every site a session has been to, so a reopened browser comes back signed in to all of them.after - The admin UI has a Site data tab. add
- The admin UI has a History tab: where a session has been, with the secrets allowed and what is saved there.and delete - Site data a save evicts over the size cap no longer comes back in a browser already open. (the preload script it was about is gone).
- Step 6: Regenerate the wiki and run the doc checks
PYTHONPATH=$S/deps:. python3 -S scripts/generate_wiki.py
PYTHONPATH=$S/deps:. python3 -S scripts/generate_wiki.py --check
PYTHONPATH=$S/deps:. python3 -S -m pytest -q tests/test_skill.py tests/test_readme.py \
tests/test_guidance.py tests/test_flowdoc.py tests/test_wiki.py \
--deselect tests/test_wiki.py::test_the_generated_pages_are_currentExpected: the generator rewrites the action pages whose shared site-data hint changed (open_session, navigate, interact, … save_site_data, list_site_data, get_site_data, run_flow, Actions) and --check then reports every page current; the tests PASS. Then the whole unit suite → PASS, and ruff clean.
- Step 7: Commit — the wiki first, inside its submodule, then the repo with the new pointer:
git -C wiki add -A
git -C wiki commit -m "Site data round 2: one save keeps every site; History beside Site data" \
-m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
git add skills/selenium-flow/references/SITE_DATA.md skills/selenium-flow/SKILL.md \
skills/selenium-flow/references/SESSIONS.md AGENTS.md CHANGELOG.md wiki
git commit -m "Site data round 2: the skill, the docs and the wiki" \
-m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"(wiki/notes/save_site_data.notes.md is inside the submodule and goes in its commit. Pushing the wiki submodule is the controller's, with the PR: an unpushed submodule SHA fails CI's Test in about ten seconds with no pytest output.)
-
The whole branch, green: the unit suite, ruff,
generate_wiki.py --check, and inui/:npx vitest run && npm run -s check && npm run -s lint && npm run -s build && npm run -s size. (Every task of this round was applied to a scratch copy of the repo while this plan was written: 1550 Python tests, 195 UI tests, ruff, svelte-check, eslint, the build and the size budget all passed on the end state.) -
Live, before the PR (spec, Testing): Chrome and Firefox, two apps signed in, one save, a real end and reopen, both signed in, timed. Run the branch in the pod against the real Grid:
GRID_URL=http://selenium-grid-selenium-hub.flow.svc.cluster.local:4444 AUTH_TOKEN=probe-token \
PORT=8766 PYTHONPATH=.:$S/deps python3 -S -m kubed.selenium_flowand, in another shell, once per browser (bash live.sh chrome, then bash live.sh firefox):
#!/usr/bin/env bash
# live.sh <browser>: two apps signed in, one save, a real end and reopen.
B=$1; SID=live-$1; U=http://127.0.0.1:8766
H=(-s -m 180 -H "Authorization: Bearer probe-token" -H "Content-Type: application/json")
call(){ curl "${H[@]}" -X "$1" "$U$2?session=$SID" ${3:+-d "$3"}; echo; }
py(){ python3 -c "import sys,json; d=json.load(sys.stdin); print($1)"; }
ms(){ echo $(( ($(date +%s%N) - $1) / 1000000 )); }
echo "== $B: a clean start, then app 1 (an httpOnly cookie, and a localStorage mark)"
T=$(date +%s%N); call POST /browser "{\"browser\":\"$B\",\"url\":\"https://the-internet.herokuapp.com/login\",\"restore_site_data\":false}" >/dev/null; echo "plain open: $(ms $T) ms"
call POST /browser/write '{"selector":{"css":"#username"},"text":"tomsmith"}' >/dev/null
call POST /browser/write '{"selector":{"css":"#password"},"text":"SuperSecretPassword!"}' >/dev/null
call POST /browser/interact/click '{"selector":{"css":"button[type=submit]"}}' | py 'd.get("url")'
call POST /browser/script '{"script":"localStorage.setItem(\"probe\",\"kept\"); return true"}' >/dev/null
echo "== app 2 (a cookie sign-in, its cart in localStorage)"
call POST /browser/navigate '{"url":"https://www.saucedemo.com/"}' >/dev/null
call POST /browser/write '{"selector":{"css":"#user-name"},"text":"standard_user"}' >/dev/null
call POST /browser/write '{"selector":{"css":"#password"},"text":"secret_sauce"}' >/dev/null
call POST /browser/interact/click '{"selector":{"css":"#login-button"}}' | py 'd.get("url")'
call POST /browser/interact/click '{"selector":{"css":"#add-to-cart-sauce-labs-backpack"}}' >/dev/null
echo "== one save, on app 2 — sites must name both origins"
T=$(date +%s%N); call POST /browser/save-site-data '{}' | py 'd.get("saved"), "_site_data_captured" in d'; echo "save: $(ms $T) ms"
echo "== a real end and reopen"
call DELETE /browser >/dev/null
T=$(date +%s%N); call POST /browser '{}' | py 'd.get("url"), d.get("site_data")'; echo "open with restore: $(ms $T) ms"
call POST /browser/extract '{"url":"https://the-internet.herokuapp.com/secure","selector":{"css":"h2"}}' | py '"app 1 at", d.get("url")'
call POST /browser/script '{"script":"return localStorage.getItem(\"probe\")"}' | py '"app 1 localStorage:", d.get("result")'
call POST /browser/navigate '{"url":"https://www.saucedemo.com/inventory.html"}' | py '"app 2 at", d.get("url")'
call POST /browser/script '{"script":"return localStorage.getItem(\"cart-contents\")"}' | py '"app 2 cart:", d.get("result")'
echo "== the admin views"
curl "${H[@]}" "$U/admin/sessions/$SID/history" | py '[(s["site"], s["saved"]) for s in d["sites"]]'
curl "${H[@]}" "$U/admin/sessions/$SID/site-data" | py '[(s["site"], s["cookies"], s["storage"]) for s in d["sites"]]'
echo "== restore off: deleted, history kept"
call POST /browser '{"restore_site_data":false}' | py 'd.get("site_data")'
curl "${H[@]}" "$U/admin/sessions/$SID/site-data" | py '"site data after:", d["sites"]'
curl "${H[@]}" "$U/admin/sessions/$SID/history" | py '"history after:", [s["site"] for s in d["sites"]]'
call DELETE /browser >/dev/nullPass: the save's saved.sites names both https://www.saucedemo.com and https://the-internet.herokuapp.com; after the reopen app 1 stays on /secure, its probe reads kept, app 2 stays on /inventory.html with a cart, and site_data.restored names both hosts; restore_site_data=false answers {"forgotten": 2} (or more, with ad hosts), leaves Site data empty and History intact. Record both browsers' "open with restore" minus "plain open" — the restore's own cost; the spike predicts well under a second for a handful of origins. Then open the admin page on the live session and compare the two tabs with the Penpot boards.
- The PR — ask Dr K first (one PR per session). Push the wiki submodule with it. Then the Copilot review loop: read, answer and resolve each thread with
gh.
| Spec section | Task |
|---|---|
The model · history: every call that lands bumps its origin; one per origin; expiry with session.ttl, top kept; cap 100; withheld URL and no-origin page record nothing; no values |
1 |
The model · history: a flow writes its steps' origins in one update, in order; before a save step, the pages so far first |
2, 8 |
The model · record.url is history[0].url; a reopen navigates there |
1 |
Why WebDriver BiDi · browsingContext.create, network.addIntercept + provideResponse; the spike's polling and event-name findings |
3 |
The model · site_data is one snapshot: shape, replaced whole, origins from the history (non-empty only), session for the page, 1 MB cap oldest first, a jar alone over it is a 400 |
4 (shape), 8 |
save_site_data: read the jar, the page, every other history origin in a spare tab; replace; a failed origin keeps its last storage, named in skipped; result shape; BiDi down is a 503; still a flow step; annotations unchanged |
8 |
Service workers at save time: the marker, close without reading, skipped reason; restore unaffected |
3 (marker), 8 (reported) |
open_session(restore_site_data=true): cookies, then every origin's localStorage in the spare tab, then sessionStorage in the main tab, then land; the silent reopen too; fresh and insecure unchanged; best effort |
4, 5 |
open_session(restore_site_data=false): the snapshot is deleted, the history stays |
5 (tested; the behaviour is round 1's) |
The hint: {restored, skipped, uri} only when something happened; {forgotten}; the first call after a silent reopen carries the report once; no arrival hint, no waiting
|
4, 5 |
Resources (agent): session://site-data, session://site-data/{site}, session://current
|
6 (listing in history order), 1 (url) |
What round 2 removes: pending, waiting, the arrival hint, preload_source, arrive, retire, replace, Actions.retire_site_data, stale, _hold_pending, MARKER, per-origin saved_at
|
4, 5, 8 |
| The admin UI: subtabs Files · Flows · Site data · History; neither tab stores a row | 9, 10 |
| History: hosts current first, rows fold like Site data (top one open), URL bold, "1m ago", saved pill linking to Site data, secret lines, secrets never make a row, the count, Clear only while more than the current site and its confirm, no per-row action | 7 (API), 10 (UI) |
| Site data: one saved pill and Clear, rows history-first then alphabetical, no secrets, row head and counts, expanded per origin, shared cookies, the count, Forget and Clear confirms, neither touches the history or the browser, the empty line, the dropped lines | 6 (API), 9 (UI) |
Behind them: the five routes, token-gated; history_count, history_rev, site_data_count, site_data_rev
|
6, 7 |
The skill: one save covers the sign-in; restore complete at open_session; no arrival hint; the limits |
11 |
Testing · unit cases named in the spec, BiDi faked at Grid.bidi, each new test broken once |
1–10, each task's break step |
Testing · test_surfaces, the declared shapes, the wiki |
4, 5, 6, 8 (shapes); 11 (wiki) |
| Testing · the integration flow stays | unchanged; 9 keeps its selectors |
| Testing · live before the PR | controller |
| Non-goals (no per-call capture, no profile, no provenance, no eTLD+1, no setter, no agent delete, no sharing, no IndexedDB, no CDP) | none of them is built |
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: A session saves its cookies, localStorage and sessionStorage with save_site_data, and every browser opened for it — the silent reopen after a reap included — comes back with them.
Architecture: Pure logic in a new core/site_data.py (merge, view, forget, the preload script). BiDi lives behind one Grid.bidi(session_id) context manager, used only by save and restore. Actions captures and restores; SessionManager merges into the record, announces restores, and deletes on restore_site_data=false. Surfaces, admin API and a Svelte pane sit on top.
Tech Stack: Python 3.10–3.14, Selenium 4.49 (WebDriver BiDi storage and script modules), FastMCP, Starlette, Svelte 5 + vitest.
Spec: docs/superpowers/specs/2026-09-30-site-data-design.md — read it before your task; it is the authority.
- The feature is called site data; the tool is
save_site_data; the open argument isrestore_site_data(defaulttrue); resources aresession://site-dataandsession://site-data/{site}; the admin tab is Site data. - Plain W3C WebDriver only. BiDi is allowed and used only for site data. No CDP.
- BiDi is reached per call:
Grid.bidi(session_id)binds a reattached driver withwebSocketUrl = <GRID_URL with http→ws>/session/<id>/se/bidiand closes its socket on exit. - Site data lives on
SessionRecord.site_datain the session store. Never on disk. Never logged. No new setting; it expires with the record. - Size cap:
MAX_BYTES = 1_000_000bytes ofjson.dumps(site_data). - httpOnly cookie values are shown as
"•••"on every surface (resource, HTTP, admin). Nothing else is masked. - Tab marker: sessionStorage key prefix
selenium-flow:restored:; keys with it are never saved. - The hint key in results is
site_data; it appears only when something happened in that call. - Every browser action is both a tool and an endpoint:
save_site_data↔POST /browser/save-site-data. -
SessionManager.actis the only place a captured result is merged; the private result key isCAPTURED = "_site_data_captured"and never reaches a caller. - House style: docstrings in
mcp/tools.pyare prompts; comments explain the non-obvious why and are terse; UI copy is terse, descriptions behind ⓘ, no helper sentences beyond the drawn ones. - Tests:
PYTHONPATH=.:<deps> python3 -S -m pytest -q -m "not integration"(the-Savoids kubed-krm shadowing thekubednamespace in the dev pod). Ruff clean.test_wikimay fail in the pod only because its subprocess lacks-S;python3 -S scripts/generate_wiki.py --checkis the real check there. - Never run psalm/php tools; never run integration tests in the pod.
- Commit messages end with
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>.
| File | Responsibility |
|---|---|
kubed/selenium_flow/core/site_data.py (new) |
Pure: origin_of, host_of, merge, live_cookies, preload_source, view, site_view, summary, forget, matching_secrets. BiDi-using: capture, restore, retire. |
kubed/selenium_flow/core/browser.py |
Grid._options enables BiDi; Grid.bidi(session_id) context manager. |
kubed/selenium_flow/core/actions.py |
save_site_data, open_session(site_data=...), retire_site_data. |
kubed/selenium_flow/session/store.py |
SessionRecord.site_data, JSON round trip, with_site_data. |
kubed/selenium_flow/session/sessions.py |
merge in act, hint on arrival, announce after reopen, open_browser(restore_site_data), remember keeps site data, describe summary. |
kubed/selenium_flow/mcp/tools.py, routes.py, spec/schemas.py, mcp/resources.py
|
The surface. |
kubed/selenium_flow/http/admin.py |
Admin API and session-row fields. |
ui/src/admin/SiteDataPane.svelte (new), SessionDetail.svelte, router.svelte.ts, ui/src/lib/types.ts
|
The tab. |
skills/selenium-flow/references/SITE_DATA.md (new) + existing references |
The skill. |
tests/test_site_data.py (new), tests/test_site_data_sessions.py (new), tests/test_site_data_surface.py (new), ui/src/admin/SiteDataPane.test.ts (new) |
Tests. |
tests/integration/flows/save-site-data.yaml (new) |
The one integration flow. |
Files:
- Create:
kubed/selenium_flow/core/site_data.py - Modify:
kubed/selenium_flow/session/store.py(SessionRecord) - Test:
tests/test_site_data.py
Interfaces:
- Produces (used by every later task):
-
CAPTURED = "_site_data_captured",MARKER = "selenium-flow:restored:",MAX_BYTES = 1_000_000,MASK = "•••" -
origin_of(url: str) -> str—location.originform:scheme://host[:port], lowercase host, default ports (http 80, https 443) dropped;""for anything without a scheme and host. -
host_of(origin_or_url: str) -> str— lowercase hostname or"". -
merge(existing: dict, captured: dict, now: float) -> tuple[dict, dict]— returns(new_site_data, saved_summary). live_cookies(cookies: list[dict], now: float) -> list[dict]preload_source(origins: dict[str, dict]) -> str-
view(site_data: dict, secrets: list[dict] | None = None) -> dictandsite_view(site_data: dict, site: str, secrets=None) -> dict | None -
summary(site_data: dict) -> dict | None—{"sites": n, "uri": "session://site-data"}orNonewhen empty. forget(site_data: dict, host: str) -> tuple[dict, dict]-
SessionRecord.site_data: dict(default{}), carried byat,reshaped,detached(they usedataclasses.replace, so this is automatic), round-tripped byto_json/from_json, andSessionRecord.with_site_data(site_data: dict) -> SessionRecord.
-
Cookie dicts in site_data["cookies"] have exactly: name, value, value_type ("string"|"base64"), domain, path, http_only, secure, same_site, expiry (int|None).
captured (what Actions.save_site_data produces, Task 2) is:
{"cookies": [cookie dicts], "origin": "<location.origin or ''>", "local": {k: v}, "session": {k: v}}.
- Step 1: Write the failing tests —
tests/test_site_data.py:
"""Site data: what a session saves for the sites it signed in to, and how
it is shown. Pure logic only; BiDi is Task 2's."""
import json
import pytest
from kubed.selenium_flow.core import site_data as sd
from kubed.selenium_flow.session.store import SessionRecord
pytestmark = pytest.mark.unit
NOW = 1_790_800_000.0
def cookie(name, domain, value="v", http_only=False, expiry=None, secure=True):
return {
"name": name, "value": value, "value_type": "string", "domain": domain,
"path": "/", "http_only": http_only, "secure": secure,
"same_site": "lax", "expiry": expiry,
}
def captured(origin="https://app.example.com", local=None, session=None, cookies=None):
return {
"cookies": cookies if cookies is not None else [cookie("sid", "app.example.com")],
"origin": origin, "local": local or {}, "session": session or {},
}
def test_origin_of_matches_location_origin():
assert sd.origin_of("https://App.Example.com:443/x?y#z") == "https://app.example.com"
assert sd.origin_of("http://localhost:3000/a") == "http://localhost:3000"
assert sd.origin_of("http://x.test:80/") == "http://x.test"
assert sd.origin_of("about:blank") == ""
assert sd.origin_of("") == ""
def test_a_save_replaces_cookies_and_adds_its_origin():
first, _ = sd.merge({}, captured(local={"theme": "dark"}), NOW)
second, saved = sd.merge(
first,
captured(origin="https://sso.example.com", cookies=[cookie("kc", "sso.example.com")]),
NOW + 5,
)
assert [c["name"] for c in second["cookies"]] == ["kc"], "cookies are the whole jar, replaced"
assert set(second["origins"]) == {"https://app.example.com", "https://sso.example.com"}
assert second["origins"]["https://app.example.com"]["local"] == {"theme": "dark"}
assert saved == {"cookies": 1, "sites": ["https://sso.example.com"], "skipped": []}
def test_a_page_with_no_origin_saves_cookies_only():
data, saved = sd.merge({}, captured(origin=""), NOW)
assert data["origins"] == {}
assert saved["sites"] == []
def test_the_restore_marker_is_never_saved():
data, _ = sd.merge(
{}, captured(session={"selenium-flow:restored:https://app.example.com": "1", "k": "v"}), NOW
)
assert data["origins"]["https://app.example.com"]["session"] == {"k": "v"}
def test_a_save_over_the_cap_keeps_cookies_and_skips_that_storage():
big = {"blob": "x" * (sd.MAX_BYTES + 1)}
data, saved = sd.merge({}, captured(local=big), NOW)
assert data["cookies"] and data["origins"] == {}
assert saved["skipped"] == [
{"site": "https://app.example.com", "reason": "storage over 1000000 bytes"}
]
def test_merge_keeps_pending():
data, _ = sd.merge({"pending": {"browser": "b", "origins": [], "script": ""}}, captured(), NOW)
assert data["pending"]["browser"] == "b"
def test_expired_cookies_are_dropped_and_session_cookies_kept():
kept = sd.live_cookies(
[cookie("old", "a.test", expiry=int(NOW) - 1), cookie("new", "a.test", expiry=int(NOW) + 60),
cookie("session", "a.test", expiry=None)],
NOW,
)
assert [c["name"] for c in kept] == ["new", "session"]
def test_the_preload_script_fills_only_its_own_origin_once_per_tab():
src = sd.preload_source({"https://app.example.com": {"local": {"a": "1"}, "session": {"b": "2"}}})
assert src.startswith("() =>")
assert "location.origin" in src
assert "selenium-flow:restored:" in src
assert json.dumps({"a": "1"}) in src
def test_the_view_groups_by_host_and_shares_parent_cookies():
data, _ = sd.merge(
{},
captured(
local={"theme": "dark"},
cookies=[
cookie("sid", "app.example.com", value="secret", http_only=True),
cookie("ab", ".example.com", value="b1"),
cookie("kc", "sso.example.com"),
],
),
NOW,
)
listing = sd.view(data)
sites = {s["site"]: s for s in listing["sites"]}
assert set(sites) == {"app.example.com", "sso.example.com"}
assert sites["app.example.com"]["origin"] == "https://app.example.com"
assert sites["app.example.com"]["cookies"] == 2, "its own and the shared one"
assert sites["sso.example.com"]["cookies"] == 2
assert sites["app.example.com"]["uri"] == "session://site-data/app.example.com"
one = sd.site_view(data, "app.example.com")
by_name = {c["name"]: c for c in one["cookies"]}
assert by_name["sid"]["value"] == sd.MASK, "httpOnly is masked"
assert by_name["ab"]["value"] == "b1" and by_name["ab"]["shared"] is True
assert one["local_storage"] == {"theme": "dark"}
assert sd.site_view(data, "nope.test") is None
def test_a_parent_cookie_with_no_host_gets_its_own_site():
data, _ = sd.merge({}, captured(origin="", cookies=[cookie("ab", ".example.com")]), NOW)
assert [s["site"] for s in sd.view(data)["sites"]] == ["example.com"]
def test_secrets_are_matched_by_host_and_unleashed_ones_counted():
secrets = [
{"name": "app", "description": "d", "keys": ["username", "password"],
"allowed_urls": ["https://app.example.com"], "restricted": True},
{"name": "anywhere", "description": "", "keys": ["token"], "allowed_urls": [],
"restricted": False},
{"name": "admin", "description": "a", "keys": ["token"],
"allowed_urls": ["https://admin.example.com"], "restricted": True},
]
data, _ = sd.merge({}, captured(), NOW)
listing = sd.view(data, secrets)
sites = {s["site"]: s for s in listing["sites"]}
assert sites["app.example.com"]["secrets"] == [
{"name": "app", "description": "d", "keys": ["username", "password"]}
]
assert sites["admin.example.com"]["saved"] is False, "a secret keeps a row with nothing saved"
assert listing["unleashed_secrets"] == 1
assert listing["saved_sites"] == 1
def test_forget_keeps_shared_cookies_and_other_sites():
data, _ = sd.merge(
{},
captured(cookies=[cookie("sid", "app.example.com"), cookie("ab", ".example.com"),
cookie("kc", "sso.example.com")]),
NOW,
)
left, removed = sd.forget(data, "app.example.com")
assert [c["name"] for c in left["cookies"]] == ["ab", "kc"]
assert left["origins"] == {}
assert removed == {"site": "app.example.com", "cookies": ["sid"], "origins": ["https://app.example.com"],
"kept_shared": ["ab"]}
def test_summary_is_none_when_empty():
assert sd.summary({}) is None
data, _ = sd.merge({}, captured(), NOW)
assert sd.summary(data) == {"sites": 1, "uri": "session://site-data"}
def test_the_record_round_trips_its_site_data():
data, _ = sd.merge({}, captured(), NOW)
record = SessionRecord(session_id="s", url="u").with_site_data(data)
again = SessionRecord.from_json(record.to_json())
assert again.site_data == data
assert again.detached().site_data == data, "ending a browser keeps site data"
assert again.at("https://x").site_data == data
def test_a_record_written_before_site_data_reads_as_empty():
assert SessionRecord.from_json('{"session_id": "s", "url": "u"}').site_data == {}
assert SessionRecord.from_json('{"session_id": "s", "site_data": [1]}').site_data == {}- Step 2: Run to verify it fails
Run: PYTHONPATH=.:$DEPS python3 -S -m pytest -q tests/test_site_data.py
Expected: FAIL — ImportError: cannot import name 'site_data'.
- Step 3: Implement —
kubed/selenium_flow/core/site_data.py:
"""Site data: the cookies and storage a session keeps for the sites it uses.
A session outlives its browser, and used to come back signed out: the page it
was on survived a reap, the sign-in did not. An agent now saves a site's data
when it knows it is worth keeping — after a sign-in is confirmed — and every
browser opened for the session gets it back (spec 2026-09-30).
Saved, not captured: every call paying for a snapshot was rejected, and a reap
gives no warning, so only an explicit save is dependable.
**A site is a host.** Cookies carry a domain and no scheme or port, so the view
groups by host. Storage is kept per origin, keyed by the page's own
``location.origin`` because that is the string the preload script compares.
Values are credentials. They live in the session store and nowhere else, are
never logged, and an httpOnly cookie's value is never shown on any surface.
"""
from __future__ import annotations
import json
from urllib.parse import urlsplit
# The private key an action hands its capture back under. `SessionManager.act`
# merges it into the record and removes it; no caller ever sees it.
CAPTURED = "_site_data_captured"
# Marks a tab whose storage was already filled, so a later page on the same
# origin never overwrites what the app changed since. Never saved.
MARKER = "selenium-flow:restored:"
MAX_BYTES = 1_000_000
MASK = "•••"
LIST_URI = "session://site-data"
DEFAULT_PORTS = {"http": 80, "https": 443}
def origin_of(url: str) -> str:
"""``url``'s origin as ``location.origin`` spells it, or "" when it has none."""
try:
parts = urlsplit((url or "").strip())
host = (parts.hostname or "").lower()
port = parts.port
except ValueError:
return ""
if not parts.scheme or not host:
return ""
scheme = parts.scheme.lower()
shown = f":{port}" if port is not None and port != DEFAULT_PORTS.get(scheme) else ""
return f"{scheme}://{host}{shown}"
def host_of(url: str) -> str:
try:
return (urlsplit((url or "").strip()).hostname or "").lower()
except ValueError:
return ""
def site_uri(host: str) -> str:
return f"{LIST_URI}/{host}"
def merge(existing: dict, captured: dict, now: float) -> tuple[dict, dict]:
"""Fold one save into what the session already had.
The cookies are the whole jar and replace the last save's; storage is the
one origin the page was on, added beside the others. A save that would
pass the cap keeps its cookies and leaves that origin's storage out,
saying so, rather than failing or truncating.
"""
data = {
"cookies": list(captured.get("cookies") or []),
"origins": dict((existing or {}).get("origins") or {}),
"saved_at": now,
}
if (existing or {}).get("pending"):
data["pending"] = existing["pending"]
origin = captured.get("origin") or ""
sites, skipped = [], []
if origin:
entry = {
"local": dict(captured.get("local") or {}),
"session": {
k: v for k, v in (captured.get("session") or {}).items()
if not k.startswith(MARKER)
},
"saved_at": now,
}
trial = {**data, "origins": {**data["origins"], origin: entry}}
if len(json.dumps(trial)) > MAX_BYTES:
skipped.append({"site": origin, "reason": f"storage over {MAX_BYTES} bytes"})
else:
data = trial
sites.append(origin)
return data, {"cookies": len(data["cookies"]), "sites": sites, "skipped": skipped}
def live_cookies(cookies: list[dict], now: float) -> list[dict]:
"""Every cookie a restore should set: expired ones dropped, session
cookies (no expiry) kept, because the session they belong to is ours and
outlived the browser."""
return [c for c in cookies if c.get("expiry") is None or c["expiry"] > now]
def preload_source(origins: dict[str, dict]) -> str:
"""The script that fills each origin's storage before the page's own
scripts run, once per tab. Every origin rides in one script because a
preload script cannot be scoped to a URL."""
payload = json.dumps(
{o: {"local": e.get("local") or {}, "session": e.get("session") or {}}
for o, e in origins.items()}
)
marker = json.dumps(MARKER)
return (
"() => { const all = " + payload + "; const o = location.origin; "
"const s = all[o]; if (!s) return; const m = " + marker + " + o; "
"try { if (sessionStorage.getItem(m)) return; "
"for (const [k, v] of Object.entries(s.local)) localStorage.setItem(k, v); "
"for (const [k, v] of Object.entries(s.session)) sessionStorage.setItem(k, v); "
"sessionStorage.setItem(m, '1'); } catch (e) {} }"
)
def _covers(domain: str, host: str) -> bool:
bare = domain.lstrip(".")
return host == bare or (domain.startswith(".") and host.endswith(domain))
def _hosts(data: dict) -> list[str]:
stored = {host_of(o) for o in (data.get("origins") or {})}
for c in data.get("cookies") or []:
domain = c.get("domain") or ""
if domain and not any(_covers(domain, h) for h in stored):
stored.add(domain.lstrip("."))
return sorted(h for h in stored if h)
def _cookie_view(c: dict) -> dict:
return {
"name": c.get("name"),
"value": MASK if c.get("http_only") else c.get("value"),
"domain": c.get("domain"),
"path": c.get("path"),
"expiry": c.get("expiry"),
"http_only": bool(c.get("http_only")),
"secure": bool(c.get("secure")),
"same_site": c.get("same_site"),
"shared": (c.get("domain") or "").startswith("."),
}
def matching_secrets(secrets: list[dict] | None, host: str) -> list[dict]:
"""The secrets allowed on ``host``, names and keys only. A secret with no
`allowed_urls` is usable anywhere and is not listed under every site."""
return [
{"name": s["name"], "description": s.get("description") or "", "keys": list(s.get("keys") or [])}
for s in secrets or []
if any(host_of(u) == host for u in s.get("allowed_urls") or [])
]
def _site(data: dict, host: str, secrets) -> dict:
origins = {o: e for o, e in (data.get("origins") or {}).items() if host_of(o) == host}
cookies = [c for c in data.get("cookies") or [] if _covers(c.get("domain") or "", host)]
local, session = {}, {}
for entry in origins.values():
local.update(entry.get("local") or {})
session.update(entry.get("session") or {})
saved = [e.get("saved_at") for e in origins.values() if e.get("saved_at")]
return {
"site": host,
"origin": next(iter(sorted(origins)), None),
"saved": bool(origins or cookies),
"saved_at": max(saved) if saved else (data.get("saved_at") if cookies else None),
"uri": site_uri(host),
"_cookies": cookies,
"_local": local,
"_session": session,
"secrets": matching_secrets(secrets, host),
}
def view(data: dict, secrets: list[dict] | None = None) -> dict:
"""The listing: one entry per site, counts only, never a value.
With ``secrets`` (the admin catalogue listing), a site a secret is allowed
on is listed even with nothing saved — secrets are not ephemeral, so their
rows are not either.
"""
hosts = set(_hosts(data or {}))
for s in secrets or []:
hosts.update(host_of(u) for u in s.get("allowed_urls") or [])
rows = []
for host in sorted(h for h in hosts if h):
site = _site(data or {}, host, secrets)
rows.append({
"site": site["site"], "origin": site["origin"], "saved": site["saved"],
"saved_at": site["saved_at"], "uri": site["uri"],
"cookies": len(site["_cookies"]), "local_storage": len(site["_local"]),
"session_storage": len(site["_session"]), "secrets": site["secrets"],
})
unleashed = sum(1 for s in secrets or [] if not s.get("allowed_urls"))
return {
"sites": rows,
"saved_sites": sum(1 for r in rows if r["saved"]),
"unleashed_secrets": unleashed,
"uri": LIST_URI,
}
def site_view(data: dict, site: str, secrets: list[dict] | None = None) -> dict | None:
"""One site in full: cookies (httpOnly masked) and both storages."""
host = (site or "").lower()
listed = {r["site"] for r in view(data, secrets)["sites"]}
if host not in listed:
return None
s = _site(data or {}, host, secrets)
return {
"site": host, "origin": s["origin"], "saved": s["saved"], "saved_at": s["saved_at"],
"uri": s["uri"], "cookies": [_cookie_view(c) for c in s["_cookies"]],
"local_storage": s["_local"], "session_storage": s["_session"], "secrets": s["secrets"],
}
def summary(data: dict) -> dict | None:
sites = _hosts(data or {})
return {"sites": len(sites), "uri": LIST_URI} if sites else None
def forget(data: dict, host: str) -> tuple[dict, dict]:
"""Remove one site: its origins and the cookies that are its own. A
parent-domain cookie stays — other sites use it."""
host = (host or "").lower()
cookies = data.get("cookies") or []
gone = [c for c in cookies if (c.get("domain") or "") == host]
shared = [c for c in cookies if (c.get("domain") or "").startswith(".") and _covers(c["domain"], host)]
origins = [o for o in (data.get("origins") or {}) if host_of(o) == host]
left = {
**data,
"cookies": [c for c in cookies if c not in gone],
"origins": {o: e for o, e in (data.get("origins") or {}).items() if o not in origins},
}
return left, {
"site": host, "cookies": [c["name"] for c in gone], "origins": origins,
"kept_shared": [c["name"] for c in shared],
}Then in kubed/selenium_flow/session/store.py, add the field to SessionRecord after settings:
# Cookies and storage the session saved (core/site_data.py). Kept with the
# record so it expires with it, and never on disk.
site_data: dict = field(default_factory=dict)read it in from_json (beside settings):
site_data = data.get("site_data")
...
site_data=site_data if isinstance(site_data, dict) else {},and add:
def with_site_data(self, site_data: dict) -> SessionRecord:
"""The same record holding this site data."""
return replace(self, site_data=dict(site_data or {}))- Step 4: Run to verify it passes
Run: PYTHONPATH=.:$DEPS python3 -S -m pytest -q tests/test_site_data.py tests/test_sessions.py
Expected: PASS.
-
Step 5: Break it on purpose — make
_cookie_viewreturn the raw value for httpOnly; confirmtest_the_view_groups_by_host_and_shares_parent_cookiesfails; restore. -
Step 6: Commit —
git commit -m "Site data: the model — merge, view, forget, the preload script, and the record field"
Files:
- Modify:
kubed/selenium_flow/core/browser.py(Grid._options, newGrid.bidi) - Modify:
kubed/selenium_flow/core/site_data.py(addcapture,restore,retire) - Modify:
kubed/selenium_flow/core/actions.py(save_site_data,open_session(site_data=None),retire_site_data) - Test:
tests/test_site_data.py(append)
Interfaces:
- Consumes: Task 1's
merge,live_cookies,preload_source,origin_of,host_of,CAPTURED,MARKER. - Produces:
-
Grid.bidi(session_id: str)— a context manager yielding a reattached driver with.storageand.script; the socket is closed on exit.webSocketUrl=self.urlwith its schemehttp→ws(https→wss) +/session/{session_id}/se/bidi. -
site_data.capture(bidi, driver) -> dict— thecapturedshape from Task 1.driveris the classic reattached driver on the page; storage is read with oneexecute_script. -
site_data.restore(bidi, data: dict, landing_url: str | None, now: float) -> tuple[dict, dict]→(report, pending).report = {"restored": [...hosts/origins], "waiting": [origins], "skipped": [{"cookie": name, "domain": d, "reason": str}]};pending = {"origins": [...], "script": "<id or ''>"}. -
site_data.retire(bidi, script: str) -> None— best effort. -
Actions.save_site_data(session_id: str, url: str | None = None) -> dict→{"url", "title", CAPTURED: captured}. -
Actions.open_session(..., site_data: dict | None = None)→ adds"site_data": reportand"_site_data_pending": pendingto its result only whensite_datahad anything. -
Actions.retire_site_data(session_id: str, script: str) -> None.
-
Restore rules (from the spec): set every live cookie with storage.set_cookie(PartialCookie(name, BytesValue(value_type, value), domain, path=, http_only=, secure=, same_site=, expiry=)); a failure appends to skipped and continues. If any origin has storage, add one preload script with script.add_preload_script(preload_source(origins)); its id is result["script"] when the result is a dict, else the result. restored = the landing URL's origin first, if it has storage, then every cookie-only host (a host with cookies and no stored origin) sorted; waiting = every other stored origin, sorted. Nothing raises out of restore; a BiDi failure as a whole returns {"restored": [], "waiting": [], "skipped": [{"reason": "<message>"}]} and empty pending.
Capture script (the execute_script body):
READ_STORAGE = (
"const dump = (s) => { const out = {}; try { for (let i = 0; i < s.length; i++) "
"{ const k = s.key(i); out[k] = s.getItem(k); } } catch (e) {} return out; }; "
"return {origin: location.origin === 'null' ? '' : location.origin, "
"local: dump(localStorage), session: dump(sessionStorage)};"
)Cookie dicts from BiDi StorageCookie: value_type = c.value.type, value = c.value.value, same_site = c.same_site, expiry = c.expiry.
- Step 1: Write the failing tests — append to
tests/test_site_data.pya fake BiDi driver and these cases:
class FakeStorage:
def __init__(self, cookies=(), refuse=()):
self.cookies, self.set, self.refuse = list(cookies), [], set(refuse)
def get_cookies(self, filter=None, partition=None):
from types import SimpleNamespace
return SimpleNamespace(cookies=self.cookies)
def set_cookie(self, cookie=None, partition=None):
if cookie.name in self.refuse:
raise RuntimeError("unable to set cookie")
self.set.append(cookie)
class FakeScript:
def __init__(self):
self.added, self.removed = [], []
def add_preload_script(self, function_declaration=None, **_):
self.added.append(function_declaration)
return {"script": "preload-1"}
def remove_preload_script(self, script=None):
self.removed.append(script)
class FakeBidi:
def __init__(self, **kw):
self.storage, self.script = FakeStorage(**kw), FakeScript()
def bidi_cookie(name, domain, value="v", http_only=False):
from types import SimpleNamespace
return SimpleNamespace(name=name, domain=domain, path="/", http_only=http_only, secure=True,
same_site="lax", expiry=None,
value=SimpleNamespace(type="string", value=value))
class PageDriver:
def __init__(self, dump):
self.dump = dump
def execute_script(self, script, *args):
return self.dump
def test_capture_reads_every_cookie_and_this_page_storage():
bidi = FakeBidi(cookies=[bidi_cookie("sid", "app.example.com", http_only=True),
bidi_cookie("kc", "sso.example.com")])
got = sd.capture(bidi, PageDriver({"origin": "https://app.example.com",
"local": {"a": "1"}, "session": {}}))
assert [c["name"] for c in got["cookies"]] == ["sid", "kc"]
assert got["cookies"][0] == {"name": "sid", "value": "v", "value_type": "string",
"domain": "app.example.com", "path": "/", "http_only": True,
"secure": True, "same_site": "lax", "expiry": None}
assert got["origin"] == "https://app.example.com" and got["local"] == {"a": "1"}
def test_restore_sets_live_cookies_and_one_preload_script():
data, _ = sd.merge({}, captured(local={"a": "1"},
cookies=[cookie("sid", "app.example.com"),
cookie("kc", "sso.example.com"),
cookie("old", "app.example.com", expiry=int(NOW) - 1)]), NOW)
data, _ = sd.merge(data, captured(origin="https://other.example.com", local={"b": "2"},
cookies=data["cookies"]), NOW)
bidi = FakeBidi()
report, pending = sd.restore(bidi, data, "https://app.example.com/home", NOW)
assert [c.name for c in bidi.storage.set] == ["sid", "kc"], "expired cookie not replayed"
assert len(bidi.script.added) == 1
assert report["restored"] == ["https://app.example.com", "sso.example.com"]
assert report["waiting"] == ["https://other.example.com"]
assert pending == {"origins": ["https://other.example.com"], "script": "preload-1"}
def test_a_refused_cookie_is_skipped_not_fatal():
data, _ = sd.merge({}, captured(cookies=[cookie("bad", "app.example.com"),
cookie("ok", "app.example.com")]), NOW)
bidi = FakeBidi(refuse={"bad"})
report, _ = sd.restore(bidi, data, None, NOW)
assert [c.name for c in bidi.storage.set] == ["ok"]
assert report["skipped"] == [{"cookie": "bad", "domain": "app.example.com",
"reason": "unable to set cookie"}]
def test_restore_with_no_storage_adds_no_preload_script():
data, _ = sd.merge({}, captured(origin=""), NOW)
bidi = FakeBidi()
_, pending = sd.restore(bidi, data, None, NOW)
assert bidi.script.added == [] and pending == {"origins": [], "script": ""}
def test_grid_bidi_derives_the_socket_from_the_grid_url():
from kubed.selenium_flow.core.browser import Grid
with Grid("https://grid.example:4444/").bidi("abc") as driver:
assert driver.caps["webSocketUrl"] == "wss://grid.example:4444/session/abc/se/bidi"
assert driver.session_id == "abc"
def test_every_browser_is_opened_with_bidi():
from kubed.selenium_flow.core.browser import Grid
for name in ("chrome", "firefox"):
assert Grid()._options(name).to_capabilities().get("webSocketUrl") is TrueAlso append an Actions test using the existing actions fixture pattern from tests/conftest.py — patch actions.grid.bidi with a context manager yielding FakeBidi(...) and actions.grid.reconnect with a driver whose execute_script returns the dump and whose current_url/title are set; assert actions.save_site_data("sid")[sd.CAPTURED]["origin"] == "https://app.example.com" and that the result has url and title. And for open_session: patch actions.grid.open to return a fake driver (session_id, get_window_size, get, current_url, title) and grid.bidi as above; assert result["site_data"]["restored"] and result["_site_data_pending"] appear with site data, and neither key appears when site_data is None or {}; assert the preload script was added before driver.get (record call order in the fake).
-
Step 2: Run to verify they fail —
…pytest -q tests/test_site_data.py→ AttributeError oncapture/restore/bidi. -
Step 3: Implement
-
Grid._options: afteroptions.set_capability("se:downloadsEnabled", True)addoptions.enable_bidi = Truewith a two-line comment: every browser speaks BiDi so a saved site can be restored into it; every other tool still uses classic WebDriver. -
Grid.bidi: acontextlib.contextmanagermethod buildingReattachDriver(command_executor=self.url, options=self._options()), settingsession_id,caps = {"webSocketUrl": <derived>}, yielding it, and infinallyclosingdriver._websocket_connectionif set (a comment: Selenium opens the socket lazily on.storage/.script, and closing it is the only cleanup — the browser is not ours to quit). -
site_data.capture,restore,retireper the rules above, withfrom selenium.webdriver.common.bidi.storage import BytesValue, CookieFilter, PartialCookieimported inside the functions that need them. -
Actions.save_site_data(self, session_id, url=None):driver = self._at(session_id, url);with self.grid.bidi(session_id) as bidi: captured = site_data.capture(bidi, driver); return{**browser.page_state(driver), site_data.CAPTURED: captured}(page_statealready givesurlandtitle). -
Actions.open_session(..., site_data=None): after the timeouts andself._moved(...), beforeif url: driver.get(url): ifsite_datahas cookies or origins,with self.grid.bidi(session_id) as bidi: report, pending = site_data_module.restore(bidi, site_data, url, time.time()), then after building the return dict add"site_data": reportand"_site_data_pending": pending. Import the module asfrom . import site_data as site_data_moduleto avoid shadowing the parameter. -
Actions.retire_site_data(self, session_id, script):with self.grid.bidi(session_id) as bidi: site_data.retire(bidi, script); swallow and log at info on failure. - Update the
open_sessiondocstring inactions.py(one sentence: a session's saved site data is restored here, before the first page loads).
-
-
Step 4: Run to verify they pass —
…pytest -q tests/test_site_data.py tests/test_browser_choice.py tests/test_routes.py -
Step 5: Break it on purpose — move the restore after
driver.get(url); confirm the order test fails; restore. -
Step 6: Commit —
git commit -m "Site data: BiDi on every browser; save captures it, open restores it"
Files:
- Modify:
kubed/selenium_flow/session/sessions.py - Test:
tests/test_site_data_sessions.py(new)
Interfaces:
- Consumes: Task 1 (
merge,origin_of,site_uri,host_of,summary,CAPTURED,SessionRecord.with_site_data), Task 2 (Actions.open_session(site_data=)result keyssite_data/_site_data_pending,Actions.retire_site_data). - Produces:
-
SessionManager.act: popsCAPTUREDfrom a dict result, merges it into the record (merge(record.site_data, captured, time.time())), and puts"saved": <summary>, "uri": "session://site-data"in the result. Aftertouch, if the record'ssite_data["pending"]names this browser and the result URL's origin is inpending["origins"], the result gets"site_data": {"restored": [origin], "uri": site_uri(host_of(origin))}and the origin leavespending; whenpending["origins"]becomes empty andpending["script"]is set, callself.actions.retire_site_data(session_id, script)and clearscript. Ifpending["announce"]is true (a silent reopen happened), the result gets"site_data": <the reopen report>(merged with any arrival above) andannounceis cleared. -
SessionManager.open_browser(name, url=None, fresh=False, restore_site_data=True, **wanted): with restore on and saved data present, passessite_data=record.site_datatoactions.open_session; storespending = {"browser": new_id, **result.pop("_site_data_pending"), "announce": False}in the record's site data; returns thesite_datareport. With restore off and saved data present: opens without it, deletes it (with_site_data({})) and returns"site_data": {"forgotten": <number of sites per summary>}. With nothing saved, nosite_datakey at all. -
resolve: the reopen passessite_data=record.site_datawhen it has any, storespendingwith"announce": Trueand"report": <report>. -
remember(name, session_id, url="", settings=None)keeps the existing record'ssite_data(read it before writing the new record). -
describe: adds"site_data": summary(record.site_data)when notNone.
-
Use as_bool(restore_site_data, True) — callers send JSON by hand.
-
Step 1: Write the failing tests —
tests/test_site_data_sessions.py, built ontests/conftest.py'sRecordingActions,manager()andnamed_caller(read them first). ExtendRecordingActionslocally in the test module (a subclass) soopen_session(url=None, site_data=None, **kw)recordssite_dataand, when it is non-empty, returns"site_data": {"restored": ["x"], "waiting": ["https://w.test"], "skipped": []}and"_site_data_pending": {"origins": ["https://w.test"], "script": "p1"}; addsave_site_data(session_id, url=None)returning a cannedCAPTUREDpayload with urlhttps://app.example.com/x, andretire_site_data(session_id, script)recording calls. Cases:-
test_a_save_is_merged_into_the_record_and_never_returned_raw—m.act(NAMED, lambda s: m.actions.save_site_data(s))→ result hassavedanduri, notCAPTURED;m.store.get(NAMED).site_data["origins"]has the origin. -
test_open_restores_what_was_saved_and_says_so— save,end_browser,open_browser(NAMED)→ the fake receivedsite_data, the result'ssite_data["waiting"] == ["https://w.test"], recordpending["browser"]is the new id. -
test_restore_off_deletes_and_says_how_many— save thenopen_browser(NAMED, restore_site_data=False)→ fake received no site data, result{"forgotten": 1}, recordsite_data == {}. -
test_nothing_saved_means_no_hint— freshopen_browser→ nosite_datakey. -
test_arriving_on_a_waiting_origin_is_announced_once_and_retires_the_script— after (2),actwhose result url ishttps://w.test/page→site_data == {"restored": ["https://w.test"], "uri": "session://site-data/w.test"};retire_site_datacalled with"p1"; a second call on the same url has nosite_data. -
test_a_quiet_call_carries_no_hint— after (2), anactonhttps://elsewhere.test→ nosite_data. -
test_a_silent_reopen_restores_and_the_next_result_says_so— save, mark the browser dead ingrid.alive, callact→ the reopen passed site data, and that call's result carries the reopen report; the next call does not. -
test_remember_keeps_site_data— save,open_browsertwice →site_data["origins"]survives. -
test_describe_summarises_site_data— after a save,m.describe(NAMED)["site_data"] == {"sites": 1, "uri": "session://site-data"}. -
test_restore_site_data_accepts_a_string_false—restore_site_data="false"deletes.
-
-
Step 2: Run to verify they fail.
-
Step 3: Implement per the Interfaces above. Keep
actreadable: move the post-call site-data handling into a private_site_data_after(name, result)method with a docstring naming the three things it does. -
Step 4: Run
…pytest -q tests/test_site_data_sessions.py tests/test_sessions.py tests/test_routes.py→ PASS. -
Step 5: Break it on purpose — drop the
site_datapreservation inremember; confirm case 8 fails; restore. -
Step 6: Commit —
git commit -m "Site data: the session merges saves, restores on open and reopen, and says when a site arrives"
Files:
- Modify:
kubed/selenium_flow/mcp/tools.py,kubed/selenium_flow/routes.py,kubed/selenium_flow/spec/schemas.py,kubed/selenium_flow/mcp/resources.py - Create:
wiki/notes/save_site_data.notes.md; regenerate wiki pages - Test:
tests/test_site_data_surface.py(new); the standingtests/test_surfaces.py,tests/test_openapi.py,tests/test_wiki.py
Interfaces:
- Consumes: Tasks 1–3.
- Produces: tool
save_site_data(url: str | None = None) -> dictviarun(lambda s: actions.save_site_data(s, url=url));open_session(..., restore_site_data: bool = True)passed tosessions.open_browser; routeENDPOINTS["save-site-data"] = "save_site_data";POST /browserreadsbody.get("restore_site_data", True); resourcessession://site-data(listing:site_data.view(record.site_data)) and templatesession://site-data/{site}(site_view, aValueErrornamingsession://site-datawhen absent); HTTPGET {prefix}/site-dataandGET {prefix}/site-data/{site}answering the same throughhttp/answer.answerwith the caller's session name, registered beside the/filestree the same way.
Tool docstrings (prompts, verbatim):
save_site_data:
Save this session's site data: every cookie the browser holds, and the
localStorage and sessionStorage of the page it is on. Call it right after a
sign-in is confirmed, and after changing a setting you want kept; never before
checking you landed, or you keep a failed sign-in.
Every browser opened for this session gets it back, the one that replaces a
reaped browser included, so you come back signed in. Saving replaces the
cookies and adds this page's storage beside other sites'. Values are never
returned; session://site-data lists what is saved.
open_session — add after the fresh sentence:
Saved site data comes back by default; restore_site_data=false opens without it
and deletes it, which is how you start as a new user.
Annotations: save_site_data — hints("Save site data", destructive=False, idempotent=True) (read the helper's signature in mcp/annotations.py and adjust the call to it); not read-only. Update any allow-list in tests/test_surfaces.py only if that test enumerates tools explicitly, and never weaken an assertion.
Spec (spec/schemas.py): a RESPONSES["save_site_data"] via _page(...) with saved (object: cookies integer, sites array of string, skipped array of objects {site, reason}) and uri (string). Add an optional site_data object to the open response and to the shared page response where the builder allows (descriptions: "Present only when site data was restored, is waiting, or was forgotten in this call."). Describe the two GET /site-data routes the way the /files routes are described.
- Step 1: Write the failing tests —
tests/test_site_data_surface.py:-
test_save_site_data_is_a_tool_and_an_endpoint— the tool is listed (fastmcpClient(server.mcp)),routes.ENDPOINTS["save-site-data"] == "save_site_data". -
test_open_session_takes_restore_site_data— the tool schema hasrestore_site_datawith defaulttrue. -
test_the_resources_list_and_show_one_site_masking_httponly— put a record with site data (built with Task 1'smerge) under the named session inserver.sessions.store; readsession://site-dataandsession://site-data/app.example.comthrough the client; the httpOnly value is"•••"; an unknown site is an error namingsession://site-data. -
test_the_http_routes_answer_the_same—TestClient(server.mcp.http_app())GET /site-dataand/site-data/app.example.comwith the bearer token and?session=; same bodies as the resources. -
test_the_capture_never_leaves_the_server— through the HTTPPOST /browser/save-site-datawithActions.save_site_datapatched to return a canned capture andSessionManager.resolvepatched to a live id, the JSON body has no_site_data_capturedkey and hassaved. -
test_current_session_names_site_data—session://currenthassite_dataafter a save.
-
- Step 2: Run to verify they fail.
- Step 3: Implement, then regenerate the wiki:
PYTHONPATH=.:$DEPS python3 -S scripts/generate_wiki.pyand writewiki/notes/save_site_data.notes.md(3 short paragraphs: when to save; what restore does and thesite_datahint; the limits — IndexedDB, httpOnly cannot be set from a script, a save after signing out saves you signed out). The wiki is a submodule: commit insidewiki/first, then the pointer. - Step 4: Run
…pytest -q tests/test_site_data_surface.py tests/test_surfaces.py tests/test_openapi.py tests/test_resources.pyandpython3 -S scripts/generate_wiki.py --check→ PASS. - Step 5: Break it on purpose — remove the
CAPTUREDpop inact; confirm test 5 fails; restore. - Step 6: Commit — wiki submodule commit, then
git commit -m "Site data: save_site_data on both surfaces, restore_site_data on open, and session://site-data"
Files:
- Modify:
kubed/selenium_flow/http/admin.py - Test:
tests/test_admin_site_data.py(new)
Interfaces:
- Consumes: Task 1
view,site_view,forget,summary;catalogue.listing()["secrets"](entries havename, description, keys, allowed_urls, restricted). - Produces:
-
GET {prefix}/admin/sessions/{key}/site-data→{"key": key, **site_data.view(record.site_data, secrets), "details": {host: site_view(...) for every listed site}}.secretsiscatalogue.listing()["secrets"]or[]when there is no catalogue. A key with no record answers the same shape with nothing saved (secret rows still listed). -
DELETE {prefix}/admin/sessions/{key}/site-data/{site}→{"forgotten": <forget's removed summary>}; writesrecord.with_site_data(left)back; 404{"error": "no saved site data for <site>"}when the site has nothing saved. Clears the host's origins frompending["origins"]too. - Every session row in
sessions_payload()gains"site_data_count": view(record.site_data)["saved_sites"](no secrets — the count is saved sites only) and"site_data_rev": json.dumps(<sorted [host, saved_at, cookies] per saved site>). - Both routes are
@guarded(token), like/admin/sessions/{key}/files.
-
- Step 1: Write the failing tests — list shape with a secret-only row; details mask httpOnly; DELETE keeps shared cookies and the secret row, and 404s the second time; unauthenticated GET is 401;
site_data_countandsite_data_revchange after a DELETE. - Step 2–4: red, implement, green (
…pytest -q tests/test_admin_site_data.py tests/test_admin_files.py tests/test_files_and_admin.py). - Step 5: Break it on purpose — show the raw httpOnly value in details; confirm a test fails; restore.
- Step 6: Commit —
git commit -m "Site data: the admin API — a session's sites with their secrets, and Forget"
Files:
- Create:
ui/src/admin/SiteDataPane.svelte,ui/src/admin/SiteDataPane.test.ts - Modify:
ui/src/admin/router.svelte.ts(tab'site-data',hashes.siteData(key)→#/sessions/<key>/site-data),ui/src/admin/SessionDetail.svelte(third subtab + pane),ui/src/lib/types.ts(types for the admin payload),ui/src/admin/router.test.ts
The drawing is the spec for the look: Penpot Admin UI, page Session · Site data, version Site data design, round 1 — secret key pills; boards site-data, site-data-forgotten, site-data-nothing-saved, overlay-confirm-site-forget. Reuse the existing pieces the drawing reuses: the Section accordion rows (as FilesPane uses them), the Settings-style key/value rows, pills, the danger button and the confirm Modal. Read FilesPane.svelte, SettingsPane.svelte, SecretsPane.svelte and Section.svelte first and copy their idioms.
Behaviour:
-
Subtab label
Site datawith the count from the session row'ssite_data_count. -
Loads
GET /admin/sessions/{key}/site-data; reloads when the session row'ssite_data_revchanges (the way Files followsfiles_rev). -
One row per
sites[]entry: title =origin ?? site; asaved <relative time>pill whensaved_at; grey countsN cookies · N local · N session · N secret(s);Forget(danger) only whensaved. Nothing saved: counts readnothing saved · N secret. -
Expanded:
COOKIESrows — mono name, value (dots for httpOnly as the API sends them), greydomain · expiry(expiry as the page formats dates elsewhere,sessionwhen null), pillshttpOnly,secure, and a blueshared;LOCAL STORAGEandSESSION STORAGErows — mono key, value,nonewhen empty;SECRETSrows —🔑 name, description, one pill per key name. A row with no saved data shows "Nothing saved. It stays listed because a secret is allowed here." above SECRETS. -
Above the rows when
saved_sites === 0:Nothing saved — an agent calls save_site_data after signing in.withsave_site_datain mono. -
Forget opens the confirm: title
Forget site data; body<origin> — a reopened browser comes back signed out here.; a mono list ofgoes <names…>then onestaysline per kept shared cookie and per secret; buttonsCancel/Forget; confirming calls DELETE and reloads. -
No restore on/off pill.
-
Step 1: Write the failing tests (
SiteDataPane.test.ts, vitest + testing-library likeSecretsPane.test.ts): rows render in order with counts; httpOnly shows dots; key pills are the key names; the secret-only row has no Forget; the empty-state line shows whensaved_sitesis 0; Forget → confirm lists goes/stays → DELETE called with the site → reload; the router parses#/sessions/x/site-data. -
Step 2–4: red, implement, green:
npx vitest runandnpm run -s checkinui/. -
Step 5:
npm --prefix ui run buildsucceeds. -
Step 6: Commit —
git commit -m "Site data: the admin tab — sites, their cookies and storage, their secrets, and Forget"
Files:
- Create:
skills/selenium-flow/references/SITE_DATA.md,tests/integration/flows/save-site-data.yaml - Modify:
skills/selenium-flow/SKILL.md,references/SESSIONS.md,references/SECRETS.md,references/FLOWS.md,references/TROUBLESHOOTING.md,AGENTS.md,CHANGELOG.md
Content:
-
SITE_DATA.md— sections: When to save (after a confirmed sign-in: extract/assert, thensave_site_data(); after a setting you want kept; never before checking); What comes back (cookies at once, storage on arrival; readingsite_datainopen_session's result —waitingis normal); Restored but still signed out (the site ended its own session; sign in, then save again); Start as a new user (open_session(restore_site_data=false)deletes); Debugging with execute_script (set/clear a localStorage flag; readdocument.cookie; compare withsession://site-data/{site}for what the page cannot see); Limits (IndexedDB; httpOnly cookies cannot be set by a script; saving replaces all cookies, so a save after signing out saves you signed out). Terse, example-led, in the voice of the other references. -
SKILL.md— "State carries over" gains "and across a reaped browser, if you saved it"; one row in the "Where to go next" table for SITE_DATA.md; the minimal task example ends withsave_site_data()beforeend_browser();save_site_datain the tool list. -
SESSIONS.md— the reap section: a refresh restores saved site data; "cannot restore" keeps only in-page state. -
SECRETS.md— the sign-in example ends withsave_site_data(). -
FLOWS.md— a login flow ends with asave_site_datastep. -
TROUBLESHOOTING.md— "Signed in yesterday, signed out today" → SITE_DATA.md. -
AGENTS.md— the "Plain W3C WebDriver only" rule names BiDi as W3C and allowed, used only for site data, reattached per call viaGrid.bidi; a short "Site data" section: saved explicitly, never captured per call; kept on the record, never on disk; httpOnly never shown; theCAPTUREDkey is merged inactand never returned. -
CHANGELOG.md[Unreleased]— one line each:save_site_datakeeps a session's cookies and storage so a reopened browser comes back signed in;open_session(restore_site_data=false)starts without it and deletes it; the admin UI has a Site data tab. -
save-site-data.yaml— same parameters askeep-a-screenshot.yaml; steps: navigate${admin}/, write the admin token (secretadmin/token) into#token, click the login submit,save_site_data, navigate${admin}/#/sessions/${session}/site-data,assertthat the row for the admin origin's host lists the keysf-tokenunder session storage (a script reading the pane's DOM;stable_for: 0.5), with a message saying the saved sign-in did not appear in the Site data tab. -
Step 1: write the docs and the flow.
-
Step 2:
…pytest -q tests/test_skill.py tests/test_readme.py tests/test_guidance.py tests/test_flowdoc.py(the flow document validates) andpython3 -S scripts/generate_wiki.py --check. -
Step 3: Commit —
git commit -m "Site data: the skill, the docs, and one integration flow"
- The full unit suite and ruff before the PR.
- The live check: run the branch in the pod against the real Grid (as for PR #48), Chrome and Firefox: sign in to a site,
save_site_data,end_browser,open_session()→ signed in; time the restore;restore_site_data=false→ signed out and the tab empty. - The PR, and the Copilot review loop.
The action pages are generated from openapi.yaml, which is itself generated from the live MCP tool schemas — so they describe the server that shipped, not the one someone remembered. Prose belongs in wiki-notes/<tool>.md in the repo.
selenium-flow · MIT
Start here
Guides
Lifecycle
Going places
Doing things
Getting things out
Site data
Console and network