repld-tool 0.3.2__tar.gz → 0.3.4__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {repld_tool-0.3.2 → repld_tool-0.3.4}/PKG-INFO +1 -1
- {repld_tool-0.3.2 → repld_tool-0.3.4}/pyproject.toml +1 -1
- {repld_tool-0.3.2 → repld_tool-0.3.4}/src/repld/browser/inject.py +63 -0
- {repld_tool-0.3.2 → repld_tool-0.3.4}/src/repld/browser/observe.py +200 -14
- {repld_tool-0.3.2 → repld_tool-0.3.4}/src/repld/browser/tab.py +325 -29
- {repld_tool-0.3.2 → repld_tool-0.3.4}/src/repld/browser_dispatch.py +41 -5
- {repld_tool-0.3.2 → repld_tool-0.3.4}/src/repld/help.py +63 -16
- {repld_tool-0.3.2 → repld_tool-0.3.4}/src/repld/protocol.py +76 -4
- {repld_tool-0.3.2 → repld_tool-0.3.4}/LICENSE +0 -0
- {repld_tool-0.3.2 → repld_tool-0.3.4}/README.md +0 -0
- {repld_tool-0.3.2 → repld_tool-0.3.4}/src/repld/.ruff_cache/.gitignore +0 -0
- {repld_tool-0.3.2 → repld_tool-0.3.4}/src/repld/.ruff_cache/0.15.22/14226964882126921391 +0 -0
- {repld_tool-0.3.2 → repld_tool-0.3.4}/src/repld/.ruff_cache/CACHEDIR.TAG +0 -0
- {repld_tool-0.3.2 → repld_tool-0.3.4}/src/repld/__init__.py +0 -0
- {repld_tool-0.3.2 → repld_tool-0.3.4}/src/repld/__main__.py +0 -0
- {repld_tool-0.3.2 → repld_tool-0.3.4}/src/repld/bg.py +0 -0
- {repld_tool-0.3.2 → repld_tool-0.3.4}/src/repld/bind.py +0 -0
- {repld_tool-0.3.2 → repld_tool-0.3.4}/src/repld/bridge.py +0 -0
- {repld_tool-0.3.2 → repld_tool-0.3.4}/src/repld/bridge_tools.py +0 -0
- {repld_tool-0.3.2 → repld_tool-0.3.4}/src/repld/browser/__init__.py +0 -0
- {repld_tool-0.3.2 → repld_tool-0.3.4}/src/repld/browser/browser.py +0 -0
- {repld_tool-0.3.2 → repld_tool-0.3.4}/src/repld/browser/capture.py +0 -0
- {repld_tool-0.3.2 → repld_tool-0.3.4}/src/repld/browser/cdp.py +0 -0
- {repld_tool-0.3.2 → repld_tool-0.3.4}/src/repld/browser/har.py +0 -0
- {repld_tool-0.3.2 → repld_tool-0.3.4}/src/repld/browser/injected_source.py +0 -0
- {repld_tool-0.3.2 → repld_tool-0.3.4}/src/repld/browser/pin.py +0 -0
- {repld_tool-0.3.2 → repld_tool-0.3.4}/src/repld/browser/png.py +0 -0
- {repld_tool-0.3.2 → repld_tool-0.3.4}/src/repld/browser/pool.py +0 -0
- {repld_tool-0.3.2 → repld_tool-0.3.4}/src/repld/browser/row.py +0 -0
- {repld_tool-0.3.2 → repld_tool-0.3.4}/src/repld/browser/selector.py +0 -0
- {repld_tool-0.3.2 → repld_tool-0.3.4}/src/repld/browser/session.py +0 -0
- {repld_tool-0.3.2 → repld_tool-0.3.4}/src/repld/browser/tab_query.py +0 -0
- {repld_tool-0.3.2 → repld_tool-0.3.4}/src/repld/browser/target.py +0 -0
- {repld_tool-0.3.2 → repld_tool-0.3.4}/src/repld/channel.py +0 -0
- {repld_tool-0.3.2 → repld_tool-0.3.4}/src/repld/cli.py +0 -0
- {repld_tool-0.3.2 → repld_tool-0.3.4}/src/repld/cli_args.py +0 -0
- {repld_tool-0.3.2 → repld_tool-0.3.4}/src/repld/core_schemas.py +0 -0
- {repld_tool-0.3.2 → repld_tool-0.3.4}/src/repld/dashboard.py +0 -0
- {repld_tool-0.3.2 → repld_tool-0.3.4}/src/repld/dashboard_cmd.py +0 -0
- {repld_tool-0.3.2 → repld_tool-0.3.4}/src/repld/dashboard_html.py +0 -0
- {repld_tool-0.3.2 → repld_tool-0.3.4}/src/repld/display.py +0 -0
- {repld_tool-0.3.2 → repld_tool-0.3.4}/src/repld/eventlog.py +0 -0
- {repld_tool-0.3.2 → repld_tool-0.3.4}/src/repld/events.py +0 -0
- {repld_tool-0.3.2 → repld_tool-0.3.4}/src/repld/exec_cmd.py +0 -0
- {repld_tool-0.3.2 → repld_tool-0.3.4}/src/repld/gate_cmd.py +0 -0
- {repld_tool-0.3.2 → repld_tool-0.3.4}/src/repld/gates.py +0 -0
- {repld_tool-0.3.2 → repld_tool-0.3.4}/src/repld/gist_api.py +0 -0
- {repld_tool-0.3.2 → repld_tool-0.3.4}/src/repld/gist_cmd.py +0 -0
- {repld_tool-0.3.2 → repld_tool-0.3.4}/src/repld/gist_deps.py +0 -0
- {repld_tool-0.3.2 → repld_tool-0.3.4}/src/repld/gist_links.py +0 -0
- {repld_tool-0.3.2 → repld_tool-0.3.4}/src/repld/gist_lint.py +0 -0
- {repld_tool-0.3.2 → repld_tool-0.3.4}/src/repld/gists.py +0 -0
- {repld_tool-0.3.2 → repld_tool-0.3.4}/src/repld/ipc.py +0 -0
- {repld_tool-0.3.2 → repld_tool-0.3.4}/src/repld/kernel.py +0 -0
- {repld_tool-0.3.2 → repld_tool-0.3.4}/src/repld/kernel_context.py +0 -0
- {repld_tool-0.3.2 → repld_tool-0.3.4}/src/repld/lifecycle_cmd.py +0 -0
- {repld_tool-0.3.2 → repld_tool-0.3.4}/src/repld/log_cmd.py +0 -0
- {repld_tool-0.3.2 → repld_tool-0.3.4}/src/repld/paths.py +0 -0
- {repld_tool-0.3.2 → repld_tool-0.3.4}/src/repld/relaunch.py +0 -0
- {repld_tool-0.3.2 → repld_tool-0.3.4}/src/repld/render.py +0 -0
- {repld_tool-0.3.2 → repld_tool-0.3.4}/src/repld/runtime.py +0 -0
- {repld_tool-0.3.2 → repld_tool-0.3.4}/src/repld/sessions.py +0 -0
- {repld_tool-0.3.2 → repld_tool-0.3.4}/src/repld/spawn.py +0 -0
- {repld_tool-0.3.2 → repld_tool-0.3.4}/src/repld/state.py +0 -0
- {repld_tool-0.3.2 → repld_tool-0.3.4}/src/repld/tasks.py +0 -0
|
@@ -511,6 +511,69 @@ async def list_option_names(tab: "Tab", limit: int = 20) -> list[str]:
|
|
|
511
511
|
return result.get("result", {}).get("value") or []
|
|
512
512
|
|
|
513
513
|
|
|
514
|
+
# Observation DOM watch. The AX diff misses purely presentational reveals
|
|
515
|
+
# (SVG ports mounting on hover render no AX nodes), so pre_observe arms a
|
|
516
|
+
# MutationObserver and post_observe reads it back when the AX diff came up
|
|
517
|
+
# empty. Lives on this world's globalThis: in the utility-world tier the page
|
|
518
|
+
# can't see or tamper with it, and it dies with the document — a navigation
|
|
519
|
+
# between arm and read simply reads back null.
|
|
520
|
+
_DOM_WATCH_START = """
|
|
521
|
+
function() {
|
|
522
|
+
const w = globalThis;
|
|
523
|
+
if (w.__repld_domwatch) { try { w.__repld_domwatch.mo.disconnect(); } catch (e) {} }
|
|
524
|
+
const rec = { added: 0, removed: 0, samples: [] };
|
|
525
|
+
const desc = n => {
|
|
526
|
+
const t = n.getAttribute && n.getAttribute('data-testid');
|
|
527
|
+
if (t) return '[data-testid=' + t + ']';
|
|
528
|
+
if (n.id) return '#' + n.id;
|
|
529
|
+
return '<' + (n.tagName || '?').toLowerCase() + '>';
|
|
530
|
+
};
|
|
531
|
+
const mo = new MutationObserver(muts => {
|
|
532
|
+
for (const m of muts) {
|
|
533
|
+
for (const n of m.addedNodes) if (n.nodeType === 1) {
|
|
534
|
+
rec.added += 1 + (n.querySelectorAll ? n.querySelectorAll('*').length : 0);
|
|
535
|
+
if (rec.samples.length < 8) rec.samples.push(desc(n));
|
|
536
|
+
}
|
|
537
|
+
for (const n of m.removedNodes) if (n.nodeType === 1)
|
|
538
|
+
rec.removed += 1 + (n.querySelectorAll ? n.querySelectorAll('*').length : 0);
|
|
539
|
+
}
|
|
540
|
+
});
|
|
541
|
+
mo.observe(this.document.documentElement, { childList: true, subtree: true });
|
|
542
|
+
w.__repld_domwatch = { mo, rec };
|
|
543
|
+
}
|
|
544
|
+
"""
|
|
545
|
+
|
|
546
|
+
_DOM_WATCH_READ = """
|
|
547
|
+
function() {
|
|
548
|
+
const w = globalThis;
|
|
549
|
+
const dw = w.__repld_domwatch;
|
|
550
|
+
if (!dw) return null;
|
|
551
|
+
try { dw.mo.disconnect(); } catch (e) {}
|
|
552
|
+
w.__repld_domwatch = null;
|
|
553
|
+
return dw.rec;
|
|
554
|
+
}
|
|
555
|
+
"""
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
async def start_dom_watch(tab: "Tab") -> bool:
|
|
559
|
+
"""Arm the observation DOM watch. False when the engine can't inject."""
|
|
560
|
+
try:
|
|
561
|
+
result = await call_engine(tab, _DOM_WATCH_START, [])
|
|
562
|
+
except Exception:
|
|
563
|
+
return False
|
|
564
|
+
return "exceptionDetails" not in result
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
async def read_dom_watch(tab: "Tab") -> dict | None:
|
|
568
|
+
"""{added, removed, samples} recorded since start_dom_watch, or None."""
|
|
569
|
+
try:
|
|
570
|
+
result = await call_engine(tab, _DOM_WATCH_READ, [])
|
|
571
|
+
except Exception:
|
|
572
|
+
return None
|
|
573
|
+
value = result.get("result", {}).get("value")
|
|
574
|
+
return value if isinstance(value, dict) else None
|
|
575
|
+
|
|
576
|
+
|
|
514
577
|
async def describe_element(tab: "Tab", el: ResolvedElement) -> tuple[str, str]:
|
|
515
578
|
"""(preview, generated selector) for receipts and error digests."""
|
|
516
579
|
result = await call_engine(
|
|
@@ -10,6 +10,7 @@ from __future__ import annotations
|
|
|
10
10
|
|
|
11
11
|
import asyncio
|
|
12
12
|
import time
|
|
13
|
+
from collections import Counter
|
|
13
14
|
from dataclasses import dataclass, field
|
|
14
15
|
from urllib.parse import urlparse
|
|
15
16
|
|
|
@@ -98,6 +99,11 @@ def _node_props(node: dict) -> str:
|
|
|
98
99
|
return (" [" + ", ".join(props) + "]") if props else ""
|
|
99
100
|
|
|
100
101
|
|
|
102
|
+
# A tree signature: (role, name, props) multiset over the same nodes the
|
|
103
|
+
# renderer emits, used by the observation diff.
|
|
104
|
+
TreeSig = Counter
|
|
105
|
+
|
|
106
|
+
|
|
101
107
|
def _build_lines(
|
|
102
108
|
nodes_by_id: dict[str, dict],
|
|
103
109
|
children_map: dict[str, list[str]],
|
|
@@ -105,6 +111,7 @@ def _build_lines(
|
|
|
105
111
|
depth: int,
|
|
106
112
|
max_depth: int,
|
|
107
113
|
lines: list[str],
|
|
114
|
+
sig: TreeSig,
|
|
108
115
|
) -> None:
|
|
109
116
|
if depth > max_depth:
|
|
110
117
|
return
|
|
@@ -115,7 +122,9 @@ def _build_lines(
|
|
|
115
122
|
if role in SKIP_ROLES:
|
|
116
123
|
# Still recurse through skipped roles
|
|
117
124
|
for child_id in children_map.get(node_id, []):
|
|
118
|
-
_build_lines(
|
|
125
|
+
_build_lines(
|
|
126
|
+
nodes_by_id, children_map, child_id, depth, max_depth, lines, sig
|
|
127
|
+
)
|
|
119
128
|
return
|
|
120
129
|
|
|
121
130
|
name = _node_name(node)
|
|
@@ -126,24 +135,31 @@ def _build_lines(
|
|
|
126
135
|
label += f" {name!r}"
|
|
127
136
|
label += props
|
|
128
137
|
lines.append(label)
|
|
138
|
+
sig[(role, name, props)] += 1
|
|
129
139
|
|
|
130
140
|
if role in LEAF_ROLES:
|
|
131
141
|
return
|
|
132
142
|
|
|
133
143
|
for child_id in children_map.get(node_id, []):
|
|
134
|
-
_build_lines(
|
|
144
|
+
_build_lines(
|
|
145
|
+
nodes_by_id, children_map, child_id, depth + 1, max_depth, lines, sig
|
|
146
|
+
)
|
|
135
147
|
|
|
136
148
|
|
|
137
|
-
async def
|
|
149
|
+
async def build_tree_sig(tab: "Tab", max_depth: int = 6) -> tuple[list[str], TreeSig]:
|
|
138
150
|
"""Compact accessibility tree from CDP Accessibility.getFullAXTree.
|
|
139
151
|
|
|
140
|
-
Returns
|
|
152
|
+
Returns (indented text lines, signature multiset). The signature counts
|
|
153
|
+
exactly the nodes the lines render — same roles, same depth cap — so a
|
|
154
|
+
diff of two signatures describes what a reader of the two trees would see
|
|
155
|
+
change.
|
|
141
156
|
"""
|
|
142
157
|
result = await tab._exec("Accessibility.getFullAXTree", {})
|
|
143
158
|
|
|
159
|
+
sig: TreeSig = Counter()
|
|
144
160
|
nodes = result.get("nodes", [])
|
|
145
161
|
if not nodes:
|
|
146
|
-
return ["(empty tree)"]
|
|
162
|
+
return ["(empty tree)"], sig
|
|
147
163
|
|
|
148
164
|
nodes_by_id: dict[str, dict] = {n["nodeId"]: n for n in nodes}
|
|
149
165
|
children_map: dict[str, list[str]] = {}
|
|
@@ -161,9 +177,14 @@ async def build_tree(tab: "Tab", max_depth: int = 6) -> list[str]:
|
|
|
161
177
|
|
|
162
178
|
lines: list[str] = []
|
|
163
179
|
for root_id in root_ids:
|
|
164
|
-
_build_lines(nodes_by_id, children_map, root_id, 0, max_depth, lines)
|
|
180
|
+
_build_lines(nodes_by_id, children_map, root_id, 0, max_depth, lines, sig)
|
|
165
181
|
|
|
166
|
-
return lines or ["(empty tree)"]
|
|
182
|
+
return lines or ["(empty tree)"], sig
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
async def build_tree(tab: "Tab", max_depth: int = 6) -> list[str]:
|
|
186
|
+
lines, _ = await build_tree_sig(tab, max_depth)
|
|
187
|
+
return lines
|
|
167
188
|
|
|
168
189
|
|
|
169
190
|
async def build_aria_tree(tab: "Tab") -> list[str]:
|
|
@@ -339,25 +360,29 @@ async def compose_tree(
|
|
|
339
360
|
tab: "Tab",
|
|
340
361
|
session: "BrowserSession",
|
|
341
362
|
max_depth: int = 8,
|
|
342
|
-
) -> tuple[list[str], list["Tab"]]:
|
|
363
|
+
) -> tuple[list[str], list["Tab"], dict[str, TreeSig]]:
|
|
343
364
|
"""Build accessibility tree with iframe children inlined.
|
|
344
365
|
|
|
345
|
-
Returns (lines, iframe_child_tabs).
|
|
366
|
+
Returns (lines, iframe_child_tabs, signatures keyed by target_id).
|
|
346
367
|
"""
|
|
347
368
|
|
|
348
369
|
# Get base tree
|
|
349
|
-
lines = await
|
|
370
|
+
lines, sig = await build_tree_sig(tab, max_depth=max_depth)
|
|
371
|
+
sigs: dict[str, TreeSig] = {tab.target_id: sig}
|
|
350
372
|
iframe_children = await _discover_iframe_children(tab, session)
|
|
351
373
|
|
|
352
374
|
if not iframe_children:
|
|
353
|
-
return lines, []
|
|
375
|
+
return lines, [], sigs
|
|
354
376
|
|
|
355
377
|
child_trees: dict[str, list[str]] = {}
|
|
356
378
|
for child in iframe_children:
|
|
357
|
-
|
|
379
|
+
child_lines, child_sig = await build_tree_sig(child, max_depth=max_depth - 2)
|
|
380
|
+
child_trees[child.target_id] = child_lines
|
|
381
|
+
sigs[child.target_id] = child_sig
|
|
358
382
|
return (
|
|
359
383
|
_inline_iframe_lines(lines, iframe_children, child_trees),
|
|
360
384
|
iframe_children,
|
|
385
|
+
sigs,
|
|
361
386
|
)
|
|
362
387
|
|
|
363
388
|
|
|
@@ -428,6 +453,9 @@ class Observation:
|
|
|
428
453
|
tree: list[str]
|
|
429
454
|
network: list[NetworkEntry]
|
|
430
455
|
console: list[str]
|
|
456
|
+
# None → no diff available (browser_open, or the page navigated);
|
|
457
|
+
# [] → diff computed, nothing changed.
|
|
458
|
+
changes: list[str] | None = None
|
|
431
459
|
|
|
432
460
|
|
|
433
461
|
# ---------------------------------------------------------------------------
|
|
@@ -445,6 +473,14 @@ class PreObservation:
|
|
|
445
473
|
# These were two separate fields until the cutoff moved to `events`; they
|
|
446
474
|
# only ever held the same values, in both construction sites.
|
|
447
475
|
snapshots: dict[str, int] = field(default_factory=dict)
|
|
476
|
+
# target_id → AX-tree signature for the observation diff. None (the
|
|
477
|
+
# browser_open path constructs PreObservation directly) suppresses the
|
|
478
|
+
# diff rather than reporting a fresh document as all-appeared.
|
|
479
|
+
tree_sigs: dict[str, TreeSig] | None = None
|
|
480
|
+
url: str = ""
|
|
481
|
+
# True when the engine's MutationObserver was armed — post_observe reads
|
|
482
|
+
# it back to catch presentational reveals the AX diff can't see.
|
|
483
|
+
dom_watch: bool = False
|
|
448
484
|
|
|
449
485
|
|
|
450
486
|
def _snapshot_max_ids(tabs: list["Tab"]) -> dict[str, int]:
|
|
@@ -475,12 +511,30 @@ def _snapshot_max_ids(tabs: list["Tab"]) -> dict[str, int]:
|
|
|
475
511
|
|
|
476
512
|
|
|
477
513
|
async def pre_observe(tab: "Tab", session: "BrowserSession") -> PreObservation:
|
|
478
|
-
"""Capture state before a mutation.
|
|
514
|
+
"""Capture state before a mutation.
|
|
515
|
+
|
|
516
|
+
The signature capture fetches the same AX trees post_observe will — same
|
|
517
|
+
depths (compose_tree's 8, children at 6) — so the diff compares like with
|
|
518
|
+
like. Best-effort: a page the AX domain chokes on still gets its mutation
|
|
519
|
+
observed, just without the changes section.
|
|
520
|
+
"""
|
|
521
|
+
from . import inject
|
|
522
|
+
|
|
479
523
|
iframe_children = await _discover_iframe_children(tab, session)
|
|
480
524
|
all_tabs = [tab] + iframe_children
|
|
525
|
+
tree_sigs: dict[str, TreeSig] | None = {}
|
|
526
|
+
try:
|
|
527
|
+
_, tree_sigs[tab.target_id] = await build_tree_sig(tab, max_depth=8)
|
|
528
|
+
for child in iframe_children:
|
|
529
|
+
_, tree_sigs[child.target_id] = await build_tree_sig(child, max_depth=6)
|
|
530
|
+
except Exception:
|
|
531
|
+
tree_sigs = None
|
|
481
532
|
return PreObservation(
|
|
482
533
|
iframe_children=iframe_children,
|
|
483
534
|
snapshots=_snapshot_max_ids(all_tabs),
|
|
535
|
+
tree_sigs=tree_sigs,
|
|
536
|
+
url=tab.url,
|
|
537
|
+
dom_watch=await inject.start_dom_watch(tab),
|
|
484
538
|
)
|
|
485
539
|
|
|
486
540
|
|
|
@@ -563,6 +617,112 @@ def console_delta(tabs: list["Tab"], pre_ids: dict[str, int]) -> list[str]:
|
|
|
563
617
|
return lines
|
|
564
618
|
|
|
565
619
|
|
|
620
|
+
# ---------------------------------------------------------------------------
|
|
621
|
+
# Tree diff
|
|
622
|
+
# ---------------------------------------------------------------------------
|
|
623
|
+
|
|
624
|
+
_DIFF_MAX_LINES = 12
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
def _diff_one(pre: TreeSig, post: TreeSig) -> tuple[Counter, Counter, list[tuple]]:
|
|
628
|
+
"""(appeared, gone, changed) between two signatures.
|
|
629
|
+
|
|
630
|
+
An entry removed and added under the same (role, name) with different
|
|
631
|
+
props is a state change, not a departure plus an arrival — that pairing
|
|
632
|
+
is what turns "− button 'Save' [disabled] / + button 'Save'" into
|
|
633
|
+
"~ button 'Save' [disabled] → enabled".
|
|
634
|
+
"""
|
|
635
|
+
added = post - pre
|
|
636
|
+
removed = pre - post
|
|
637
|
+
changed: list[tuple] = []
|
|
638
|
+
removed_by_rn: dict[tuple[str, str], list[tuple[str, int]]] = {}
|
|
639
|
+
for (role, name, props), cnt in removed.items():
|
|
640
|
+
removed_by_rn.setdefault((role, name), []).append((props, cnt))
|
|
641
|
+
for key in list(added):
|
|
642
|
+
role, name, new_props = key
|
|
643
|
+
buckets = removed_by_rn.get((role, name))
|
|
644
|
+
if not buckets:
|
|
645
|
+
continue
|
|
646
|
+
old_props, old_cnt = buckets[0]
|
|
647
|
+
n = min(added[key], old_cnt)
|
|
648
|
+
changed.append((role, name, old_props, new_props, n))
|
|
649
|
+
added[key] -= n
|
|
650
|
+
removed[(role, name, old_props)] -= n
|
|
651
|
+
if old_cnt - n:
|
|
652
|
+
buckets[0] = (old_props, old_cnt - n)
|
|
653
|
+
else:
|
|
654
|
+
buckets.pop(0)
|
|
655
|
+
if not buckets:
|
|
656
|
+
del removed_by_rn[(role, name)]
|
|
657
|
+
return +added, +removed, changed
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
def _entry_str(role: str, name: str, props: str) -> str:
|
|
661
|
+
label = role
|
|
662
|
+
if name:
|
|
663
|
+
label += f" {name!r}"
|
|
664
|
+
return label + props
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
def tree_diff_lines(
|
|
668
|
+
pre_sigs: dict[str, TreeSig],
|
|
669
|
+
post_sigs: dict[str, TreeSig],
|
|
670
|
+
main_target: str,
|
|
671
|
+
) -> list[str]:
|
|
672
|
+
"""Render the appeared/gone/changed summary, main tab first.
|
|
673
|
+
|
|
674
|
+
An iframe present on only one side reads as its whole tree
|
|
675
|
+
appearing/disappearing, which is accurate: the frame itself came or went.
|
|
676
|
+
"""
|
|
677
|
+
detail: list[str] = []
|
|
678
|
+
totals = [0, 0, 0]
|
|
679
|
+
targets = [main_target] + [t for t in post_sigs if t != main_target]
|
|
680
|
+
targets += [t for t in pre_sigs if t not in post_sigs]
|
|
681
|
+
for target in targets:
|
|
682
|
+
added, removed, changed = _diff_one(
|
|
683
|
+
pre_sigs.get(target) or Counter(), post_sigs.get(target) or Counter()
|
|
684
|
+
)
|
|
685
|
+
totals[0] += sum(added.values())
|
|
686
|
+
totals[1] += sum(removed.values())
|
|
687
|
+
totals[2] += sum(n for *_, n in changed)
|
|
688
|
+
prefix = "" if target == main_target else f"{target} "
|
|
689
|
+
for (role, name, props), cnt in sorted(added.items(), key=lambda kv: -kv[1]):
|
|
690
|
+
mult = f" ×{cnt}" if cnt > 1 else ""
|
|
691
|
+
detail.append(f"+ {prefix}{_entry_str(role, name, props)}{mult}")
|
|
692
|
+
for role, name, old, new, n in changed:
|
|
693
|
+
mult = f" ×{n}" if n > 1 else ""
|
|
694
|
+
old_s = old.strip() or "[none]"
|
|
695
|
+
new_s = new.strip() or "[none]"
|
|
696
|
+
detail.append(
|
|
697
|
+
f"~ {prefix}{_entry_str(role, name, '')} {old_s} → {new_s}{mult}"
|
|
698
|
+
)
|
|
699
|
+
for (role, name, props), cnt in sorted(removed.items(), key=lambda kv: -kv[1]):
|
|
700
|
+
mult = f" ×{cnt}" if cnt > 1 else ""
|
|
701
|
+
detail.append(f"- {prefix}{_entry_str(role, name, props)}{mult}")
|
|
702
|
+
|
|
703
|
+
if not detail:
|
|
704
|
+
return []
|
|
705
|
+
header = f"changes: {totals[0]} appeared, {totals[1]} gone, {totals[2]} changed"
|
|
706
|
+
if len(detail) > _DIFF_MAX_LINES:
|
|
707
|
+
hidden = len(detail) - _DIFF_MAX_LINES
|
|
708
|
+
detail = detail[:_DIFF_MAX_LINES] + [f"… +{hidden} more (full tree below)"]
|
|
709
|
+
return [header] + [" " + d for d in detail]
|
|
710
|
+
|
|
711
|
+
|
|
712
|
+
def _dom_delta_line(rec: dict) -> str | None:
|
|
713
|
+
"""The AX-silent fallback line — presentational nodes came or went."""
|
|
714
|
+
added = rec.get("added") or 0
|
|
715
|
+
removed = rec.get("removed") or 0
|
|
716
|
+
if not added and not removed:
|
|
717
|
+
return None
|
|
718
|
+
counts = Counter(rec.get("samples") or [])
|
|
719
|
+
sample = ", ".join(f"{s} ×{n}" if n > 1 else s for s, n in counts.most_common(4))
|
|
720
|
+
line = f"changes: none in the AX tree — dom: +{added} −{removed} elements"
|
|
721
|
+
if sample:
|
|
722
|
+
line += f" ({sample})"
|
|
723
|
+
return line
|
|
724
|
+
|
|
725
|
+
|
|
566
726
|
# ---------------------------------------------------------------------------
|
|
567
727
|
# Formatting
|
|
568
728
|
# ---------------------------------------------------------------------------
|
|
@@ -576,6 +736,14 @@ def format_observation(obs: Observation) -> str:
|
|
|
576
736
|
parts.append(f"url: {obs.url} (settled in {obs.settle_ms}ms)")
|
|
577
737
|
parts.append("")
|
|
578
738
|
|
|
739
|
+
# Changes — what this mutation did to the AX tree, before the full tree.
|
|
740
|
+
if obs.changes is not None:
|
|
741
|
+
if obs.changes:
|
|
742
|
+
parts.extend(obs.changes)
|
|
743
|
+
else:
|
|
744
|
+
parts.append("changes: none (AX tree unchanged)")
|
|
745
|
+
parts.append("")
|
|
746
|
+
|
|
579
747
|
# Tree
|
|
580
748
|
tree_count = len(obs.tree)
|
|
581
749
|
parts.append(f"tree ({tree_count} nodes):")
|
|
@@ -640,7 +808,24 @@ async def post_observe(
|
|
|
640
808
|
|
|
641
809
|
# Build composed tree — re-discovers iframes, which may differ from
|
|
642
810
|
# pre.iframe_children if the mutation added/removed one.
|
|
643
|
-
tree_lines, fresh_iframes = await compose_tree(tab, session)
|
|
811
|
+
tree_lines, fresh_iframes, post_sigs = await compose_tree(tab, session)
|
|
812
|
+
|
|
813
|
+
# Diff needs a pre signature *and* the same document — after a navigation
|
|
814
|
+
# the whole tree is new, and reporting it as a few hundred appearances
|
|
815
|
+
# would bury the one section that exists to be short.
|
|
816
|
+
from . import inject
|
|
817
|
+
|
|
818
|
+
dom_rec = await inject.read_dom_watch(tab) if pre.dom_watch else None
|
|
819
|
+
changes: list[str] | None = None
|
|
820
|
+
if pre.tree_sigs is not None:
|
|
821
|
+
if tab.url == pre.url:
|
|
822
|
+
changes = tree_diff_lines(pre.tree_sigs, post_sigs, tab.target_id)
|
|
823
|
+
if not changes and dom_rec:
|
|
824
|
+
line = _dom_delta_line(dom_rec)
|
|
825
|
+
if line:
|
|
826
|
+
changes = [line]
|
|
827
|
+
else:
|
|
828
|
+
changes = ["changes: (page navigated — tree replaced)"]
|
|
644
829
|
|
|
645
830
|
# A mutation can spawn a brand-new iframe (e.g. an OAuth popup frame)
|
|
646
831
|
# that wasn't in pre.iframe_children and so never got a quiet-period
|
|
@@ -675,6 +860,7 @@ async def post_observe(
|
|
|
675
860
|
tree=tree_lines,
|
|
676
861
|
network=net_entries,
|
|
677
862
|
console=console_lines,
|
|
863
|
+
changes=changes,
|
|
678
864
|
)
|
|
679
865
|
|
|
680
866
|
text = format_observation(obs)
|