Ctrl K
ComunidadGuías…

tools/visual_check.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""Look at the panel, because nothing else here can.

Every other check in this repo reasons about the code. None of them can see
that a control is invisible, that one thing is covering another, or that a
theme did not reach the terminal — and that is where the bugs have actually
been. A preview popup layered above the context menu broke right-click on every
sidebar row and the whole suite stayed green through it.

So this drives a real browser: it opens the panel, asserts the things only a
rendered page can answer, and writes screenshots to look at afterwards.

**A development tool, not part of the product.** CLIque is standard library and
no build step; this needs Chromium and Playwright and lives in a virtualenv of
its own, so nothing a user installs is affected:

    python3 -m venv ~/.cache/clique-visual
    ~/.cache/clique-visual/bin/pip install playwright
    ~/.cache/clique-visual/bin/playwright install chromium
    ~/.cache/clique-visual/bin/python tools/visual_check.py

It authenticates by minting a session cookie with the server's own signing key
rather than by typing a password, so it needs no secret and cannot be run
against a panel that is not this machine's.

**It runs its own panel, not yours.** A tmux window has one size, shared by
every client attached to it — so a second browser opening the live panel fights
the first for it and the loser gets a screen padded out with dots. Making a
throwaway *session* was not enough: loading the panel restores the workspace,
which attaches every tab that was open, which is every session someone is
working in.

So this starts a second CLIque on another port with its own state directory and
its own tmux socket, checks that, and tears the whole thing down. Nothing it
does can reach a session anybody is using.
"""

from __future__ import annotations

import os
import shutil
import subprocess
import sys
import time
import urllib.error
import urllib.request
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parents[1]))

from clique import tmux
from clique.auth import COOKIE_NAME, Auth

#: Its own everything. The port is not 3200 and the home is not ~/.clique on
#: purpose: this must not be able to touch the panel someone is working in.
#: Not a secret: this panel exists for a few seconds, on loopback, holding
#: nothing. It has a password only because the server requires one.
PASSWORD = "visual-check"  # noqa: S105 — a throwaway panel on loopback, alive for seconds

PORT = 3299
BASE = f"http://127.0.0.1:{PORT}"
SANDBOX = Path("/tmp/clique-visual-home")
SOCKET = "clique-visual"
SHOTS = Path("/tmp/clique-visual")

passed = failed = 0


def check(label: str, cond: bool, detail: object = "") -> None:
    global passed, failed
    if cond:
        passed += 1
        print(f"  ok   {label}")
    else:
        failed += 1
        print(f"  FAIL {label} {detail}")


def _api(path: str, method: str = "GET", body: dict | None = None) -> dict:
    import json
    import urllib.request

    data = json.dumps(body).encode() if body is not None else None
    request = urllib.request.Request(BASE + path, data=data, method=method)
    request.add_header("Content-Type", "application/json")
    request.add_header("Authorization", "Bearer " + _token())
    with urllib.request.urlopen(request, timeout=20) as response:
        return json.loads(response.read() or b"{}")


_cached_token = ""


def _token() -> str:
    """A short-lived token of this tool's own, minted on the box."""
    global _cached_token
    if not _cached_token:
        import subprocess

        # Minted against the sandbox's own store, not the live panel's — the
        # token subcommand reads CLIQUE_HOME like everything else.
        made = subprocess.run(
            [sys.executable, "-m", "clique", "token", "create", "visual-check"],
            capture_output=True,
            text=True,
            env=dict(os.environ, CLIQUE_HOME=str(SANDBOX), CLIQUE_TMUX_SOCKET=SOCKET),
            cwd=str(Path(__file__).resolve().parents[1]),
        )
        _cached_token = next(
            line.strip() for line in made.stdout.splitlines() if line.strip().startswith("mxp_")
        )
    return _cached_token


def _scratch_session() -> str:
    repo = SANDBOX / "work"
    repo.mkdir(parents=True, exist_ok=True)
    subprocess.run(["git", "init"], cwd=repo, check=True, capture_output=True)
    subprocess.run(
        ["git", "-C", str(repo), "symbolic-ref", "HEAD", "refs/heads/visual"],
        check=True,
        capture_output=True,
    )
    (repo / "note.md").write_text("hello from a click\n", encoding="utf-8")
    return _api(
        "/api/sessions", "POST", {"cli": "shell", "cwd": str(repo), "name": "visual check"}
    )["id"]


def _remove_session(session_id: str) -> None:
    import contextlib

    with contextlib.suppress(Exception):
        _api("/api/sessions/" + session_id, "DELETE")


def main() -> int:

    SHOTS.mkdir(parents=True, exist_ok=True)
    panel = _own_panel()
    try:
        return _run(panel)
    finally:
        panel.terminate()
        try:
            panel.wait(timeout=10)
        except subprocess.TimeoutExpired:
            panel.kill()
        tmux._run(["kill-server"], SOCKET, check=False)
        shutil.rmtree(SANDBOX, ignore_errors=True)


def _sandbox_catalogue() -> None:
    """The real catalogue plus a boxed stand-in, so copy/click can be seen.

    Claude, Grok and Gemini all turn mouse tracking on and draw their own
    prompt. This is that shape without opening a paid CLI in the check.
    """
    root = Path(__file__).resolve().parents[1]
    packaged = root / "clique" / "config" / "clis.toml"
    fake = root / "tools" / "fake_boxed_cli.py"
    body = packaged.read_text(encoding="utf-8")
    body += (
        "\n[cli.boxed]\n"
        'label      = "Boxed"\n'
        f"command    = {sys.executable!r}\n"
        f"args       = [{str(fake)!r}]\n"
        'color      = "#6b7280"\n'
        "own_input  = true\n"
    )
    (SANDBOX / "clis.toml").write_text(body, encoding="utf-8")


def _own_panel() -> subprocess.Popen:
    """A second CLIque, with its own state and tmux socket.

    Own home, own ``--state``, own ``CLIQUE_TMUX_SOCKET``. Missing any one
    of those is how a check used to attach to the pane someone was using.
    """
    shutil.rmtree(SANDBOX, ignore_errors=True)
    SANDBOX.mkdir(parents=True)
    _sandbox_catalogue()
    env = dict(os.environ, CLIQUE_HOME=str(SANDBOX), CLIQUE_TMUX_SOCKET=SOCKET)
    proc = subprocess.Popen(
        [
            sys.executable,
            "-m",
            "clique",
            "--host",
            "127.0.0.1",
            "--port",
            str(PORT),
            "--password",
            PASSWORD,
            "--state",
            str(SANDBOX / "state.json"),
        ],
        cwd=str(Path(__file__).resolve().parents[1]),
        env=env,
        stdout=subprocess.DEVNULL,
        stderr=subprocess.DEVNULL,
    )
    for _ in range(80):
        try:
            urllib.request.urlopen(BASE + "/healthz", timeout=2).read()
            return proc
        except (urllib.error.URLError, OSError):
            time.sleep(0.25)
    proc.kill()
    raise SystemExit(f"the check's own panel never came up on {PORT}")


