Answer the question the process list asks: what IS that, and whose is it

Eight identical 'call (WebRTC)' rows with no owner is the least useful thing
a monitor can print. Three changes:

- detail panes group processes by role with a plain-English gloss, a pid list,
  and a judgement when a group looks abandoned ('11 of 14 idle for over 30
  min, oldest 201.7h'). The hint states evidence and never says safe to delete,
  because a muted call and a dead call both sit at zero.
- the tab list is one row per URL, duplicates summed with a count badge, each
  row carrying the site's own favicon fetched through the local server and
  cached on disk. Sites with no icon get a letter tile, never a third-party
  icon service.
- Vitals' own cost is in the header, and its own tab is badged 'this screen'.

Tests caught a real bug while writing this: merge_tabs compared each tab to the
group running total, so the busiest tab could never name the row.

Co-developed-with: CLU
This commit is contained in:
CLU 2026-10-20 13:56:26 -05:00
parent 95c1c1d3eb
commit 9cdbb43394
4 changed files with 467 additions and 28 deletions

138
icons.py Normal file
View file

@ -0,0 +1,138 @@
"""Site icons for Vitals, fetched from the site itself and cached on disk.
No third-party icon service, no tracking pixel, no CDN in the middle: the icon
comes from the origin you are already talking to, over the same network path the
browser used. First request for a host hits the network once, then it is served
from ~/.cache/vitals/icons for a month.
If a site has no icon, we return None and the UI draws a letter tile in the
site's own hash colour. A missing icon is never an error worth showing.
"""
import hashlib
import os
import socket
import time
import urllib.error
import urllib.request
from urllib.parse import urlparse
CACHE_DIR = os.path.expanduser("~/.cache/vitals/icons")
MAX_BYTES = 96 * 1024
MAX_AGE_S = 30 * 24 * 3600
TIMEOUT = 2.5
# what a browser would accept, most common first
CANDIDATES = ("/favicon.ico", "/favicon.png", "/apple-touch-icon.png")
def _host_of(url):
try:
p = urlparse(url)
if p.scheme not in ("http", "https") or not p.hostname:
return None
return p.scheme, p.hostname, p.port
except ValueError:
return None
def _path_for(host):
return os.path.join(CACHE_DIR, hashlib.sha1(host.encode()).hexdigest() + ".img")
def _content_type(data):
if data[:4] == b"\x89PNG":
return "image/png"
if data[:6] in (b"GIF87a", b"GIF89a"):
return "image/gif"
if data[:4] == b"RIFF" and data[8:12] == b"WEBP":
return "image/webp"
if data[:2] == b"\xff\xd8":
return "image/jpeg"
if data[:4] == b"<svg" or b"<svg" in data[:200]:
return "image/svg+xml"
return "image/x-icon"
def icon_for(url):
"""Return (bytes, content_type) for the site's icon, or None.
Cached for 30 days. A stale cache entry is refreshed in place; a failure
leaves the old one alone rather than blanking a working icon.
"""
host_bits = _host_of(url)
if not host_bits:
return None
scheme, host, port = host_bits
cache_key = f"{host}:{port or ''}"
path = _path_for(cache_key)
fresh = False
if os.path.exists(path):
age = time.time() - os.path.getmtime(path)
if age < MAX_AGE_S:
fresh = True
else:
try:
with open(path, "rb") as f:
cached = f.read()
_refresh_in_background(scheme, host, port, path)
return cached, _content_type(cached)
except OSError:
pass
if fresh:
try:
with open(path, "rb") as f:
data = f.read()
if data:
return data, _content_type(data)
except OSError:
pass
data = _fetch(scheme, host, port)
if not data:
return None
try:
os.makedirs(CACHE_DIR, exist_ok=True)
tmp = path + ".tmp"
with open(tmp, "wb") as f:
f.write(data)
os.replace(tmp, path)
except OSError:
pass
return data, _content_type(data)
def _fetch(scheme, host, port):
netloc = host if not port else f"{host}:{port}"
for suffix in CANDIDATES:
url = f"{scheme}://{netloc}{suffix}"
try:
req = urllib.request.Request(url, headers={
"User-Agent": "Mozilla/5.0 (compatible; Vitals/0.2; local monitor)",
"Accept": "image/*,*/*;q=0.8"})
with urllib.request.urlopen(req, timeout=TIMEOUT) as r:
data = r.read(MAX_BYTES)
if data and len(data) > 40:
return data
except (urllib.error.URLError, socket.timeout, OSError, ValueError):
continue
return None
def _refresh_in_background(scheme, host, port, path):
import threading
def work():
data = _fetch(scheme, host, port)
if not data:
return
try:
tmp = path + ".tmp"
with open(tmp, "wb") as f:
f.write(data)
os.replace(tmp, path)
except OSError:
pass
threading.Thread(target=work, daemon=True, name="vitals-icon").start()

View file

@ -128,6 +128,33 @@ button:hover{border-color:#37424d;color:var(--fg)}
.row .bar i{transition:width .9s linear}
.num{font-variant-numeric:tabular-nums}
.stat, .legend span, .tabs .tr{will-change:auto}
/* site identity in the tab list */
.tabs .tr .t{display:flex;gap:8px;align-items:center;min-width:0}
.tabs .tr .tt{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.tabs .tr .tt b{font-weight:500}
.ico{width:16px;height:16px;flex:0 0 16px;border-radius:3px;display:inline-flex;
align-items:center;justify-content:center;font-size:9px;font-weight:700;color:#0d1013;
overflow:hidden;background:#2a333c}
.ico img{width:16px;height:16px;display:block}
.badge{font-size:9.5px;border:1px solid var(--line);border-radius:2px;padding:0 4px;
color:var(--dim);margin-left:6px;white-space:nowrap;vertical-align:1px}
.badge.self{border-color:#3f5c52;color:#8fd0bf}
/* role groups in the detail pane */
.role{padding:4px 0 9px;border-bottom:1px solid #1a2027}
.role .rh{display:grid;grid-template-columns:1fr 64px 76px 78px;gap:8px;align-items:baseline}
.role .rh .rn b{font-weight:500;font-size:11.5px}
.role .rh .cnt{color:var(--dim2);font-size:10px;margin-left:6px;border:1px solid var(--line);
padding:0 4px;border-radius:2px;font-variant-numeric:tabular-nums}
.role .gloss{color:var(--dim);font-size:11px;margin-top:3px;padding-right:8px}
.role .hint{color:#e8c88a;font-size:11px;margin-top:3px}
.role .pids{color:var(--dim2);font-size:9.5px;margin-top:3px;white-space:nowrap;
overflow:hidden;text-overflow:ellipsis}
.ph.roles{grid-template-columns:1fr 64px 76px 78px}
details.procs{margin-top:10px}
details.procs summary{cursor:pointer;color:var(--dim2);font-size:10px;letter-spacing:.07em;
text-transform:uppercase;padding:4px 0}
</style>
</head>
<body>
@ -239,7 +266,7 @@ function flagChips(w){
function render(){
const s=state.snap; if(!s) return;
const cores=s.cores;
setText($("#meta"), `live · ${new Date(s.t*1000).toLocaleTimeString()} · ${s.sample_ms}ms sample`);
setText($("#meta"), `live · ${new Date(s.t*1000).toLocaleTimeString()} · ${s.sample_ms}ms · this monitor ${s.self_cost.toFixed(2)} cores`);
if(s.host) setText($("#hostlabel"), "workload map · " + s.host);
ease.busyT = s.total_pct; ease.coresT = s.total_busy;
setText($("#coresmax"), " / " + cores);
@ -279,22 +306,20 @@ function render(){
}).join(""));
$("#flags").style.display = (s.flags||[]).length ? "flex" : "none";
// browser tabs: rebuilt only when the tab data itself changes
// browser tabs: one row per site, with the site's own icon. Rebuilt only
// when the data changes, so icons are not re-fetched every tick.
const tabSig=JSON.stringify(s.tabs||[]);
if(state.lastTabs!==tabSig){
state.lastTabs=tabSig;
const tabRows = (s.tabs||[]).map(t=>{
const meta = [shortUrl(t.url)];
if(t.port) meta.push("port "+t.port);
if(t.proc_cores) meta.push(t.proc_cores+" cores in this browser");
if(t.instance_age) meta.push(fmtAge(t.instance_age)+" old");
return `<div class="tr">
<div class="t">${esc(t.title)}<em>${esc(meta.join(" · "))}</em></div>
<div class="b">${t.port ? "instance "+t.port : esc(t.browser)}</div>
<div class="num mono">${t.task_pct.toFixed(1)}%</div>
<div class="num dim">${t.heap_mb} MB</div>
</div>`;}).join("");
$("#tabs").innerHTML = tabRows || `<div class="empty">no tab data yet</div>`;
const host=$("#tabs");
host.innerHTML="";
const tabs=(s.tabs||[]).slice().sort((a,b)=>(b.task_pct||0)-(a.task_pct||0));
if(!tabs.length){
const e=document.createElement("div"); e.className="empty";
e.textContent = s.brave_cdp ? "no tabs reported yet" : "no tab data yet";
host.appendChild(e);
}
tabs.forEach(t=>host.appendChild(tabRow(t, s.self_url)));
}
const brave = (s.workloads||[]).find(w=>/brave/i.test(w.name) && w.cpu > 0.05);
const banner = (!s.brave_cdp && brave) ? `<div class="banner">
@ -434,17 +459,34 @@ function fmtRam(mb){ return mb>=1024 ? (mb/1024).toFixed(2)+" GB" : mb+" MB"; }
function detailHTML(w,d){
if(!d) return `<div class="empty">loading…</div>`;
const tabBlock = (w.tabs&&w.tabs.length) ? `
<div style="margin-bottom:14px">
<div class="ph" style="grid-template-columns:1fr 70px 66px;"><div>tab</div><div>page work</div><div>heap</div></div>
<div class="ph" style="grid-template-columns:1fr 84px 66px;"><div>site</div><div>page work</div><div>heap</div></div>
${w.tabs.map(t=>`<div class="pt">
<div class="r"><b>${esc(t.title)}</b><em>${esc(shortUrl(t.url))}</em></div>
<div class="num mono">${t.task_pct.toFixed(1)}%</div>
<div class="num dim">${t.heap_mb} MB</div></div>`).join("")}
<div class="r"><b>${esc(hostOf(t.url) || t.title)}</b><em>${esc(t.title)}${(t.tab_count||1)>1?` · ${t.tab_count} tabs`:""}</em></div>
<div class="num mono">${(t.task_pct||0).toFixed(1)}%</div>
<div class="num dim">${t.heap_mb||0} MB</div></div>`).join("")}
${w.verdict?`<div class="verdict">${esc(w.verdict)}</div>`:""}
</div>` : "";
if(!d.processes.length) return tabBlock+`<div class="empty">no processes seen in this sample</div>`;
const rows=d.processes.map(p=>{
const roles = d.roles || [];
const roleBlock = roles.length ? `
<div class="ph roles"><div>role</div><div>cores</div><div>RAM</div><div>age</div></div>
${roles.map(r=>`
<div class="role">
<div class="rh">
<div class="rn"><b>${esc(r.role)}</b>${r.count>1?`<span class="cnt">×${r.count}</span>`:""}</div>
<div class="num mono">${(r.cpu||0).toFixed(2)}</div>
<div class="num dim">${fmtRam(r.rss_mb||0)}</div>
<div class="num dim">${r.count>1 ? fmtAge(r.age_max)+" max" : fmtAge(r.age_max)}</div>
</div>
${r.gloss?`<div class="gloss">${esc(r.gloss)}</div>`:""}
${r.hint?`<div class="hint">${esc(r.hint)}</div>`:""}
<div class="pids mono">pid ${r.pids.slice(0,12).join(" · ")}${r.pids.length>12?" …":""}</div>
</div>`).join("")}` : "";
const procRows = (d.processes||[]).map(p=>{
const why=[];
if(p.windows.length) why.push("window: "+p.windows[0]);
if(p.ports.length) why.push("ports "+p.ports.join(","));
@ -455,13 +497,21 @@ function detailHTML(w,d){
<div class="num mono">${(p.cpu||0).toFixed(2)}</div>
<div class="num dim">${fmtAge((p.age_h||0))}</div>
<div class="num dim">${(p.threads&&p.threads[0])?p.threads[0].name.slice(0,10):"—"}</div>
<div class="num dim">${p.threads&&p.threads[0]?p.threads[0].ticks>0?Math.round(p.threads[0].ticks/100)+"s":"":"—"}</div>
<div class="num dim">${p.threads&&p.threads[0]?p.threads[0].ticks>0?Math.round(p.threads[0].ticks/100)+"s":"":""}</div>
<div class="why">${esc(why.join(" · "))}</div>
</div>`;
}).join("");
return tabBlock+`<div class="dgrid"><div class="dlist">
const procTable = procRows ? `
<details class="procs"><summary>${d.processes.length} individual processes</summary>
<div class="dlist">
<div class="ph"><div>process / role</div><div>cores</div><div>age</div><div>hot thread</div><div>cpu</div></div>
${rows}</div>
${procRows}
</div></details>` : "";
return tabBlock + `<div class="dgrid"><div class="dlist">
${roleBlock || `<div class="empty">no processes seen in this sample</div>`}
${procTable}</div>
<div class="chart"><div class="cap">last 5 minutes · cores</div>
${bigChart(d.series, colorFor(w.key))}</div></div>`;
}
@ -470,6 +520,54 @@ function shortUrl(u){
try{ const x=new URL(u); return x.hostname.replace(/^www\./,"")+x.pathname.slice(0,40); }
catch(e){ return (u||"").slice(0,50); }
}
function hostOf(u){
try{ return new URL(u).hostname.replace(/^www\./,""); }catch(e){ return ""; }
}
/* The icon comes from the site itself through the local server, cached on disk.
If the site has none, a letter tile in the site's own colour stands in. */
function iconEl(url, host){
const box=document.createElement("span");
box.className="ico";
const img=document.createElement("img");
img.alt=""; img.loading="lazy"; img.decoding="async";
img.src="/api/icon?u="+encodeURIComponent(url||"");
img.onerror=()=>{
img.remove();
box.textContent=(host||"?").slice(0,1).toUpperCase();
box.style.background=colorFor(host||"?");
};
box.appendChild(img);
return box;
}
function tabRow(t, selfUrl){
const row=document.createElement("div"); row.className="tr";
const host=hostOf(t.url);
const cell=document.createElement("div"); cell.className="t";
cell.appendChild(iconEl(t.url, host));
const txt=document.createElement("div"); txt.className="tt";
const name=document.createElement("b"); name.textContent=host || t.title || t.url || "(untitled)";
txt.appendChild(name);
if((t.tab_count||1)>1){
const b=document.createElement("span"); b.className="badge"; b.textContent="×"+t.tab_count; txt.appendChild(b);
}
if(selfUrl && (t.url||"").startsWith(selfUrl)){
const b=document.createElement("span"); b.className="badge self"; b.textContent="this screen"; txt.appendChild(b);
}
const em=document.createElement("em"); em.textContent=t.title || ""; txt.appendChild(em);
cell.appendChild(txt); row.appendChild(cell);
const bcell=document.createElement("div"); bcell.className="b";
bcell.textContent = t.port ? "instance "+t.port : (t.browser||"");
row.appendChild(bcell);
const pct=document.createElement("div"); pct.className="num mono";
pct.textContent=((t.task_pct||0)).toFixed(1)+"%"; row.appendChild(pct);
const heap=document.createElement("div"); heap.className="num dim";
heap.textContent=((t.heap_mb||0))+" MB"; row.appendChild(heap);
return row;
}
function bigChart(series,color){
const w=284,h=120;
if(!series||series.length<2) return `<div class="empty">collecting…</div>`;

133
server.py
View file

@ -25,6 +25,7 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import urlparse, parse_qs
import browsers
import icons
HZ = os.sysconf("SC_CLK_TCK")
CORES = os.cpu_count() or 1
@ -33,6 +34,90 @@ LABELS_PATH = os.path.join(HERE, "labels.json")
SAMPLE_S = 2.0
HISTORY_N = 150 # 5 minutes at 2s
PORT = 8791 # replaced by main(); used to recognise our own tab
# What a role actually means, in words a person can act on. Shown in the
# detail pane next to the count, because "call (WebRTC) x8" is not an answer.
ROLE_GLOSS = {
"call (WebRTC)": "a page process holding a live audio/video connection. "
"Idle for hours means a call that ended and never died.",
"tab renderer": "runs one tab's page: HTML, script, layout, and its video.",
"gpu": "one compositor per browser. Draws every window and tab.",
"main": "the browser process itself: UI, network, extensions.",
"utility": "a background service belonging to the browser.",
"video_captureservice": "holds the camera. Only exists while something captures.",
"audio": "the audio service: playback and mixing for the whole browser.",
"audioservice": "the audio service: playback and mixing for the whole browser.",
"network": "the browser's network service. Every request goes through it.",
"networkservice": "the browser's network service. Every request goes through it.",
"storage": "the browser's storage service.",
"storageservice": "the browser's storage service.",
"datadecoder": "parses data for the page, in a throwaway process.",
"app": "part of this app, and Chromium does not classify it further.",
"monitor": "Vitals itself, measuring this screen.",
"headless browser": "a browser automation instance with no window.",
"renderer": "a page process in a headless browser instance.",
}
def url_key(url):
"""Normalise a URL so two tabs of the same page collapse into one row."""
try:
p = urlparse(url or "")
if not p.netloc:
return (url or "").strip()
tail = (p.path or "/") + (("?" + p.query) if p.query else "")
return f"{p.scheme}://{p.netloc}{tail}"
except ValueError:
return (url or "").strip()
def merge_tabs(tabs):
"""One row per URL. Duplicated tabs are added up, not listed twice."""
groups = {}
for t in tabs:
k = url_key(t.get("url", ""))
g = groups.get(k)
if g is None:
g = dict(t)
g["tab_count"] = 1
g["_busiest"] = t.get("task_pct") or 0
groups[k] = g
else:
g["tab_count"] += 1
g["task_pct"] = round(g.get("task_pct", 0) + (t.get("task_pct") or 0), 1)
g["heap_mb"] = round((g.get("heap_mb") or 0) + (t.get("heap_mb") or 0), 1)
g["nodes"] = int(g.get("nodes") or 0) + int(t.get("nodes") or 0)
# the busiest tab of the group names the row
if (t.get("task_pct") or 0) > g["_busiest"]:
g["_busiest"] = t.get("task_pct") or 0
g["title"] = t.get("title")
out = list(groups.values())
for g in out:
g.pop("_busiest", None)
return out
def leak_hint(role, items):
"""Say out loud when a group of processes looks abandoned, with the evidence.
A muted call and an abandoned call both sit at zero, so this never says
"safe to delete": it states the count, the age, and the condition.
"""
if not items:
return ""
idle = [p for p in items if (p.get("cpu") or 0) < 0.01 and (p.get("age_h") or 0) > 0.5]
oldest = max((p.get("age_h") or 0) for p in items)
if role in ("call (WebRTC)", "tab renderer") and idle and oldest > 1.0:
if len(idle) == len(items):
return (f"all {len(items)} idle for over 30 min, oldest {oldest:.1f}h. "
f"If you are not in a call, these can go.")
if len(idle) >= 0.5 * len(items):
return (f"{len(idle)} of {len(items)} idle for over 30 min, oldest {oldest:.1f}h. "
f"If you are not in a call, those can go.")
if role == "video_captureservice" and len(idle) == len(items):
return "the camera is held but nothing is using it"
return ""
# ---------------------------------------------------------------- labels ----
@ -496,6 +581,7 @@ class Sampler(threading.Thread):
v["cpu"] for k, v in res.get("procs", {}).items()
if pid_key.get(k) == w["key"]) / 100, 2)})
tabs.sort(key=lambda t: -t["task_pct"])
tabs_m = merge_tabs(tabs)
w_cores = w["cpu"] / 100.0
if tabs:
if w_cores - tab_cpu > 0.5 and tab_cpu < w_cores * 0.4:
@ -503,7 +589,7 @@ class Sampler(threading.Thread):
f"{w_cores - tab_cpu:.2f} cores burned by browser internals "
f"(compositor / raster / media), not by a tab")
elif tab_cpu > 0.2:
verdict = f"page work: {tab_cpu:.2f} cores across {len(tabs)} tab(s)"
verdict = f"page work: {tab_cpu:.2f} cores across {len(tabs_m)} site(s)"
else:
verdict = ""
inst_detail = []
@ -514,7 +600,7 @@ class Sampler(threading.Thread):
"tab": next((t["title"] for t in tabs if t.get("instance") == inst), ""),
"url": next((t["url"] for t in tabs if t.get("instance") == inst), ""),
})
for t in tabs:
for t in tabs_m:
tabs_flat.append({"browser": w["name"], "key": w["key"], **t})
out.append({
"key": w["key"], "name": w["name"],
@ -526,7 +612,7 @@ class Sampler(threading.Thread):
"instances": len(w["instances"]),
"roles": {k: round(v / 100, 2) for k, v in w["roles"].most_common(6) if v > 0},
"flags": sorted(w["flags"]),
"tabs": tabs[:20],
"tabs": tabs_m[:20],
"verdict": verdict,
"instances_detail": inst_detail,
"has_cdp": bool(tabs),
@ -550,6 +636,7 @@ class Sampler(threading.Thread):
"workloads": out, "flags": flags, "tabs": tabs_flat[:40],
"browsers": sorted(browsers_seen, key=lambda b: b["port"]),
"brave_cdp": any(b["port"] == 9222 for b in browsers_seen),
"self_url": f"http://127.0.0.1:{PORT}/",
"self_cost": round(self.self_cost / 100, 2),
"sample_ms": round((time.time() - t0) * 1000),
}
@ -575,12 +662,34 @@ class Sampler(threading.Thread):
"threads": read_threads(pid, 6),
})
procs.sort(key=lambda p: (-p["cpu"], -p["rss_mb"]))
# One line per role, because eight identical "call (WebRTC)" rows with
# no owner is the least useful thing a monitor can print.
by_role = collections.defaultdict(list)
for p in procs:
by_role[p["role"]].append(p)
role_groups = []
for role, items in sorted(by_role.items(),
key=lambda kv: (-sum(i["cpu"] for i in kv[1]),
-sum(i["rss_mb"] for i in kv[1]))):
ages = [i["age_h"] for i in items]
role_groups.append({
"role": role,
"count": len(items),
"cpu": round(sum(i["cpu"] for i in items), 2),
"rss_mb": sum(i["rss_mb"] for i in items),
"age_min": round(min(ages), 1),
"age_max": round(max(ages), 1),
"gloss": ROLE_GLOSS.get(role, ""),
"hint": leak_hint(role, items),
"pids": [i["pid"] for i in items],
"top_thread": next((i["threads"][0]["name"] for i in items if i.get("threads")), ""),
})
series = []
if hist:
keys = [k for _, h in hist for k in h]
if key in keys:
series = [round(h.get(key, 0.0), 2) for _, h in hist]
return {"key": key, "processes": procs, "series": series[-120:]}
return {"key": key, "processes": procs, "roles": role_groups, "series": series[-120:]}
def run(self):
self.refresh_containers()
@ -651,6 +760,20 @@ class Handler(BaseHTTPRequestHandler):
return self._send(json.dumps(
DEMO["data"]["details"].get(key, {"key": key, "processes": [], "series": []})))
return self._send(json.dumps(SAMPLER.detail(key)))
if u.path == "/api/icon":
target = parse_qs(u.query).get("u", [""])[0]
if DEMO["data"]:
return self._send(b"", "image/x-icon", 404)
got = icons.icon_for(target)
if not got:
return self._send(b"", "image/x-icon", 404)
data, ctype = got
self.send_response(200)
self.send_header("Content-Type", ctype)
self.send_header("Content-Length", str(len(data)))
self.send_header("Cache-Control", "private, max-age=86400")
self.end_headers()
return self.wfile.write(data)
return self._send("not found", "text/plain", 404)
@ -661,6 +784,8 @@ def main():
ap.add_argument("--demo", action="store_true",
help="serve the canned demo snapshot (documentation, screenshots, UI work)")
args = ap.parse_args()
global PORT
PORT = args.port
if args.demo:
load_demo()
print("Vitals in DEMO mode — canned data, no /proc sampling, no CDP", flush=True)

78
tests/test_tabs.py Normal file
View file

@ -0,0 +1,78 @@
"""Tests for site grouping and the plain-English role judgements."""
import os
import sys
import unittest
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import server # noqa: E402
class TestUrlKey(unittest.TestCase):
def test_fragments_collapse(self):
a = server.url_key("https://site.com/page#one")
b = server.url_key("https://site.com/page#two")
self.assertEqual(a, b)
def test_query_is_part_of_identity(self):
self.assertNotEqual(server.url_key("https://site.com/?tab=1"),
server.url_key("https://site.com/?tab=2"))
def test_empty_path_becomes_slash(self):
self.assertEqual(server.url_key("https://site.com"), "https://site.com/")
def test_junk_does_not_raise(self):
self.assertEqual(server.url_key(""), "")
self.assertEqual(server.url_key(None), "")
class TestMergeTabs(unittest.TestCase):
def test_same_url_is_summed_with_a_count(self):
tabs = [
{"url": "https://a.com/x", "title": "A", "task_pct": 4.0, "heap_mb": 100, "nodes": 10},
{"url": "https://a.com/x#frag", "title": "A again", "task_pct": 6.0, "heap_mb": 50, "nodes": 5},
{"url": "https://b.com/", "title": "B", "task_pct": 1.0, "heap_mb": 10, "nodes": 1},
]
out = server.merge_tabs(tabs)
self.assertEqual(len(out), 2)
a = next(t for t in out if "a.com" in t["url"])
self.assertEqual(a["tab_count"], 2)
self.assertAlmostEqual(a["task_pct"], 10.0)
self.assertEqual(a["heap_mb"], 150)
self.assertEqual(a["nodes"], 15)
def test_distinct_urls_stay_separate(self):
out = server.merge_tabs([{"url": "https://a/1", "task_pct": 1},
{"url": "https://a/2", "task_pct": 1}])
self.assertEqual(len(out), 2)
def test_busiest_title_wins(self):
out = server.merge_tabs([{"url": "https://a/", "title": "quiet", "task_pct": 0.1},
{"url": "https://a/", "title": "loud", "task_pct": 9.0}])
self.assertEqual(out[0]["title"], "loud")
class TestLeakHint(unittest.TestCase):
def idle(self, age_h, cpu=0.0):
return {"cpu": cpu, "age_h": age_h}
def test_old_idle_calls_are_called_out(self):
hint = server.leak_hint("call (WebRTC)", [self.idle(20), self.idle(9), self.idle(0.2, 0.1)])
self.assertIn("2 of 3", hint)
self.assertIn("20.0h", hint)
def test_a_busy_group_is_not_accused(self):
self.assertEqual(server.leak_hint("call (WebRTC)",
[self.idle(20, 0.5), self.idle(20, 0.4)]), "")
def test_young_processes_are_not_accused(self):
self.assertEqual(server.leak_hint("call (WebRTC)", [self.idle(0.1), self.idle(0.2)]), "")
def test_hint_never_says_safe_to_delete(self):
hint = server.leak_hint("call (WebRTC)", [self.idle(30)])
self.assertNotIn("safe", hint.lower())
if __name__ == "__main__":
unittest.main()