def _run(panel) -> int:
    from playwright.sync_api import sync_playwright

    # A cookie the sandbox panel will accept, signed with its own key.
    auth = Auth(PASSWORD, SANDBOX / "secret")
    mine = _scratch_session()

    with sync_playwright() as play:
        browser = play.chromium.launch()
        context = browser.new_context(
            viewport={"width": 1400, "height": 900},
            permissions=["clipboard-read", "clipboard-write"],
        )
        # Pin the canvas renderer for the suite. What it exercises is CLIque's
        # click/copy/scroll *logic*, which is renderer-independent by design (it
        # maps from getBoundingClientRect, not the GPU). Headless Chromium's
        # software WebGL (SwiftShader) is not a real GPU: readPixels stalls and
        # context churn make interaction flaky under a full run's load. The
        # WebGL path itself is verified in isolation by tools/webgl_check.py.
        context.add_init_script("try { localStorage.setItem('clique.gpu', '0'); } catch (e) {}")
        context.add_cookies(
            [
                {
                    "name": COOKIE_NAME,
                    "value": auth.issue(),
                    "domain": "127.0.0.1",
                    "path": "/",
                }
            ]
        )
        page = context.new_page()

        problems: list[str] = []
        page.on("console", lambda m: problems.append(m.text) if m.type == "error" else None)
        page.on("pageerror", lambda e: problems.append(str(e)))

        page.goto(BASE, wait_until="networkidle")
        page.wait_for_timeout(1500)

        print("the page")
        check("it is the panel, not the login form", page.locator("#tabbar").is_visible())
        check("nothing threw on the way up", not problems, problems[:2])
        check("the sidebar drew", page.locator("#tree").is_visible())

        print("git on the row")
        row = page.locator(f'.session[data-id="{mine}"]')
        try:
            page.wait_for_function(
                """(id) => {
                  const el = document.querySelector('.session[data-id="' + id + '"]');
                  return !!(el && /visual/.test(el.innerText) && /changed/.test(el.innerText));
                }""",
                arg=mine,
                timeout=8000,
            )
        except Exception as err:  # noqa: BLE001 — the checks below name what failed
            print(f"       git row did not settle: {err}")
        shown = row.inner_text() if row.count() else ""
        check("the row names the branch", "visual" in shown, shown)
        check("and it says the folder is dirty", "changed" in shown, shown)
        page.locator("#sidebar").screenshot(path=str(SHOTS / "sidebar.png"))

        # Same mutation the rest of this file uses: poke `state`, then the
        # render the poll would have called. Cleared before we return so a
        # later check is not looking at a badge the server never sent.
        badge = page.evaluate(
            """(id) => {
              const s = (state.sessions || []).find((x) => x.id === id);
              if (!s) return {row: false, tab: false, why: "missing"};
              const prevTabs = openTabs.slice();
              const prev = s.sub_clis;
              const had = openTabs.includes(id);
              s.sub_clis = ["grok"];
              if (!had) openTabs = openTabs.concat([id]);
              renderTree();
              renderTabs();
              const vis = (sel) => {
                const el = document.querySelector(sel);
                if (!el) return false;
                const r = el.getBoundingClientRect();
                const st = getComputedStyle(el);
                return r.width > 0 && r.height > 0
                  && st.display !== "none" && st.visibility !== "hidden";
              };
              const row = vis('.session[data-id="' + id + '"] .sub-clis');
              const tab = vis('.tab[data-id="' + id + '"] .sub-clis');
              s.sub_clis = prev || [];
              openTabs = prevTabs;
              renderTree();
              renderTabs();
              return {row, tab};
            }""",
            mine,
        )
        check(
            "a descendant CLI shows on the row and the tab",
            bool(badge) and badge.get("row") and badge.get("tab"),
            badge,
        )

        print("controls are actually visible")
        # Icon-only buttons are the ones that fail silently: an icon that did
        # not load leaves a button that is present, clickable and empty.
        for sel, name in [
            ("#moreBtn", "more menu"),
            ("#settingsBtn", "settings"),
            ("#collapse", "hide sidebar"),
            ("#newTab", "new session"),
            ("#lock", "follow output"),
            ("#keysBtn", "shortcuts"),
            ("#fullScr", "full screen"),
            ("#fontMinus", "smaller type"),
            ("#fontPlus", "larger type"),
        ]:
            box = page.locator(sel).bounding_box()
            check(f"{name} has a size", bool(box) and box["width"] > 6 and box["height"] > 6, box)
            icon = page.locator(f"{sel} .ico").first
            if icon.count():
                ibox = icon.bounding_box()
                check(f"{name} drew its icon", bool(ibox) and ibox["width"] > 4, ibox)
        pause = page.locator("#lock .ico-pause")
        if pause.count():
            check("pause glyph stays hidden while following", pause.bounding_box() is None)

        keys_box = page.locator("#keysBtn").bounding_box()
        tab_box = page.locator("#tabbar").bounding_box()
        if keys_box and tab_box:
            check(
                "shortcuts sit in the bottom bar, not the tab strip",
                keys_box["y"] > tab_box["y"] + tab_box["height"] - 1,
                keys_box,
            )
        stats_box = page.locator("#stats").bounding_box()
        if stats_box and tab_box:
            check(
                "stats sit in the bottom bar, not the tab strip",
                stats_box["y"] > tab_box["y"] + tab_box["height"] - 1,
                stats_box,
            )
        font_box = page.locator("#fontSize").bounding_box()
        if font_box and keys_box:
            check(
                "font size sits in the bottom-right, past the shortcuts",
                font_box["x"] > keys_box["x"] + keys_box["width"] - 1,
                font_box,
            )
        size_label = page.locator("#fontSizeVal").inner_text()
        check(
            "the stepper shows a size",
            size_label.strip().isdigit() and 9 <= int(size_label.strip()) <= 28,
            size_label,
        )

        print("nothing is covering anything")
        rows = page.locator(f'.session[data-id="{mine}"]')
        if rows.count():
            row = rows.first
            row.click(button="right")
            page.wait_for_timeout(250)
            menu = page.locator("#menu")
            check("right-click opens our menu", menu.is_visible())
            box = menu.bounding_box()
            if box:
                # The question the layering bug answered wrongly: at a point
                # inside the menu, is the menu what the pointer would hit?
                on_top = page.evaluate(
                    "([x, y]) => { const e = document.elementFromPoint(x, y);"
                    " return !!(e && e.closest('#menu')); }",
                    [box["x"] + box["width"] / 2, box["y"] + 12],
                )
                check("and the menu is what a click would land on", on_top)
            page.keyboard.press("Escape")

        print("stats hold still")
        page.wait_for_timeout(200)
        before = page.evaluate(
            "() => document.querySelector('#stats').getBoundingClientRect().width"
        )
        page.evaluate(
            """() => {
              const mem = document.querySelector('#mem .v');
              const cpu = document.querySelector('#cpu .v');
              if (mem) mem.textContent = '0.1/8G';
              if (cpu) cpu.textContent = '100.0%';
            }"""
        )
        after = page.evaluate(
            "() => document.querySelector('#stats').getBoundingClientRect().width"
        )
        check(
            "changing a reading does not resize the stats",
            abs(before - after) < 1,
            {"before": before, "after": after},
        )

        # A stat the machine cannot report is gone, not blank. Most VMs have no
        # temperature sensor and no swap in use, and holding those columns open
        # left two permanent holes in the row.
        gaps = page.evaluate(
            """() => {
              const q = (s) => document.querySelector(s);
              const swap = q('#swap'), temp = q('#temp');
              swap.classList.add('is-off');
              temp.classList.add('is-off');
              return { off: temp.getBoundingClientRect().width + swap.getBoundingClientRect().width };
            }"""
        )
        check("a stat with no reading takes no width", gaps["off"] < 1, gaps)

        # Nothing in the bar may be cut off mid-word. Readings drop out whole
        # as the row narrows; the plan meters cost it real estate and the first
        # attempt at them left VIEWS reading "VIEW". Asked at three widths
        # because the row is the window minus the sidebar, so collapsing the
        # sidebar has to change the answer.
        OVERHANG = """() => {
          const bar = document.querySelector('#statusbar').getBoundingClientRect();
          const bad = [];
          for (const el of document.querySelectorAll('#stats > .stat')) {
            if (!el.offsetParent) continue;
            const r = el.getBoundingClientRect();
            if (r.right > bar.right + 1 || r.left < bar.left - 1) bad.push(el.id || '?');
          }
          return bad;
        }"""
        for width, label in ((1280, "at a normal window"), (900, "at a narrow one")):
            page.set_viewport_size({"width": width, "height": 860})
            page.wait_for_timeout(350)
            over = page.evaluate(OVERHANG)
            check(f"no reading hangs off the status bar {label}", over == [], over)
        page.evaluate("() => setSidebar(false)")
        page.wait_for_timeout(400)
        check(
            "nor with the sidebar collapsed", page.evaluate(OVERHANG) == [], page.evaluate(OVERHANG)
        )
        page.evaluate("() => setSidebar(true)")
        page.set_viewport_size({"width": 1280, "height": 860})
        page.wait_for_timeout(350)
        # Retired: plan usage used to also draw a compact copy in the status
        # bar; the sidebar panel now covers every CLI, so #plan is gone from
        # the page entirely rather than merely hidden.
        check(
            "the status bar carries no plan block any more",
            page.evaluate("() => document.querySelector('#plan')") is None,
        )

        # Every installed CLI's usage, in the sidebar footer. Collapsed by
        # default, and opening it is the explicit ask that fires the
        # all-installed fetch — the routine poll never sends ?all=1.
        toggle = page.locator("#usageToggle")
        check(
            "usage panel starts collapsed",
            page.evaluate("() => document.querySelector('#usageBody').hidden") is True,
        )
        all_requests = []
        page.on(
            "request",
            lambda r: (
                all_requests.append(r.url) if "api/usage" in r.url and "all=1" in r.url else None
            ),
        )
        toggle.click()
        check("opening it fires the all-installed fetch", any(all_requests), all_requests)
        check(
            "and the body un-hides",
            page.evaluate("() => document.querySelector('#usageBody').hidden") is False,
        )
        check("the toggle marks itself open", toggle.get_attribute("aria-expanded") == "true")
        # A real box may have a working probe (network round trip) or none at
        # all (the empty-state text) — either way the fetch resolving is what
        # to wait on, not a guess at how long the request takes.
        page.wait_for_function("() => document.querySelector('#usageBody').children.length > 0")
        body_text = page.locator("#usageBody").inner_text().strip()
        check(
            "once loaded, it never sits blank",
            body_text != "",
            body_text,
        )
        page.locator("#sidebar").screenshot(path=str(SHOTS / "usage-panel-open.png"))
        toggle.click()
        page.wait_for_timeout(200)
        check(
            "clicking again collapses it",
            page.evaluate("() => document.querySelector('#usageBody').hidden") is True,
        )

        shown = page.evaluate(
            """() => {
              const temp = document.querySelector('#temp');
              temp.classList.remove('is-off');
              return temp.getBoundingClientRect().width;
            }"""
        )
        check("a stat that comes back is drawn again", shown > 10, shown)

        print("the theme reached the terminal")
        sessions = page.locator(f'.session[data-id="{mine}"]')
        if sessions.count():
            sessions.first.click()
            page.wait_for_timeout(2500)
            check("a terminal is on screen", page.locator(".xterm").count() > 0)
            painted = page.evaluate(
                "() => { const s = document.querySelector('.xterm-screen, .xterm');"
                " return s ? getComputedStyle(s).backgroundColor : ''; }"
            )
            print(f"       terminal background: {painted or '(none set)'}")

            # The pane's scrollbar was the last raw browser control in the
            # panel. It has to follow the theme like everything else, and it
            # has to keep following when the theme changes.
            bar = page.evaluate(
                """() => {
                  const vp = document.querySelector('.xterm-viewport');
                  if (!vp) return null;
                  const cs = getComputedStyle(vp);
                  return {width: cs.scrollbarWidth, color: cs.scrollbarColor};
                }"""
            )
            check(
                "the pane's scrollbar is thin, not the browser default",
                bool(bar) and bar["width"] == "thin",
                bar,
            )
            check(
                "and it is coloured, not auto", bool(bar) and bar["color"] not in ("", "auto"), bar
            )
            before = bar["color"] if bar else ""
            page.evaluate(
                """async () => {
                  await fetch('/api/settings', {method:'PATCH',
                    headers:{'Content-Type':'application/json'},
                    body: JSON.stringify({theme: 'dracula'})});
                  await refresh();
                }"""
            )
            page.wait_for_timeout(600)
            after = page.evaluate(
                "() => { const v = document.querySelector('.xterm-viewport');"
                " return v ? getComputedStyle(v).scrollbarColor : ''; }"
            )
            check(
                "and it changes with the theme",
                after and after != before,
                {"before": before, "after": after},
            )
            page.screenshot(path=str(SHOTS / "terminal-dracula.png"))
            page.evaluate(
                """async () => {
                  await fetch('/api/settings', {method:'PATCH',
                    headers:{'Content-Type':'application/json'},
                    body: JSON.stringify({theme: ''})});
                  await refresh();
                }"""
            )
            page.wait_for_timeout(400)

            # The character a theme carries. Nothing else in this suite can see
            # it: it is a background image on an element with no text, sized as
            # a percentage of a pane, blended into the terminal. Every part of
            # that is invisible to a test that reasons about the code.
            print("working groups, and the band that shows them")
            # Put the strip back afterwards. This test opens tabs and makes one
            # of them active, and everything below drags across whichever pane
            # is in front: leaving a fresh empty shell there broke three copy
            # assertions that had nothing to do with groups.
            was_active = page.evaluate("() => activeId")
            # The band is the whole visual and it only reads as a band if the
            # tabs it runs under are adjacent. Scattered through the strip the
            # same colour is three unrelated pills, which is what the first
            # version of this drew.
            # Two sessions of its own: the sandbox has one, and a group of one
            # cannot show that a band runs across a run of tabs.
            made = page.evaluate(
                """async () => {
                  const post = (u, b) => fetch(u, {method:'POST',
                    headers:{'Content-Type':'application/json'},
                    body: JSON.stringify(b || {})}).then((r) => r.json());
                  const g = await post('/api/groups',
                    {name: 'Visual group', color: '#7aa2f7'});
                  const ids = [];
                  for (const n of ['group one', 'group two']) {
                    const s = await post('/api/sessions',
                      {cli: 'shell', cwd: '/tmp', name: n});
                    if (s.id) { ids.push(s.id); await post(`/api/groups/${g.id}/add`,
                      {session: s.id}); }
                  }
                  await refresh();
                  return {gid: g.id, ids};
                }"""
            )
            page.wait_for_timeout(1500)
            check(
                "the sidebar lists the group",
                page.evaluate("() => !!document.querySelector('#groups .group-row')") is True,
            )
            page.evaluate("() => document.querySelector('#groups .group-open').click()")
            page.wait_for_timeout(4000)
            band = page.evaluate(
                """() => {
                  const tabs = [...document.querySelectorAll('#tabs .tab')];
                  const at = tabs.map((t, i) => t.classList.contains('grouped') ? i : -1)
                    .filter((i) => i >= 0);
                  const starts = tabs.filter((t) => t.classList.contains('group-start')).length;
                  const ends = tabs.filter((t) => t.classList.contains('group-end')).length;
                  const one = tabs.find((t) => t.classList.contains('grouped'));
                  const after = one ? getComputedStyle(one, '::after') : null;
                  return {at, starts, ends,
                          h: after ? after.height : '', bg: after ? after.backgroundColor : '',
                          barHeight: document.querySelector('#tabbar').getBoundingClientRect().height};
                }"""
            )
            check("both members are in the group", len(band["at"]) == 2, band)
            check(
                "their tabs sit next to each other",
                len(band["at"]) == 2 and band["at"][1] == band["at"][0] + 1,
                band,
            )
            check(
                "and read as one band, not two pills",
                band["starts"] == 1 and band["ends"] == 1,
                band,
            )
            check(
                "the band is drawn in the group's colour",
                band["h"] == "3px" and "122, 162, 247" in band["bg"],
                band,
            )
            # The point of putting it inside the tab: a phone has no row to give.
            check("and costs the strip no height", band["barHeight"] <= 36, band)
            page.locator("#tabbar").screenshot(path=str(SHOTS / "group-band.png"))
            page.evaluate(
                """async (gid) => {
                  await fetch(`/api/groups/${gid}/delete`, {method:'POST'});
                  await refresh();
                }""",
                made["gid"],
            )
            page.wait_for_timeout(400)
            check(
                "deleting the group leaves the tabs alone",
                page.evaluate("() => document.querySelectorAll('#tabs .tab.grouped').length") == 0,
            )
            page.evaluate(
                """async (ids) => { for (const id of ids)
                     await fetch(`/api/sessions/${id}`, {method:'DELETE'}); }""",
                made["ids"],
            )
            page.wait_for_timeout(800)
            page.evaluate("(id) => { if (id) selectTab(id); }", was_active)
            page.wait_for_timeout(800)
            check(
                "and the pane you were on is back in front",
                page.evaluate("() => activeId") == was_active,
            )

            print("reloading an installed app")
            # A PWA has no address bar, so there is no reload in it. The button
            # exists for exactly that case and is deliberately absent from a
            # browser tab, where it would duplicate a control the browser
            # already has in a row that is already full.
            check(
                "a browser tab has no reload button",
                page.evaluate("() => document.querySelector('#reloadBtn').hidden") is True,
            )
            page.evaluate("() => openPalette()")
            page.wait_for_timeout(300)
            page.keyboard.type("reload")
            page.wait_for_timeout(400)
            offered = page.evaluate(
                """() => [...document.querySelectorAll('#palette .pal-row')]
                     .some((r) => r.textContent.includes('Reload the panel'))"""
            )
            check("but the palette offers it anywhere", offered is True)
            page.keyboard.press("Escape")
            page.wait_for_timeout(300)

            print("the CLI's logo in the corner")
            # The pane edge already carries the CLI colour. This is the same
            # question answered for someone who has not learned the colours,
            # and it has to stay faint enough that output crossing it reads.
            mark = page.evaluate(
                """() => {
                  const el = document.querySelector('#cliMark');
                  if (!el) return null;
                  const cs = getComputedStyle(el);
                  const box = el.getBoundingClientRect();
                  const pane = document.querySelector('#termwrap').getBoundingClientRect();
                  return {
                    hidden: el.hidden, opacity: parseFloat(cs.opacity),
                    w: Math.round(box.width), h: Math.round(box.height),
                    mask: cs.maskImage || cs.webkitMaskImage || '',
                    image: cs.backgroundImage || '',
                    tint: cs.backgroundColor, events: cs.pointerEvents,
                    topRight: box.right <= pane.right + 1 && box.top >= pane.top - 1,
                  };
                }"""
            )
            check("the active CLI's logo is drawn", bool(mark) and not mark["hidden"], mark)
            check(
                "it is the icon, one way or the other",
                bool(mark) and ("url(" in mark["mask"] or "url(" in mark["image"]),
                mark,
            )
            check(
                "faint enough to read output through",
                bool(mark) and 0 < mark["opacity"] <= 0.12,
                mark,
            )
            check(
                "big enough to recognise across a screen",
                bool(mark) and mark["w"] >= 60 and mark["h"] >= 60,
                mark,
            )
            check(
                "in the top-right, opposite the theme character",
                bool(mark) and mark["topRight"],
                mark,
            )
            check("and it cannot be clicked", bool(mark) and mark["events"] == "none", mark)
            # A single-colour glyph is masked and takes the CLI's own colour,
            # which is the whole reason one file can serve every mode.
            if "url(" in (mark or {}).get("mask", ""):
                check(
                    "a mono glyph is tinted, not left grey",
                    mark["tint"] not in ("rgba(0, 0, 0, 0)", "transparent"),
                    mark["tint"],
                )
            gone = page.evaluate(
                """async () => {
                  await fetch('/api/settings', {method:'PATCH',
                    headers:{'Content-Type':'application/json'},
                    body: JSON.stringify({cli_watermark: false})});
                  await refresh();
                  return document.querySelector('#cliMark').hidden;
                }"""
            )
            check("turning it off turns it off", gone is True, gone)
            page.evaluate(
                """async () => {
                  await fetch('/api/settings', {method:'PATCH',
                    headers:{'Content-Type':'application/json'},
                    body: JSON.stringify({cli_watermark: true})});
                  await refresh();
                }"""
            )
            page.wait_for_timeout(300)

            print("the theme's character in the corner")
            wide = page.viewport_size or {"width": 1440, "height": 900}
            page.evaluate(
                """async () => {
                  await fetch('/api/settings', {method:'PATCH',
                    headers:{'Content-Type':'application/json'},
                    body: JSON.stringify({theme: 'plumber', theme_art: true})});
                  await refresh();
                }"""
            )
            page.wait_for_timeout(600)
            art = page.evaluate(
                """() => {
                  const el = document.querySelector('#themeArt');
                  if (!el) return null;
                  const cs = getComputedStyle(el);
                  const box = el.getBoundingClientRect();
                  const pane = document.querySelector('#termwrap').getBoundingClientRect();
                  return {
                    display: cs.display, blend: cs.mixBlendMode,
                    opacity: parseFloat(cs.opacity), image: cs.backgroundImage.slice(0, 40),
                    events: cs.pointerEvents,
                    w: box.width, h: box.height,
                    insidePane: box.right <= pane.right + 1 && box.bottom <= pane.bottom + 1,
                  };
                }"""
            )
            check("a theme with a figure draws one", bool(art) and art["display"] != "none", art)
            check(
                "it is a picture, not an element full of text",
                bool(art) and "url(" in art["image"],
                art,
            )
            # A drawing is composited normally: the extreme blends the grid
            # form uses would eat its blacks and whites. What has to hold is
            # that it is faint enough to read straight through, which the
            # next check is.
            check(
                "it is a drawing, sized to the box rather than to a grid",
                bool(art) and art["blend"] == "normal",
                art,
            )
            check("faint enough to read through", bool(art) and 0 < art["opacity"] <= 0.14, art)
            check("and it cannot be clicked", bool(art) and art["events"] == "none", art)
            check("it stays inside the pane", bool(art) and art["insidePane"], art)
            check(
                "and it is big enough to be a character, not a speck",
                bool(art) and art["h"] > 80 and art["w"] > 60,
                art,
            )
            page.screenshot(path=str(SHOTS / "theme-art.png"))

            # The gate is the pane's width, not the window's. A window query
            # would be wrong by exactly the width of the sidebar, which is the
            # mistake the status bar's readings already made once.
            page.set_viewport_size({"width": 700, "height": wide["height"]})
            page.wait_for_timeout(500)
            narrow = page.evaluate(
                "() => getComputedStyle(document.querySelector('#themeArt')).display"
            )
            check("a narrow pane takes it away", narrow == "none", narrow)
            page.set_viewport_size(wide)
            page.wait_for_timeout(500)
            back = page.evaluate(
                "() => getComputedStyle(document.querySelector('#themeArt')).display"
            )
            check("and gives it back with the room", back != "none", back)

            off = page.evaluate(
                """async () => {
                  await fetch('/api/settings', {method:'PATCH',
                    headers:{'Content-Type':'application/json'},
                    body: JSON.stringify({theme_art: false})});
                  await refresh();
                  const el = document.querySelector('#themeArt');
                  return {hidden: el.hidden,
                          shown: getComputedStyle(el).display !== 'none'};
                }"""
            )
            check("turning it off turns it off", bool(off) and off["hidden"], off)

            plain = page.evaluate(
                """async () => {
                  await fetch('/api/settings', {method:'PATCH',
                    headers:{'Content-Type':'application/json'},
                    body: JSON.stringify({theme: '', theme_art: true})});
                  await refresh();
                  return document.querySelector('#themeArt').hidden;
                }"""
            )
            check("a theme with no figure draws nothing", plain is True, plain)
            page.wait_for_timeout(300)

        print("the session menu, actually clicked")
        # Being defined is not the assertion; the menu working is. Two of these
        # items threw a ReferenceError for twelve releases and the suite stayed
        # green, because a throw inside a click handler goes to a console
        # nobody has open.
        errors = []
        page.on("pageerror", lambda e: errors.append(str(e)))
        row = page.locator(f'.session[data-id="{mine}"]').first
        if row.count():
            row.click(button="right")
            page.wait_for_timeout(350)
            labels = page.evaluate(
                "() => [...document.querySelectorAll('#menu button')].map((b) => b.textContent)"
            )
            check("right-click opens the session menu", len(labels) > 4, labels)
            check(
                "and it offers another CLI when one is installed",
                any("another CLI" in x for x in labels),
                labels,
            )

            def click_item(needle):
                return page.evaluate(
                    """(needle) => {
                      const b = [...document.querySelectorAll('#menu button')]
                        .find((x) => x.textContent.includes(needle));
                      if (!b) return false;
                      b.click();
                      return true;
                    }""",
                    needle,
                )

            # Opening the submenu is the part that used to throw.
            check("Move to folder opens the folder list", click_item("Move to folder"))
            page.wait_for_timeout(350)
            folders = page.evaluate(
                "() => [...document.querySelectorAll('#menu button')].map((b) => b.textContent)"
            )
            check("and the list has folders in it", len(folders) > 0, folders)
            page.keyboard.press("Escape")
            page.wait_for_timeout(200)

            row.click(button="right")
            page.wait_for_timeout(300)
            check("Open in another CLI lists the others", click_item("another CLI"))
            page.wait_for_timeout(350)
            clis = page.evaluate(
                "() => [...document.querySelectorAll('#menu button')].map((b) => b.textContent)"
            )
            check("and names at least one", len(clis) > 0, clis)
            page.keyboard.press("Escape")
            page.wait_for_timeout(200)
        check("no menu item threw", not errors, errors)

        print("finding a project by name in the new-session dialog")
        # The unit tests prove the walk. This proves the box people actually
        # type into is wired to it: a bare name is not a path, and before this
        # the field answered nothing at all for one.
        page.evaluate("openModal()")
        page.wait_for_timeout(400)
        page.fill("#newForm [name=cwd]", "clique")
        # The search walks the disk, and on a busy box that outlasts any fixed
        # wait. Wait for its answer; a timeout falls through to the checks.
        try:
            page.wait_for_function(
                """() => [...document.querySelectorAll('#cwdList option')]
                         .some((o) => o.textContent.includes(' · '))""",
                timeout=8000,
            )
        except Exception as err:  # noqa: BLE001 - the checks below name what failed
            print(f"       the project search did not answer: {err}")
        listed = page.evaluate(
            """() => [...document.querySelectorAll('#cwdList option')]
                     .map((o) => ({value: o.value, label: o.textContent}))"""
        )
        check("typing a name fills the suggestions", len(listed) > 0, listed)
        check(
            "and one of them is this repo",
            any(o["value"].rstrip("/").endswith("/clique") for o in listed),
            listed,
        )
        check(
            "the value is a path it can launch in",
            all(o["value"].startswith("/") for o in listed),
            listed,
        )
        check(
            "and a found project shows its name beside the path",
            any(" · " in (o["label"] or "") for o in listed),
            listed,
        )
        page.fill("#newForm [name=cwd]", "")
        page.wait_for_timeout(200)
        page.evaluate("document.querySelector('#modal').hidden = true")
        page.wait_for_timeout(300)

        print("the mode pill does not sit on the pane")
        # A CLI that draws its own prompt hides ours and leaves the pill, the
        # paperclip and the lock. Reported 2026-09-18 as the pill covering the
        # bottom line of the terminal. Forced here rather than waited for,
        # because it needs a CLI with modes and its own input.
        before = page.evaluate(
            """() => {
              const bar = document.querySelector("#inputbar");
              const wrap = document.querySelector("#termwrap");
              if (!bar || !wrap) return null;
              const pill = document.querySelector("#modePill");
              pill.hidden = false;
              pill.textContent = "auto mode on (shift+tab to cycle)";
              for (const sel of ["#prompt", ".stepper", ".runsplit"]) {
                const el = document.querySelector(sel);
                if (el) el.hidden = true;
              }
              bar.hidden = false;
              bar.classList.add("pill-only");
              return true;
            }"""
        )
        if before:
            page.wait_for_timeout(700)
            lap = page.evaluate(
                """() => {
                  const bar = document.querySelector("#inputbar").getBoundingClientRect();
                  const wrap = document.querySelector("#termwrap").getBoundingClientRect();
                  // The pane for the session being looked at, not the first in
                  // the DOM: panes for other sessions stay mounted and hidden.
                  let scr = null, rows = 0, cell = 0;
                  for (const s of document.querySelectorAll(".xterm-screen")) {
                    const r = s.getBoundingClientRect();
                    if (r.height > 0 && r.width > 0) { scr = r; break; }
                  }
                  const rowsEl = document.querySelector(".xterm-rows");
                  if (rowsEl) {
                    rows = rowsEl.children.length;
                    if (rows) cell = rowsEl.children[0].getBoundingClientRect().height;
                  }
                  return {
                    barTop: Math.round(bar.top), wrapBottom: Math.round(wrap.bottom),
                    screenBottom: scr ? Math.round(scr.bottom) : null,
                    rows: rows, cell: Math.round(cell * 100) / 100,
                    found: document.querySelectorAll(".xterm-screen").length,
                  };
                }"""
            )
            # Two different failures, so two checks. The bar sitting on the
            # wrapper is a layout overlap; the rows outrunning the wrapper is a
            # stale fit, and the second is invisible in a screenshot.
            check(
                "the bar starts below the pane, not on top of it",
                lap["barTop"] >= lap["wrapBottom"] - 1,
                lap,
            )
            check(
                "and the rows the terminal drew fit inside the pane",
                lap["screenBottom"] is not None and lap["screenBottom"] <= lap["wrapBottom"] + 1,
                lap,
            )
            page.locator("#inputbar").screenshot(path=str(SHOTS / "pill-only-bar.png"))
            page.evaluate(
                """() => {
                  const bar = document.querySelector("#inputbar");
                  bar.classList.remove("pill-only");
                  document.querySelector("#modePill").hidden = true;
                  for (const sel of ["#prompt", ".stepper", ".runsplit"]) {
                    const el = document.querySelector(sel);
                    if (el) el.hidden = false;
                  }
                }"""
            )

        print("a bar snippet: add through Settings, tint, confirm before removing")
        # `mine` is a plain shell, which clis.toml gives no quick_commands of
        # its own — so the row showing at all here, with nothing but the "+",
        # is itself proof a bar snippet does not depend on the CLI having any.
        before_count = page.locator("#quickRow .quick-cmd").count()
        check("starts with no bar snippets", before_count == 0, before_count)

        page.click(".quick-add")
        page.wait_for_timeout(200)
        check(
            "the + opens the same settings sheet snippets already use",
            page.locator("#settings").is_visible()
            and page.locator('.pane[data-pane="snippets"]:not([hidden])').count() > 0,
        )
        page.click("#addSnippet")
        row = page.locator("#snippetRows .snippet-row").last
        row.locator(".snip-label").fill("Smoke test")
        row.locator(".snip-label").dispatch_event("change")
        row.locator("textarea").fill("/smoke-test")
        row.locator("textarea").dispatch_event("change")
        row.locator(".snip-bar input").check()
        page.wait_for_timeout(300)
        page.locator("#snippetRows").screenshot(path=str(SHOTS / "snippet-bar-toggle.png"))
        page.click("#settingsDone")
        page.wait_for_function(
            "() => document.querySelectorAll('#quickRow .quick-cmd').length === 1"
        )

        pills = page.locator("#quickRow .quick-cmd")
        check("the new bar snippet shows as a pill", pills.count() == 1, pills.count())
        pill_text = pills.first.locator("button").first.inner_text()
        check("using its label, not the full sent text", pill_text == "Smoke test", pill_text)
        pill_class = pills.first.get_attribute("class") or ""
        check(
            "and it is tinted differently from a built-in",
            "quick-cmd-custom" in pill_class,
            pill_class,
        )
        page.locator("#quickRow").screenshot(path=str(SHOTS / "quick-row-bar-pill.png"))

        print("removing a bar snippet needs a real confirmation, not a bare click")
        page.click("#quickRow .quick-cmd-del")
        page.wait_for_timeout(200)
        check(
            "the click alone does not remove it",
            page.locator("#quickRow .quick-cmd").count() == 1,
        )
        check(
            "it opens the same confirm sheet every destructive action uses",
            page.locator("#confirmSheet").is_visible(),
        )
        page.click("#confirmNo")
        page.wait_for_timeout(200)
        check("cancelling leaves it on the bar", page.locator("#quickRow .quick-cmd").count() == 1)

        page.click("#quickRow .quick-cmd-del")
        page.wait_for_timeout(200)
        page.click("#confirmOk")
        page.wait_for_function(
            "() => document.querySelectorAll('#quickRow .quick-cmd').length === 0"
        )
        check("confirming takes it off the bar", page.locator("#quickRow .quick-cmd").count() == 0)

        page.click("#settingsBtn")
        page.click('#setTabs button[data-pane="snippets"]')
        page.wait_for_timeout(200)
        rows_left = page.locator("#snippetRows .snippet-row").count()
        check(
            "the snippet itself survives, only its bar flag came off",
            rows_left == 1,
            rows_left,
        )
        page.click("#snippetRows .snippet-row .danger")  # clean up after ourselves
        page.wait_for_timeout(200)
        page.click("#settingsDone")
        page.wait_for_timeout(200)

        print("a link with no https:// in front of it")
        # node runs these out of the file. This runs the shipped file as the
        # browser actually loaded it, which is what catches a scope or syntax
        # slip that the region extractor would never notice. The pane itself
        # is left alone: later checks read it.
        linked = page.evaluate(
            """() => {
              if (typeof paneBareLinks !== "function") return { err: "not defined" };
              const parts = [
                { y: 1, text: "grab it at fdroid.usecliq" },
                { y: 2, text: "ue.dev/repo now" },
              ];
              return {
                first: paneBareLinks(parts, 1).map((h) => h.url),
                second: paneBareLinks(parts, 2).map((h) => h.url),
                asPath: panePathLinks(parts, 1).length + panePathLinks(parts, 2).length,
              };
            }"""
        )
        want = ["https://fdroid.useclique.dev/repo"]
        check(
            "the browser turns a bare host into a link",
            isinstance(linked, dict)
            and linked.get("first") == want
            and linked.get("second") == want,
            linked,
        )
        check(
            "and does not also claim it as a file path",
            isinstance(linked, dict) and linked.get("asPath") == 0,
            linked,
        )

        print("copy from the pane")
        # A shell has no mouse tracking, so a drag is a selection. The chip
        # and the clipboard are how we know it actually landed.
        term = page.locator("#terminal .xterm").first
        box = term.bounding_box() if term.count() else None
        check("the pane has a size to drag across", bool(box) and box["width"] > 80, box)
        if box:
            page.mouse.click(box["x"] + 24, box["y"] + 24)
            page.wait_for_timeout(150)
            page.mouse.move(box["x"] + 12, box["y"] + 10)
            page.mouse.down()
            page.mouse.move(
                box["x"] + min(box["width"] - 12, 420),
                box["y"] + min(box["height"] - 12, 36),
                steps=10,
            )
            page.mouse.up()
            page.wait_for_timeout(400)
            check("a drag shows the copy button", page.locator("#copySel").is_visible())
            try:
                taken = page.evaluate("() => navigator.clipboard.readText()")
            except Exception as err:  # noqa: BLE001 — the check names the failure
                taken = str(err)
            check(
                "and the selection is on the clipboard",
                isinstance(taken, str) and len(taken.strip()) > 0,
                (taken[:80] if isinstance(taken, str) else taken),
            )
            # Same pane, with the CLI listening for clicks — the case that
            # used to swallow the drag so there was nothing to copy.
            _api(
                "/api/sessions/" + mine + "/send",
                "POST",
                {"text": "printf '\\033[?1000h\\033[?1002h\\033[?1006h'", "enter": True},
            )
            page.wait_for_timeout(500)
            tracking = page.evaluate("() => !!document.querySelector('.xterm.enable-mouse-events')")
            check("the pane is now eating mouse events", tracking)
            page.mouse.click(box["x"] + 8, box["y"] + box["height"] / 2)
            page.wait_for_timeout(150)
            page.mouse.move(box["x"] + 12, box["y"] + 10)
            page.mouse.down()
            page.mouse.move(
                box["x"] + min(box["width"] - 12, 420),
                box["y"] + min(box["height"] - 12, 36),
                steps=10,
            )
            page.mouse.up()
            page.wait_for_timeout(400)
            check(
                "a drag still selects while the CLI is eating clicks",
                page.locator("#copySel").is_visible(),
            )
            # A release with no click after it (the pointer left the window)
            # used to leave a document-wide catcher armed that ate the next
            # click anywhere. Synthetic events, because real input always
            # sends the click.
            freed = page.evaluate("""() => new Promise((done) => {
              const el = document.querySelector('#terminal .xterm.enable-mouse-events');
              const r = el.getBoundingClientRect();
              const at = (x, y, extra) => ({ bubbles: true, cancelable: true,
                clientX: x, clientY: y, button: 0, ...extra });
              el.dispatchEvent(new MouseEvent('mousedown', at(r.left + 12, r.top + 10, { buttons: 1, detail: 1 })));
              document.dispatchEvent(new MouseEvent('mousemove', at(r.left + 300, r.top + 30, { buttons: 1 })));
              document.dispatchEvent(new MouseEvent('mouseup', at(r.left + 300, r.top + 30)));
              setTimeout(() => {
                let heard = false;
                const probe = () => { heard = true; };
                document.addEventListener('click', probe);
                document.body.dispatchEvent(new MouseEvent('click', { bubbles: true }));
                document.removeEventListener('click', probe);
                done(heard);
              }, 50);
            })""")
            check("a drag that ends with no click does not eat the next one", freed)

        print("copy from a boxed CLI")
        # The CLIs people actually copy from turn mouse tracking on. The
        # panel hides that from the browser, a drag is a native selection,
        # and a click is still delivered as SGR.
        boxed = _api(
            "/api/sessions", "POST", {"cli": "boxed", "cwd": "/tmp", "name": "boxed copy"}
        )["id"]
        page.wait_for_timeout(400)
        # Pull the session list before opening it. Half of what the panel does
        # with a pane it reads off the record rather than the terminal --
        # `own_input` is what decides whether a click is forwarded to the CLI at
        # all -- and a session made through the API is not in `state.sessions`
        # until a poll brings it in. The UI refreshes on create; a test that
        # skips it is testing a panel that does not know what it is looking at,
        # and this one silently was from 0.62.0 until 2026-09-02.
        page.evaluate("async (id) => { await refresh(); await openSession(id); }", boxed)
        try:
            page.wait_for_function(
                """() => {
                  const e = terms.get(activeId);
                  if (!e || !e.term) return false;
                  const line = e.term.buffer.active.getLine(e.term.buffer.active.viewportY);
                  const text = line ? line.translateToString(true) : '';
                  return text.includes('BOXED-COPY-LINE');
                }""",
                timeout=8000,
            )
        except Exception as err:  # noqa: BLE001 — the checks below name what failed
            print(f"       boxed pane did not draw: {err}")
        # The *active* pane, not the first one in the DOM. Panes for other
        # sessions stay mounted, so `.first` is whichever was created earliest
        # and clicking its box aims at a session nobody is looking at.
        boxed_box = page.evaluate(
            """() => {
              const e = terms.get(activeId);
              const el = e && e.term && e.term.element;
              if (!el) return null;
              const b = el.getBoundingClientRect();
              return { x: b.x, y: b.y, width: b.width, height: b.height };
            }"""
        )
        tracking = page.evaluate(
            """() => {
              const e = terms.get(activeId);
              return !!(e && e.term && e.term.element &&
                e.term.element.classList.contains('enable-mouse-events'));
            }"""
        )
        check("a boxed CLI does not put the browser in mouse mode", not tracking)
        pane_text = page.evaluate(
            """() => {
              const e = terms.get(activeId);
              if (!e || !e.term) return '';
              const buf = e.term.buffer.active;
              const lines = [];
              for (let i = 0; i < e.term.rows; i++) {
                const line = buf.getLine(buf.viewportY + i);
                lines.push(line ? line.translateToString(true) : '');
              }
              return lines.join('\\n');
            }"""
        )
        check("the boxed stand-in actually drew", "BOXED-COPY-LINE" in pane_text, pane_text[:120])
        if boxed_box:
            page.mouse.move(boxed_box["x"] + 12, boxed_box["y"] + 10)
            page.mouse.down()
            page.mouse.move(
                boxed_box["x"] + min(boxed_box["width"] - 12, 360), boxed_box["y"] + 18, steps=8
            )
            page.mouse.up()
            # The wait is the check: a late repaint can clear the selection a
            # moment after the button shows, and a second is_visible() then
            # reads that later state instead of what the drag did.
            try:
                page.locator("#copySel").wait_for(state="visible", timeout=3000)
                shown = True
            except Exception as err:  # noqa: BLE001 - the check below names the failure
                print(f"       no copy button after the drag: {err}")
                shown = False
            check("a drag on a boxed pane shows the copy button", shown)
            try:
                taken = page.evaluate("() => navigator.clipboard.readText()")
            except Exception as err:  # noqa: BLE001 — the check names the failure
                taken = str(err)
            check(
                "and copied the boxed line",
                isinstance(taken, str) and "COPY-LINE" in taken.upper(),
                (taken[:80] if isinstance(taken, str) else taken),
            )
            page.screenshot(path=str(SHOTS / "boxed-copy.png"))
            page.evaluate(
                "() => { const e = terms.get(activeId); if (e && e.term) e.term.clearSelection(); }"
            )
            page.wait_for_timeout(150)
            page.mouse.click(boxed_box["x"] + 40, boxed_box["y"] + 12)
            try:
                page.wait_for_function(
                    """() => {
                      const e = terms.get(activeId);
                      if (!e || !e.term) return false;
                      const buf = e.term.buffer.active;
                      for (let i = 0; i < Math.min(8, e.term.rows); i++) {
                        const line = buf.getLine(buf.viewportY + i);
                        const text = line ? line.translateToString(true) : '';
                        if (/clicked/i.test(text)) return true;
                      }
                      return false;
                    }""",
                    timeout=6000,
                )
                clicked_ok = True
                after_click = "clicked"
            except Exception as err:  # noqa: BLE001 — the check names what failed
                clicked_ok = False
                after_click = str(err)
            check("a click still reaches the boxed CLI", clicked_ok, after_click)
            print("zooming a boxed pane instead of wrapping it")
            before_cols = page.evaluate(
                "() => { const e = terms.get(activeId); return e && e.term ? e.term.cols : 0; }"
            )
            page.set_viewport_size({"width": 1200, "height": 720})
            page.wait_for_timeout(700)
            # Force the race that failed 2 runs in 5 (#22): a poll's settings
            # repaint landing after the resize must keep the zoom, not refit.
            page.evaluate(
                "() => { for (const e of terms.values()) e.painted = ''; applySettings(); }"
            )
            page.wait_for_timeout(200)
            zoomed = page.evaluate(
                """() => {
                  const e = terms.get(activeId);
                  if (!e || !e.term || !e.term.element) return {};
                  const buf = e.term.buffer.active;
                  const line = buf.getLine(buf.viewportY);
                  return {
                    cols: e.term.cols,
                    owns: sessionOwnsInput(activeId),
                    transform: e.term.element.style.transform || "",
                    text: line ? line.translateToString(true) : ""
                  };
                }"""
            )
            check(
                "the grid did not get narrower",
                isinstance(zoomed, dict) and zoomed.get("cols") == before_cols,
                (zoomed, before_cols),
            )
            check(
                "the pane zoomed to fit instead",
                isinstance(zoomed, dict) and "scale(" in (zoomed.get("transform") or ""),
                zoomed,
            )
            check(
                "and the boxed line did not wrap",
                isinstance(zoomed, dict) and "BOXED-COPY-LINE" in (zoomed.get("text") or ""),
                zoomed,
            )
            page.screenshot(path=str(SHOTS / "boxed-zoom.png"))
            page.set_viewport_size({"width": 1400, "height": 900})
            page.wait_for_timeout(400)
        _remove_session(boxed)
        page.evaluate("(id) => openSession(id)", mine)
        page.wait_for_timeout(400)

        print("tabs that do not fit")
        names = [
            "Duchamp Events Dev",
            "CLIque Code Review",
            "Whatbox IPTV Dev",
            "WSG Platform Gen",
            "Sentinel Dev",
            "Duchamp Room Rates",
            "Meridian Nightly",
            "Daily Deck Writer",
            "Prowler Scan",
            "Inbox Agent",
            "Dealophant Shop",
        ]
        extra: list[str] = []
        try:
            for name in names:
                extra.append(
                    _api("/api/sessions", "POST", {"cli": "shell", "cwd": "/tmp", "name": name})[
                        "id"
                    ]
                )
            page.wait_for_timeout(3500)
            page.evaluate(
                """(ids) => { openTabs = ids; activeId = ids[0]; renderTabs(); }""", extra
            )
            page.wait_for_timeout(250)
            page.locator("#tabbar").screenshot(path=str(SHOTS / "tab-overflow.png"))
            clipped = page.evaluate(
                """() => {
                  const bar = document.querySelector("#tabbar");
                  const edge = bar.getBoundingClientRect().right;
                  return [...document.querySelectorAll("#tabs .tab")]
                    .filter((t) => !t.hidden)
                    .filter((t) => t.getBoundingClientRect().right > edge + 1)
                    .map((t) => t.textContent.trim());
                }"""
            )
            check("no visible tab is clipped by the bar", clipped == [], clipped)
            more = page.locator("#tabOverflow")
            check("the overflow control is on screen", more.is_visible())
            more_box = more.bounding_box()
            bar_box = page.locator("#tabbar").bounding_box()
            if more_box and bar_box:
                check(
                    "and it sits inside the bar",
                    more_box["x"] + more_box["width"] <= bar_box["x"] + bar_box["width"] + 1,
                    more_box,
                )
            more.click()
            page.wait_for_timeout(200)
            check("clicking it lists the rest", page.locator("#menu .tab-more-item").count() > 0)
            page.keyboard.press("Escape")
            contrast = page.evaluate(
                """() => {
                  const active = document.querySelector(".tab.active");
                  const other = document.querySelector(".tab:not(.active)");
                  if (!active || !other) return { ok: false, why: "need two tabs" };
                  const a = getComputedStyle(active);
                  const b = getComputedStyle(other);
                  const al = getComputedStyle(active.querySelector(".label"));
                  const bl = getComputedStyle(other.querySelector(".label"));
                  return {
                    ok: true,
                    activeBg: a.backgroundColor,
                    otherBg: b.backgroundColor,
                    activeWeight: Number(al.fontWeight),
                    otherWeight: Number(bl.fontWeight),
                    activeColor: a.color,
                    otherColor: b.color,
                  };
                }"""
            )
            check(
                "active tab is heavier than its neighbours",
                contrast.get("ok")
                and contrast["activeWeight"] >= 600
                and contrast["activeWeight"] > contrast["otherWeight"],
                contrast,
            )
            check(
                "active tab is not the same colour as an idle one",
                contrast.get("ok") and contrast["activeColor"] != contrast["otherColor"],
                contrast,
            )
        finally:
            for sid in extra:
                _remove_session(sid)

        print("a path you can look at")
        work = SANDBOX / "work"
        sample = work / "clique-visual-file.md"
        sample.write_text(
            "# Hello from a click\n\n"
            "Not an editor.\n\n"
            "## A heading\n\n"
            "| col | other |\n"
            "| --- | ---: |\n"
            "| a | b |\n"
            "| c | d |\n\n"
            "```js\n"
            "# not a heading\n"
            "```\n\n"
            "[safe](https://example.com) and [XSS](javascript:alert(1))\n",
            encoding="utf-8",
        )
        (work / "sub").mkdir(exist_ok=True)
        (work / "sub" / "inside.md").write_text("nested file\n", encoding="utf-8")
        shutil.copy(
            Path(__file__).resolve().parents[1] / "clique" / "web" / "brand" / "icon-64.png",
            work / "shot.png",
        )
        page.evaluate("([id, p]) => openFileSheet(id, p)", [mine, str(sample)])
        page.wait_for_function(
            """() => {
              const el = document.getElementById('fileDoc');
              return el && !el.hidden && (el.innerText || '').includes('Hello from a click');
            }""",
            timeout=8000,
        )
        check("the file sheet opens", page.locator("#file").is_visible())
        shown = page.locator("#fileDoc").inner_text()
        check("and it shows the text", "Hello from a click" in shown, shown[:80])
        check(
            "javascript: label is plain text",
            "XSS" in shown and "XSS)" not in shown,
            shown[shown.find("safe") :][:40] if "safe" in shown else shown[:80],
        )
        md = page.evaluate(
            """() => {
              const doc = document.getElementById('fileDoc');
              const text = document.getElementById('fileText');
              const src = document.getElementById('fileSource');
              const hrefs = [...doc.querySelectorAll('a')].map(
                (a) => a.getAttribute('href') || ''
              );
              return {
                h2: doc.querySelectorAll('h2').length,
                tableRows: doc.querySelectorAll('table tr').length,
                pre: doc.querySelectorAll('pre').length,
                jsLinks: hrefs.filter((h) => /javascript:/i.test(h)).length,
                sourceLabel: src ? src.textContent : '',
                sourceHidden: !src || src.hidden,
                docHidden: doc.hidden,
                textHidden: text.hidden,
              };
            }"""
        )
        # A rendered document is the one thing here that an assertion cannot
        # judge: headings can exist and still be unreadable, and a table can
        # have rows and still be a mess. Keep the picture.
        page.locator("#file").screenshot(path=str(SHOTS / "file-markdown.png"))
        check("rendered markdown has an h2", md.get("h2", 0) >= 1, md)
        check("a table has rows", md.get("tableRows", 0) >= 2, md)
        check("a fenced block is a pre", md.get("pre", 0) >= 1, md)
        check("javascript: is not an <a>", md.get("jsLinks", 1) == 0, md)
        check(
            "Source is offered on a markdown file",
            not md.get("sourceHidden") and md.get("sourceLabel") == "Source",
            md,
        )
        page.locator("#fileSource").click()
        page.wait_for_timeout(150)
        after_src = page.evaluate(
            """() => {
              const doc = document.getElementById('fileDoc');
              const text = document.getElementById('fileText');
              const src = document.getElementById('fileSource');
              return {
                docHidden: doc.hidden,
                textHidden: text.hidden,
                label: src.textContent,
                raw: text.innerText || '',
              };
            }"""
        )
        check(
            "Source shows the raw pre",
            after_src.get("docHidden")
            and not after_src.get("textHidden")
            and after_src.get("label") == "Rendered"
            and "javascript:alert(1)" in after_src.get("raw", ""),
            after_src,
        )
        page.locator("#fileSource").click()
        page.wait_for_timeout(150)
        back = page.evaluate(
            """() => ({
              docHidden: document.getElementById('fileDoc').hidden,
              textHidden: document.getElementById('fileText').hidden,
              label: document.getElementById('fileSource').textContent,
            })"""
        )
        check(
            "Rendered puts the document back",
            not back.get("docHidden") and back.get("textHidden") and back.get("label") == "Source",
            back,
        )
        page.locator("#file").screenshot(path=str(SHOTS / "file-sheet.png"))

        page.evaluate("([id, p]) => openFileSheet(id, p)", [mine, str(work / "shot.png")])
        page.wait_for_function(
            """() => {
              const el = document.getElementById('fileImg');
              return el && !el.hidden && el.complete && el.naturalWidth > 0;
            }""",
            timeout=8000,
        )
        check("an image opens in the sheet", page.locator("#fileImg").is_visible())
        page.locator("#file").screenshot(path=str(SHOTS / "file-image.png"))

        page.evaluate("([id, p]) => openFileSheet(id, p)", [mine, str(work)])
        page.wait_for_function(
            "() => document.querySelectorAll('#fileList button').length > 0",
            timeout=8000,
        )
        listed = page.locator("#fileList button").all_inner_texts()
        check(
            "a directory lists its files",
            any("clique-visual-file.md" in t for t in listed),
            listed[:8],
        )
        page.locator("#file").screenshot(path=str(SHOTS / "file-dir.png"))
        page.locator("#fileList button").filter(has_text="clique-visual-file.md").first.click()
        page.wait_for_function(
            """() => {
              const el = document.getElementById('fileDoc');
              return el && !el.hidden && (el.innerText || '').includes('Hello from a click');
            }""",
            timeout=8000,
        )
        check(
            "clicking a listing opens the file",
            "Hello from a click" in page.locator("#fileDoc").inner_text(),
        )

        page.keyboard.press("Escape")
        page.wait_for_timeout(200)
        check("escape closes it", page.locator("#file").is_hidden())

        page.screenshot(path=str(SHOTS / "panel.png"), full_page=False)

        print("what's new")
        check(
            "the mark is off until there is something to read",
            page.locator("#whatsNew").is_hidden(),
        )
        # On the server, not just state.settings: a poll landing in between
        # would put the real value back and the mark would go out again.
        page.evaluate(
            """async () => {
              await fetch('/api/settings', {method:'PATCH',
                headers:{'Content-Type':'application/json'},
                body: JSON.stringify({changelog_seen: '0.1.0'})});
              await refresh();
            }"""
        )
        try:
            page.wait_for_selector("#whatsNew", state="visible", timeout=3000)
        except Exception as err:  # noqa: BLE001 — the check below names what failed
            print(f"       the mark never lit: {err}")
        check("an unread release lights the bottom bar", page.locator("#whatsNew").is_visible())
        page.locator("#whatsNew").click()
        page.wait_for_timeout(800)
        check(
            "clicking it opens the notes",
            page.locator("#settings").is_visible()
            and page.locator('.pane[data-pane="changelog"]:not([hidden])').count() > 0,
        )
        check(
            "and the mark goes out once they have been opened",
            page.locator("#whatsNew").is_hidden(),
        )
        articles = page.locator("#changelog .clog-entry")
        try:
            page.wait_for_selector("#changelog .clog-entry", timeout=4000)
        except Exception as err:  # noqa: BLE001 — the checks below name what failed
            print(f"       changelog did not draw: {err}")
        check("the sheet holds the last few releases", articles.count() == 5, articles.count())
        more = page.locator("a.clog-more")
        href = more.get_attribute("href") if more.count() else ""
        check(
            "and the rest is a link to the file on GitHub",
            more.is_visible() and "CHANGELOG.md" in (href or ""),
            href,
        )
        page.screenshot(path=str(SHOTS / "changelog.png"))
        page.screenshot(path=str(SHOTS / "settings.png"))
        check("settings opens", page.locator("#settings").is_visible())

        print("which app is holding this page")
        footer = page.locator("#version")
        check(
            "a browser draws no shell version",
            page.locator("#version .version-shell").count() == 0,
            footer.inner_text(),
        )
        # The desktop client sets this before any page script runs. Setting it
        # here and re-rendering is the same path, without needing Windows.
        page.evaluate(
            """() => {
              window.cliqueShell = { kind: "desktop", version: "0.3.12" };
              renderVersion();
            }"""
        )
        page.wait_for_timeout(150)
        shell_text = footer.inner_text()
        check(
            "the desktop client's own version is drawn beside the panel's",
            "desktop 0.3.12" in shell_text and "v" in shell_text,
            shell_text,
        )
        check(
            "and the panel version is still the button that opens the changelog",
            page.locator("#version .version-link").count() == 1,
            shell_text,
        )
        page.locator("#version").screenshot(path=str(SHOTS / "version-shell.png"))

        # The desktop update badge, drawn beside the version it updates —
        # same path the desktop shell drives through window.cliqueUpdateBadge.
        # Read the class in the same call: a poll can repaint the footer
        # before a later read, and only the first paint carries "arrive".
        arrived = page.evaluate(
            """() => {
              window.cliqueUpdateBadge = { version: "0.76.1", state: "ready" };
              renderVersion();
              return document.querySelector("#version .version-update").className;
            }"""
        )
        page.wait_for_timeout(150)
        update_btn = page.locator("#version .version-update")
        check("the update badge draws beside the version", update_btn.count() == 1)
        check(
            "it names the version that is ready",
            "0.76.1" in (update_btn.get_attribute("title") or ""),
        )
        check(
            "its label says which version, on the control itself",
            (update_btn.inner_text() or "").strip() == "Update to 0.76.1",
            update_btn.inner_text(),
        )
        check(
            "it arrives with motion the first time",
            "arrive" in arrived,
        )
        page.evaluate("() => renderVersion()")
        page.wait_for_timeout(50)
        check(
            "a repaint does not replay the motion, it holds a steady highlight",
            "arrive" not in (page.locator("#version .version-update").get_attribute("class") or "")
            and page.locator("#version .version-update").evaluate(
                "b => getComputedStyle(b).backgroundColor"
            )
            not in ("rgba(0, 0, 0, 0)", "transparent"),
        )
        page.locator("#version").screenshot(path=str(SHOTS / "version-update-ready.png"))

        page.evaluate(
            "() => { window.cliqueUpdateBadge.state = 'failed'; window.cliqueUpdateBadge.message = 'network error'; renderVersion(); }"
        )
        page.wait_for_timeout(150)
        check(
            "a failed update is tinted differently, not just relabelled",
            "failed" in (page.locator("#version .version-update").get_attribute("class") or ""),
        )
        page.locator("#version").screenshot(path=str(SHOTS / "version-update-failed.png"))

        page.evaluate(
            "() => { delete window.cliqueUpdateBadge; delete window.cliqueShell; renderVersion(); }"
        )
        page.wait_for_timeout(150)

        print("sign-in QR")
        page.click('#setTabs button[data-pane="api"]')
        page.wait_for_timeout(300)
        page.click("#pairStart")
        page.wait_for_timeout(400)
        loopback_note = page.locator("#pairQrNote")
        check(
            "a loopback panel tells you to set the public URL",
            loopback_note.is_visible() and "public URL" in (loopback_note.inner_text() or ""),
            loopback_note.inner_text() if loopback_note.count() else "",
        )
        loopback_qr = page.locator("#pairQr")
        check(
            "and does not draw a localhost QR",
            not loopback_qr.is_visible(),
        )
        if page.locator("#pairCancel").count():
            page.click("#pairCancel")
            page.wait_for_timeout(200)
        page.evaluate(
            """async () => {
              await fetch('/api/settings', {method:'PATCH',
                headers:{'Content-Type':'application/json'},
                body: JSON.stringify({panel_url: 'https://clique.example.ts.net'})});
              await refresh();
            }"""
        )
        page.wait_for_timeout(200)
        page.click("#pairStart")
        page.wait_for_timeout(500)
        pixels = page.evaluate(
            """() => {
              const c = document.querySelector('#pairQr');
              if (!c || c.hidden || !c.width) {
                return {dark: 0, modules: 0, w: 0, h: 0};
              }
              const ctx = c.getContext('2d');
              const {data} = ctx.getImageData(0, 0, c.width, c.height);
              let dark = 0;
              for (let i = 0; i < data.length; i += 4) {
                if (data[i] < 40 && data[i + 1] < 40 && data[i + 2] < 40 && data[i + 3] > 200) {
                  dark++;
                }
              }
              return {
                dark,
                modules: Number(c.dataset.modules || 0),
                w: c.width,
                h: c.height,
              };
            }"""
        )
        check("the QR canvas is drawn", pixels["dark"] > 0, pixels)
        check(
            "and the module count is a real QR",
            21 <= pixels["modules"] <= 77,
            pixels,
        )
        shown = page.locator(".pair-code").inner_text().strip()
        page.locator("#pairBox").screenshot(path=str(SHOTS / "pair-qr.png"))
        # The only assertion that proves the thing is scannable rather than
        # merely drawn, and the only one that does not go back through the
        # encoder that drew it. zbarimg is a system package, so it is used
        # when it is there and skipped, out loud, when it is not.
        if shutil.which("zbarimg"):
            decoded = subprocess.run(
                ["zbarimg", "--quiet", "--raw", str(SHOTS / "pair-qr.png")],
                capture_output=True,
                text=True,
            ).stdout.strip()
            check(
                "a real decoder reads the panel's URL and the code off the screen",
                decoded == f"https://clique.example.ts.net/?pair={shown}",
                decoded,
            )
        else:
            print("  skip zbarimg is not installed, so the QR was not decoded")
        if page.locator("#pairCancel").count():
            page.click("#pairCancel")
            page.wait_for_timeout(200)

        # The theme maker lives at the bottom of a scrolling pane, which is
        # exactly where the About tab once disappeared. Being in the DOM is
        # not the assertion; being on screen is. Run here, with the sheet
        # already open, so it cannot disturb anything that follows.
        # Earlier tests left the sheet on another pane, and the panes are
        # hidden rather than scrolled past, so ask for Appearance by name.
        page.click('#setTabs button[data-pane="appearance"]')
        page.wait_for_timeout(300)
        page.evaluate(
            "() => { const p = document.querySelector('#settings .panes');"
            " if (p) p.scrollTop = p.scrollHeight; }"
        )
        page.wait_for_timeout(300)
        for sel, name in (
            ("#themePrompt", "the description box"),
            ("#themeGen", "the generate button"),
            ("#themeGenNote", "the note saying what it needs"),
        ):
            node = page.locator(sel)
            box = node.bounding_box() if node.count() else None
            check(
                f"{name} is on screen in Appearance",
                bool(box) and box["width"] > 20 and box["height"] > 8,
                box,
            )
        check(
            "the button is off until a provider is set up",
            page.evaluate("() => document.querySelector('#themeGen').disabled") is True,
        )
        # A picked stack can miss its first face and the browser draws a later
        # one. The line under the menu has to name whichever family resolved
        # on this machine, so the name itself is not asserted.
        resolved = (page.locator("#fontResolved").text_content() or "").strip()
        check(
            "the typeface line names the family that resolved",
            resolved.startswith("Using: ") and bool(resolved[len("Using: ") :].strip()),
            resolved,
        )

        # Which themes come with a character, said in the picker itself. A
        # marker glyph would have needed a legend; a group says it in words.
        # Worth asserting because it is easy to break by adding a theme and
        # not thinking about which group it lands in.
        picker = page.evaluate(
            """() => {
              const sel = document.querySelector('#setTheme');
              if (!sel) return null;
              const groups = [...sel.querySelectorAll('optgroup')].map((g) => ({
                label: g.label,
                items: [...g.querySelectorAll('option')].map((o) => o.value),
              }));
              return {
                groups,
                loose: [...sel.children].filter((c) => c.tagName === 'OPTION').length,
                total: sel.querySelectorAll('option').length,
              };
            }"""
        )
        check("the theme picker is grouped", bool(picker) and len(picker["groups"]) >= 2, picker)
        check("and no theme escapes a group", bool(picker) and picker["loose"] == 0, picker)
        named = {g["label"]: g["items"] for g in (picker or {}).get("groups", [])}
        drawn = named.get("With a character", [])
        check(
            "the seven with a figure are grouped as such",
            sorted(drawn)
            == sorted(
                ["aincrad", "bricks", "chompy", "drizzt", "fellowship", "plumber", "triforce"]
            ),
            drawn,
        )
        check(
            "and the plain presets are not in with them",
            "dracula" in named.get("Presets", []),
            named.get("Presets"),
        )
        check(
            "every theme is still reachable",
            bool(picker)
            and picker["total"]
            == page.evaluate("() => Object.keys(window.CLIQUE_THEMES || {}).length"),
            picker,
        )

        # The rotation: the same list again as checkboxes, because a phone
        # cannot ctrl-click a multi-select. What matters visually is that the
        # two lists agree, that ticking one actually stores it, and that the
        # schedule fields grey out with the switch rather than sitting there
        # live and doing nothing.
        print("rotating through the themes you like")
        rot = page.evaluate(
            """() => {
              const pool = document.querySelector('#themeRotatePool');
              if (!pool) return null;
              const boxes = [...pool.querySelectorAll('input[type=checkbox]')];
              const rows = [...pool.querySelectorAll('label')];
              return {
                count: boxes.length,
                ids: boxes.map((b) => b.dataset.theme),
                groups: [...pool.querySelectorAll('.rotate-head')].map((h) => h.textContent),
                shortest: Math.min(...rows.map((r) => Math.round(
                  r.getBoundingClientRect().height))),
                hoursOff: document.querySelector('#setThemeRotateHours').disabled,
                atOff: document.querySelector('#setThemeRotateAt').disabled,
              };
            }"""
        )
        every = page.evaluate("() => Object.keys(window.CLIQUE_THEMES || {}).length")
        check("every theme can be put in the rotation", bool(rot) and rot["count"] == every, rot)
        check(
            "grouped the same way the picker is",
            bool(rot) and "With a character" in rot["groups"],
            rot,
        )
        check("a thumb can hit a row", bool(rot) and rot["shortest"] >= 44, rot)
        check(
            "the schedule is greyed out until it is switched on",
            bool(rot) and rot["hoursOff"] and rot["atOff"],
            rot,
        )
        stored = page.evaluate(
            """async () => {
              document.querySelector('#setThemeRotate').click();
              await new Promise((r) => setTimeout(r, 400));
              await refresh();
              // Looked up again rather than held across the save: writing a
              // setting re-renders the pool, and a box captured before that is
              // a detached node nothing is listening to.
              const box = document.querySelector('#themeRotatePool input[type=checkbox]');
              box.click();
              await new Promise((r) => setTimeout(r, 400));
              await refresh();
              return {
                pool: state.settings.theme_rotate_pool,
                on: state.settings.theme_rotate,
                live: !document.querySelector('#setThemeRotateAt').disabled,
                wanted: box.dataset.theme,
              };
            }"""
        )
        check("switching it on ungreys the schedule", stored.get("live") is True, stored)
        check(
            "and ticking a theme puts it in the pool",
            stored.get("pool") == [stored.get("wanted")] and stored.get("on") is True,
            stored,
        )
        page.screenshot(path=str(SHOTS / "theme-rotation.png"))

        page.screenshot(path=str(SHOTS / "theme-maker.png"))

        # A notification's link is the panel with ?session=<id>. Opening it has
        # to put that session in front and drop the parameter, or a reload
        # would keep dragging you back to it (CLQ-58).
        linked = _api("/api/sessions", "POST", {"cli": "shell", "cwd": "/tmp", "name": "linked"})[
            "id"
        ]
        try:
            page.goto(BASE + "/?session=" + linked, wait_until="networkidle")
            try:
                page.wait_for_function("(id) => activeId === id", arg=linked, timeout=8000)
            except Exception as err:  # noqa: BLE001 - the check below names what failed
                print("the linked session never came to the front:", err)
            check(
                "a notification link opens its session", page.evaluate("() => activeId") == linked
            )
            check("the link's ?session= is dropped after", "session=" not in page.url, page.url)
        finally:
            _remove_session(linked)

        # Phone build, first pass: a narrow viewport, reloaded so the mobile
        # bootstrap runs (drawer starts closed), then the drawer opened over the
        # pane. Not asserted yet — screenshots to iterate the layout on.
        page.set_viewport_size({"width": 390, "height": 844})
        page.goto(BASE, wait_until="networkidle")
        page.wait_for_timeout(600)
        # Open the session so activeId is set — the key row shows with a pane in front.
        page.evaluate(
            "typeof state !== 'undefined' && state.sessions[0] && openSession(state.sessions[0].id)"
        )
        page.wait_for_timeout(900)
        page.screenshot(path=str(SHOTS / "mobile-closed.png"))
        # A thumb needs about 32px. Every visible control in the main pane is
        # measured, so a new 22px button on the phone layout fails here.
        tiny = page.evaluate(
            """() => [...document.querySelectorAll('button, [role=button]')]
                 .filter(b => b.checkVisibility({ visibilityProperty: true }) && (() => {
                   const r = b.getBoundingClientRect();
                   return r.right > 0 && r.left < innerWidth && r.bottom > 0 && r.top < innerHeight; })())
                 .map(b => { const r = b.getBoundingClientRect();
                   return [b.id || b.className || b.textContent.trim(), Math.round(r.width), Math.round(r.height)]; })
                 .filter(([, w, h]) => w && h && (w < 32 || h < 32))"""
        )
        check("every phone control is big enough for a thumb", not tiny, tiny)

        print("scrolling the pane with a finger")
        # Three separate things had to be true for this to work and none of
        # them was, so it is worth pinning all three. tmux has to keep history
        # (it kept none while the CLI held the alternate screen), the browser
        # has to be showing the buffer that history is in (it was showing the
        # alternate one), and something has to turn a drag into a scroll (the
        # pane only ever listened for a wheel). A phone has no wheel.
        page.evaluate(
            """() => { const e = [...terms.values()][0];
                 for (let i = 0; i < 400; i++) e.term.write('scrollback ' + i + '\\r\\n'); }"""
        )
        page.wait_for_timeout(900)
        state = page.evaluate(
            """() => { const t = [...terms.values()][0].term;
                 const host = document.querySelector('#terminal > div[data-session]');
                 return {kind: t.buffer.active.type, back: t.buffer.active.baseY,
                         touch: host ? getComputedStyle(host).touchAction : ''}; }"""
        )
        check("the pane shows the buffer that holds history", state["kind"] == "normal", state)
        check("and there is history in it", state["back"] > 20, state)
        check(
            "the pane claims the gesture rather than the browser", state["touch"] == "none", state
        )

        cdp = context.new_cdp_session(page)
        cdp.send("Emulation.setTouchEmulationEnabled", {"enabled": True, "maxTouchPoints": 1})
        box = page.evaluate(
            """() => { const r = document.querySelector('#terminal').getBoundingClientRect();
                 return {x: Math.round(r.x + r.width / 2),
                         y: Math.round(r.y + r.height / 2)}; }"""
        )

        def finger(dy: int) -> None:
            cdp.send("Input.dispatchTouchEvent", {"type": "touchStart", "touchPoints": [box]})
            for i in range(1, 21):
                cdp.send(
                    "Input.dispatchTouchEvent",
                    {
                        "type": "touchMove",
                        "touchPoints": [{"x": box["x"], "y": box["y"] + dy * i // 20}],
                    },
                )
            cdp.send("Input.dispatchTouchEvent", {"type": "touchEnd", "touchPoints": []})
            page.wait_for_timeout(500)

        where = lambda: page.evaluate(  # noqa: E731 - a probe, not a design
            "() => [...terms.values()][0].term.buffer.active.viewportY"
        )
        page.evaluate("() => [...terms.values()][0].term.scrollToBottom()")
        page.wait_for_timeout(300)
        bottom = where()
        finger(300)
        back = where()
        check(
            "dragging down goes back through the scrollback",
            back < bottom,
            {"from": bottom, "to": back},
        )
        finger(-300)
        check("and dragging up comes forward again", where() > back, {"from": back, "to": where()})
        # Typing on a phone, which is a different question from typing on a
        # desktop and was got wrong twice. Touch emulation is on from the
        # scroll test above, so `(pointer: coarse)` matches and this is the
        # real code path rather than a stub.
        print("typing on a phone goes to the box, not the terminal")
        # Its own, because the earlier boxed sessions are gone by now and the
        # only thing left is a shell, which draws no box of its own and so
        # would have had the panel's one anyway. The whole question here is
        # what happens to a CLI that *does* draw one.
        phone_boxed = _api(
            "/api/sessions", "POST", {"cli": "boxed", "cwd": "/tmp", "name": "phone typing"}
        )["id"]
        page.wait_for_timeout(600)
        typing = page.evaluate(
            """async (id) => {
              await refresh();
              const boxed = state.sessions.find((s) => s.id === id && s.own_input);
              if (!boxed) return { skipped: true };
              await openSession(boxed.id);
              await new Promise((r) => setTimeout(r, 700));
              const active = () => document.activeElement;
              const inPane = (el) => !!(el && el.classList
                && el.classList.contains('xterm-helper-textarea'));
              const before = inPane(active());
              const key = document.querySelector('#keyrow [data-key]');
              if (key) key.click();
              await new Promise((r) => setTimeout(r, 300));
              return {
                coarse: matchMedia('(pointer: coarse)').matches,
                ownInput: true,
                boxShown: !document.querySelector('#inputbar').hidden,
                paneTookFocusOnOpen: before,
                paneTookFocusFromKeyRow: inPane(active()),
              };
            }""",
            phone_boxed,
        )
        if typing.get("skipped"):
            print("       the boxed stand-in did not arrive; nothing to test with")
        else:
            check(
                "a phone gets the panel's box even for a CLI with its own",
                typing["coarse"] and typing["boxShown"],
                typing,
            )
            # The half of 0.66.0 that was missing: the box was drawn and the
            # pane was handed the keyboard anyway, so Gboard went on typing
            # into the terminal and went on duplicating the line.
            check(
                "opening a session does not hand the keyboard to the pane",
                typing["paneTookFocusOnOpen"] is False,
                typing,
            )
            check(
                "and a key-row tap does not take it back",
                typing["paneTookFocusFromKeyRow"] is False,
                typing,
            )

        # Everything a phone can only do by long-pressing, and the menu it
        # long-presses into. All three were reported by a second model on
        # 2026-09-02 and confirmed against the code before being believed.
        print("what a phone can actually reach")
        page.evaluate("setSidebar(true)")
        page.wait_for_timeout(500)
        menu = page.evaluate(
            """async () => {
              const row = document.querySelector('#tree .session');
              if (!row) return { skipped: true };
              row.dispatchEvent(new MouseEvent('contextmenu', {
                bubbles: true, cancelable: true, clientX: 40, clientY: 300 }));
              await new Promise((r) => setTimeout(r, 250));
              const el = document.querySelector('#menu');
              const box = el.getBoundingClientRect();
              const cs = getComputedStyle(el);
              return {
                rows: el.querySelectorAll('button').length,
                top: Math.round(box.top),
                bottom: Math.round(box.bottom),
                windowH: innerHeight,
                scrolls: cs.overflowY === 'auto' || cs.overflowY === 'scroll',
                reachable: el.scrollHeight > el.clientHeight
                  ? el.scrollHeight > 0 : true,
              };
            }"""
        )
        if not menu.get("skipped"):
            # It ran off both ends at once: the clamp goes negative when the
            # menu is taller than the window, hiding Open above the screen
            # while Kill sat below it, with nothing to scroll.
            check("the long-press menu starts on screen", menu["top"] >= 0, menu)
            check("and ends on screen", menu["bottom"] <= menu["windowH"] + 1, menu)
            check(
                "scrolling to the rest of it is possible",
                menu["scrolls"] and menu["reachable"],
                menu,
            )
        page.evaluate("() => { document.querySelector('#menu').hidden = true; }")

        # Its own group rather than the one the desktop pass made: that one may
        # or may not have survived to here, and a check that quietly skips
        # itself is a check that stops being run.
        page.evaluate(
            """async () => {
              if (document.querySelector('#groups .group-row')) return;
              await fetch('/api/groups', {method: 'POST',
                headers: {'Content-Type': 'application/json'},
                body: JSON.stringify({name: 'Phone group', color: '#7aa2f7'})});
              await refresh();
            }"""
        )
        page.wait_for_timeout(500)
        groups_touch = page.evaluate(
            """async () => {
              const row = document.querySelector('#groups .group-row');
              if (!row) return { skipped: true };
              const at = row.getBoundingClientRect();
              const point = { clientX: at.x + 20, clientY: at.y + 10 };
              row.dispatchEvent(new TouchEvent('touchstart', {
                bubbles: true, cancelable: true,
                touches: [new Touch({ identifier: 1, target: row, ...point })] }));
              await new Promise((r) => setTimeout(r, 800));
              const open = !document.querySelector('#menu').hidden;
              const items = [...document.querySelectorAll('#menu button')]
                .map((b) => b.textContent);
              document.querySelector('#menu').hidden = true;
              return { open, items };
            }"""
        )
        if groups_touch.get("skipped"):
            print("       no group in the sidebar to press")
        else:
            # Rename and delete were right-click only, so from a phone you
            # could launch a working group and never change or remove one.
            check("a working group answers a long press", groups_touch["open"], groups_touch)
            check(
                "and offers more than the Open button already does",
                any("ename" in t for t in groups_touch["items"]),
                groups_touch,
            )

        reachable = page.evaluate(
            """async () => {
              const attach = document.querySelector('#attachFile');
              const term = document.querySelector('#terminal');
              const at = term.getBoundingClientRect();
              term.dispatchEvent(new MouseEvent('contextmenu', {
                bubbles: true, cancelable: true,
                clientX: Math.round(at.x + 30), clientY: Math.round(at.y + 30) }));
              await new Promise((r) => setTimeout(r, 250));
              const items = [...document.querySelectorAll('#menu button')]
                .map((b) => b.textContent);
              document.querySelector('#menu').hidden = true;
              return {
                attachShown: !!attach && attach.offsetParent !== null,
                picker: !!document.querySelector('#filePick'),
                paneItems: items,
              };
            }"""
        )
        check(
            "a phone can hand a session a file",
            reachable["attachShown"] and reachable["picker"],
            reachable,
        )
        # A finger drag is our scroll, so nothing left makes an xterm
        # selection and the Copy chip never appears. Without these you cannot
        # get an error message off the screen at all.
        check(
            "and can copy output off the pane",
            any("Copy" in t for t in reachable["paneItems"]),
            reachable,
        )

        # The narrowest phone anyone still has. The attach button is one more
        # control in a bar that was already tight, and a row that overflows
        # here puts Run off the edge of the screen.
        page.set_viewport_size({"width": 320, "height": 568})
        page.wait_for_timeout(500)
        narrow = page.evaluate(
            """() => {
              const bar = document.querySelector('#inputbar');
              const run = document.querySelector('#run');
              const clip = run.getBoundingClientRect();
              return {
                over: bar.scrollWidth - bar.clientWidth,
                promptW: Math.round(
                  document.querySelector('#prompt').getBoundingClientRect().width),
                runRight: Math.round(clip.right),
                width: innerWidth,
              };
            }"""
        )
        check("the input bar fits a 320px phone", narrow["over"] <= 0, narrow)
        # It measured 26px before the bar was allowed to wrap: every control
        # around it refuses to shrink and the textarea was the only thing
        # giving. Narrower than one word, on the box a phone types prompts in.
        check("and the prompt box is a box", narrow["promptW"] >= 180, narrow)
        check("and Run is still on the screen", narrow["runRight"] <= narrow["width"] + 1, narrow)
        page.set_viewport_size({"width": 390, "height": 844})

        page.wait_for_timeout(300)
        page.screenshot(path=str(SHOTS / "mobile-drawer.png"))

        # Installed, with the panel unreachable. In a standalone window there
        # is no URL bar and no reload button, so without a fallback the app is
        # a blank screen that looks exactly like a broken app. Justin hit this
        # on 2026-09-17 with his phone off the tailnet and had no way to tell
        # the difference from the outside.
        print("\nthe installed app with nothing to talk to")
        ready = page.evaluate(
            """async () => {
              if (!navigator.serviceWorker) return "no service worker support";
              const reg = await navigator.serviceWorker.ready;
              return reg.active ? reg.active.state : "never activated";
            }"""
        )
        check("the service worker is running", ready == "activated", ready)
        context.set_offline(True)
        try:
            page.goto(BASE, wait_until="domcontentloaded")
            body = page.inner_text("body")
            check("it says the panel could not be reached", "Cannot reach" in body, body[:80])
            check("and offers a way back in", page.locator("button").count() >= 1)
            page.screenshot(path=str(SHOTS / "offline.png"))
        finally:
            context.set_offline(False)
        page.goto(BASE, wait_until="networkidle")
        check("and the panel returns once it can be reached", page.locator("#tabbar").is_visible())

        # Local echo (CLQ-64). The link is made slow by holding every keystroke
        # back 600ms, so the only way a letter can be on screen early is the
        # guess. Then the two places it must never guess: a password prompt,
        # and with the setting off.
        print("\na typed letter shows before the server echoes it")
        echo_id = _api("/api/sessions", "POST", {"cli": "shell", "cwd": "/tmp", "name": "echo"})[
            "id"
        ]
        page.wait_for_timeout(600)
        echo = page.evaluate(
            """async (id) => {
              const wait = (ms) => new Promise((r) => setTimeout(r, ms));
              await refresh();
              await openSession(id);
              await wait(1200);
              const entry = terms.get(id);
              // A late replay or repaint would drop the guess; type once the pane is quiet.
              let last = Date.now();
              const quiet = entry.term.onWriteParsed(() => { last = Date.now(); });
              while (Date.now() - last < 500) await wait(100);
              quiet.dispose();
              const real = entry.ws.send.bind(entry.ws);
              entry.ws.send = (b) => setTimeout(() => real(b), 600);
              const shown = () => (entry.echoEl && entry.echoEl.textContent) || "";
              const row = () => { const b = entry.term.buffer.active;
                return b.getLine(b.baseY + b.cursorY).translateToString(true); };
              const out = {};
              for (const ch of "echo hi") entry.term.input(ch);   // keystrokes, not a paste
              await wait(100);
              out.early = shown();
              out.rowEarly = row();
              await wait(1200);
              out.after = shown();
              out.rowAfter = row();
              entry.term.input("\\r");
              await wait(900);
              entry.term.input("read -s -p 'Password: ' x\\r");
              await wait(1500);
              for (const ch of "abc") entry.term.input(ch);
              await wait(100);
              out.secret = shown();
              entry.term.input("\\r");
              await wait(900);
              await saveSettings({ local_echo: false });
              for (const ch of "xyz") entry.term.input(ch);
              await wait(100);
              out.off = shown();
              await saveSettings({ local_echo: true });
              return out;
            }""",
            echo_id,
        )
        check("the letters are drawn before the echo", echo["early"] == "echo hi", echo)
        check(
            "while the terminal itself has not got them yet",
            "echo hi" not in echo["rowEarly"],
            echo,
        )
        check(
            "and the guess goes once the real ones land",
            echo["after"] == "" and "echo hi" in echo["rowAfter"],
            echo,
        )
        check("nothing is guessed on a password line", echo["secret"] == "", echo)
        check("nor with the setting off", echo["off"] == "", echo)
        _api(f"/api/sessions/{echo_id}", "DELETE")

        # A real phone, not a narrow desktop window: only a touch context
        # matches `pointer: coarse`, which is where the enlarged tap targets
        # live. Those on the right rail reached 13px past the screen edge,
        # and the whole page could be dragged sideways (CLQ-62). The window
        # above never saw it because a mouse gets the small targets.
        print("\na phone cannot drag the page sideways")
        phone = browser.new_context(**play.devices["Pixel 7"])
        phone.add_cookies(
            [{"name": COOKIE_NAME, "value": auth.issue(), "domain": "127.0.0.1", "path": "/"}]
        )
        tap = phone.new_page()
        tap.goto(BASE, wait_until="networkidle")
        tap.wait_for_timeout(500)
        widths = tap.evaluate(
            "() => ({view: screen.width, page: document.documentElement.scrollWidth,"
            " coarse: matchMedia('(pointer: coarse)').matches})"
        )
        check("it is a touch screen", widths["coarse"], widths)
        # Against the screen, not innerWidth: a phone widens its layout
        # viewport to fit an overflowing page, so innerWidth grows with it.
        check("and the page is no wider than the screen", widths["page"] <= widths["view"], widths)
        phone.close()

        browser.close()

    print(f"\nscreenshots in {SHOTS}")
    print(f"{passed} passed, {failed} failed")
    return 1 if failed else 0


if __name__ == "__main__":
    raise SystemExit(main())