Ctrl K
ComunidadGuías…

tools/redraw_check.py

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

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env python3
"""Verify the pane settles to the right size after a layout change.

The bug this guards is old and kept coming back in new clothes: opening the
side panel, the sidebar or zen mode changes the terminal's width, and if the
refit runs before the browser has finished laying that out it measures the
*old* box. The pane comes back scaled wrong, or the right size locally while
tmux is still painting the other one, and the mismatch shows as dead space,
a stray scrollbar, or a screen of dots.

It used to be papered over with `setTimeout(settle, 80)`. This asserts the
thing the timeout was guessing at, so the guess is not needed:

- one toggle produces exactly one settle, on a frame, not a timer;
- after it, the terminal's columns match the tmux window's, both ways round;
- `refresh` reaches tmux and repaints only the browser that asked.

    ~/.cache/clique-visual/bin/python tools/redraw_check.py

Its own home, port and tmux socket. 0 on pass, 1 on fail.
"""

from __future__ import annotations

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

ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from clique import tmux as tmux_mod
from clique.auth import COOKIE_NAME, Auth

PASSWORD = "redraw-check"  # noqa: S105 — a throwaway panel on loopback
PORT = 3303
BASE = f"http://127.0.0.1:{PORT}"
HOME = Path("/tmp/clique-redraw-check-home")
SOCKET = "clique-redraw-check"
WORK = Path("/tmp/clique-redraw-check-work")

# Count the settles instead of trusting a screenshot. One toggle is one
# settle: the old pair fired twice, once too early to be right.
COUNT_SETTLES = """() => {
  window.__settles = 0;
  const real = window.settlePane;
  window.settlePane = function () { window.__settles += 1; return real.apply(this, arguments); };
}"""


def _catalogue() -> None:
    """The packaged CLIs plus a boxed stand-in.

    Claude, Grok and Gemini draw their own prompt box, and that is the shape
    the pane zoom exists for. This is that shape without opening a paid CLI
    inside a check.
    """
    body = (ROOT / "clique" / "config" / "clis.toml").read_text(encoding="utf-8")
    body += (
        "\n[cli.boxed]\n"
        'label      = "Boxed"\n'
        f"command    = {sys.executable!r}\n"
        f"args       = [{str(ROOT / 'tools' / 'fake_boxed_cli.py')!r}]\n"
        'color      = "#6b7280"\n'
        "own_input  = true\n"
    )
    (HOME / "clis.toml").write_text(body, encoding="utf-8")


def _panel() -> subprocess.Popen:
    shutil.rmtree(HOME, ignore_errors=True)
    HOME.mkdir(parents=True)
    _catalogue()
    env = dict(os.environ, CLIQUE_HOME=str(HOME), CLIQUE_TMUX_SOCKET=SOCKET)
    subprocess.run(
        [sys.executable, "-m", "clique", "password"],
        input=f"{PASSWORD}\n{PASSWORD}\n",
        text=True,
        env=env,
        cwd=str(ROOT),
        capture_output=True,
    )
    proc = subprocess.Popen(
        [
            sys.executable,
            "-m",
            "clique",
            "--host",
            "127.0.0.1",
            "--port",
            str(PORT),
            "--state",
            str(HOME / "state.json"),
        ],
        env=env,
        cwd=str(ROOT),
        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)
    raise SystemExit(f"the check's own panel never came up on {PORT}")


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

    shutil.rmtree(WORK, ignore_errors=True)
    WORK.mkdir(parents=True)
    Path("/tmp/clique-redraw").mkdir(exist_ok=True)

    proc = _panel()
    res: dict[str, object] = {}
    try:
        auth = Auth(PASSWORD, HOME / "secret")
        with sync_playwright() as play:
            browser = play.chromium.launch()
            ctx = browser.new_context(viewport={"width": 1280, "height": 860})
            ctx.add_cookies(
                [{"name": COOKIE_NAME, "value": auth.issue(), "domain": "127.0.0.1", "path": "/"}]
            )
            page = ctx.new_page()
            errs: list[str] = []
            page.on("pageerror", lambda e: errs.append(str(e)))
            page.goto(BASE, wait_until="domcontentloaded")
            page.wait_for_timeout(700)

            sid = page.evaluate(
                """async (cwd) => {
                  const r = await fetch('/api/sessions', {method:'POST',
                    headers:{'Content-Type':'application/json'},
                    body: JSON.stringify({cli:'shell', cwd, name:'redraw'})});
                  const id = (await r.json()).id;
                  await refresh(); await openSession(id); return id;
                }""",
                str(WORK),
            )
            page.wait_for_timeout(1500)
            # The tmux session name, which is not the CLIque id and is not
            # sent to the browser. One session was made here, so this is it.
            mux = next(
                s.mux
                for s in tmux_mod.list_sessions(SOCKET, prefix=tmux_mod.PREFIX)
                if not s.mux.startswith(tmux_mod.VIEW_PREFIX)
            )

            def cols() -> int:
                return page.evaluate(
                    "(id) => { const e = terms.get(id); return e && e.term ? e.term.cols : 0; }",
                    sid,
                )

            def rows() -> int:
                return page.evaluate(
                    "(id) => { const e = terms.get(id); return e && e.term ? e.term.rows : 0; }",
                    sid,
                )

            def tmux_rows() -> int:
                out = tmux_mod._run(
                    ["list-windows", "-t", f"={mux}", "-F", "#{window_height}"], SOCKET
                )
                return int(out.split()[0]) if out.split() else 0

            def tmux_cols() -> int:
                # list-windows, not display-message: the latter wants a client
                # and there is none on the session itself, only on the view.
                out = tmux_mod._run(
                    ["list-windows", "-t", f"={mux}", "-F", "#{window_width}"], SOCKET
                )
                return int(out.split()[0]) if out.split() else 0

            wide, tall = cols(), rows()
            res["terminal_opened"] = wide > 40 and tall > 10 and bool(mux)
            res["tmux_matches_at_rest"] = (tmux_cols(), tmux_rows()) == (wide, tall)

            # Taller rows and wider letters fit fewer of each, and tmux has to
            # be told: a pane that draws 30 rows while tmux paints 40 is the
            # row-too-many bug from 0.67.12 under another name.
            base_cols, base_rows = wide, tall
            page.evaluate("() => saveSettings({ term_line_height: 150, term_letter_spacing: 2 })")
            page.wait_for_timeout(600)
            spaced_cols, spaced_rows = cols(), rows()
            res["line_height_fits_fewer_rows"] = 0 < spaced_rows < base_rows
            res["letter_spacing_fits_fewer_cols"] = 0 < spaced_cols < base_cols
            res["tmux_matches_spaced_type"] = (tmux_cols(), tmux_rows()) == (spaced_cols, spaced_rows)
            page.screenshot(path="/tmp/clique-redraw/spaced-type.png")
            page.evaluate("() => saveSettings({ term_line_height: 100, term_letter_spacing: 0 })")
            page.wait_for_timeout(600)
            res["type_reset_restores_size"] = (cols(), rows()) == (base_cols, base_rows)

            # One toggle, one settle — and the columns it lands on are the ones
            # tmux is painting, not the ones the pane had before the panel.
            page.evaluate(COUNT_SETTLES)
            page.evaluate("() => openPanel('notes')")
            page.wait_for_timeout(250)
            narrow, narrow_rows = cols(), rows()
            res["panel_narrows_pane"] = narrow < wide
            res["tmux_matches_with_panel"] = (tmux_cols(), tmux_rows()) == (narrow, narrow_rows)
            # The pane keeps its full height when the panel takes width off it.
            res["panel_keeps_pane_height"] = narrow_rows == tall
            page.screenshot(path="/tmp/clique-redraw/panel-open.png")
            res["one_settle_per_toggle"] = page.evaluate("() => window.__settles") == 1

            # A settle is a frame, not a timer. Two frames is well inside the
            # 80ms this replaces, so a short wait has to be enough on its own.
            page.evaluate("() => { window.__settles = 0; closePanel(); }")
            page.wait_for_timeout(60)
            res["settles_within_two_frames"] = cols() == wide and tmux_cols() == wide

            # Toggling twice in a frame collapses to one settle rather than
            # queueing a second that fits against a box already gone.
            page.evaluate("() => { window.__settles = 0; openPanel('notes'); closePanel(); }")
            page.wait_for_timeout(250)
            res["rapid_toggle_settles_once"] = page.evaluate("() => window.__settles") == 1
            res["rapid_toggle_ends_wide"] = cols() == wide and tmux_cols() == wide

            # A boxed CLI keeps its columns and zooms the picture instead of
            # refitting, because narrowing the grid is what stacks its prompt.
            # Zooming to fit the width shrinks the cells, so the pane has to
            # take the rows that frees up or it sits in the top of its box with
            # a dead band underneath — the bug this whole section exists for.
            bid = page.evaluate(
                """async (cwd) => {
                  const r = await fetch('/api/sessions', {method:'POST',
                    headers:{'Content-Type':'application/json'},
                    body: JSON.stringify({cli:'boxed', cwd, name:'boxed'})});
                  const id = (await r.json()).id;
                  await refresh(); await openSession(id); return id;
                }""",
                str(WORK),
            )
            page.wait_for_timeout(1800)
            res["boxed_is_boxed"] = page.evaluate("(id) => sessionOwnsInput(id)", bid)

            def boxed_fill() -> float:
                """How much of the pane's height the terminal actually covers."""
                return page.evaluate(
                    """(id) => {
                      const e = terms.get(id);
                      if (!e || !e.term || !e.term.element) return 0;
                      const painted = e.term.element.getBoundingClientRect().height;
                      return e.el.clientHeight ? painted / e.el.clientHeight : 0;
                    }""",
                    bid,
                )

            res["boxed_fills_height_at_rest"] = boxed_fill() > 0.9

            # The label that explains an odd-looking pane. Quiet when the pane
            # is at its own fit, which is almost always.
            def size_note() -> str:
                return page.evaluate(
                    "() => { const e = document.querySelector('#sessionline .sl-size');"
                    " return e ? e.textContent : ''; }"
                )

            res["no_size_note_at_rest"] = size_note() == ""
            page.evaluate("() => openPanel('notes')")
            page.wait_for_timeout(400)
            zoomed = page.evaluate(
                """(id) => {
                  const e = terms.get(id);
                  return (e.term.element.style.transform || '').includes('scale');
                }""",
                bid,
            )
            res["boxed_zooms_for_panel"] = zoomed
            note = size_note()
            res["size_note_explains_the_zoom"] = "scaled to fit" in note and "x" in note
            if not res["size_note_explains_the_zoom"]:
                print(f"       size note read {note!r}")
            fill = boxed_fill()
            res["boxed_fills_height_with_panel"] = fill > 0.9
            spill = page.evaluate(
                """(id) => {
                  const e = terms.get(id);
                  const term = e.term.element.getBoundingClientRect();
                  const box = e.el.getBoundingClientRect();
                  return {right: term.right - box.right, bottom: term.bottom - box.bottom};
                }""",
                bid,
            )
            res["zoomed_pane_stays_in_its_box"] = spill["right"] <= 1 and spill["bottom"] <= 1
            # xterm lays its viewport out from getBoundingClientRect, which
            # reports scaled pixels. Resizing under a transform sized the
            # viewport to the shrunken measurement while the screen kept its
            # real width, and the scrollbar ended up stranded in the middle of
            # the pane with terminal text on both sides of it.
            edges = page.evaluate(
                """(id) => {
                  const el = terms.get(id).term.element;
                  const vp = el.querySelector('.xterm-viewport');
                  const sc = el.querySelector('.xterm-screen');
                  if (!vp || !sc) return null;
                  return Math.round(sc.getBoundingClientRect().right
                                    - vp.getBoundingClientRect().right);
                }""",
                bid,
            )
            res["scrollbar_sits_at_the_pane_edge"] = edges is not None and abs(edges) <= 2
            if not res["scrollbar_sits_at_the_pane_edge"]:
                print(f"       screen overhangs the scrollbar by {edges}px")
            # The question the overlap answered wrongly: just inside the
            # panel's left edge, is the panel what the pointer would hit?
            res["panel_is_not_covered_by_the_pane"] = page.evaluate(
                """() => {
                  const panel = document.querySelector('#panelBody')
                    || document.querySelector('#sidepanel');
                  if (!panel) return false;
                  const r = panel.getBoundingClientRect();
                  const hits = [8, r.height / 2, r.height - 8].map((dy) => {
                    const el = document.elementFromPoint(r.left + 6, r.top + dy);
                    return Boolean(el && !el.closest('#termwrap'));
                  });
                  return hits.every(Boolean);
                }"""
            )
            if not res["zoomed_pane_stays_in_its_box"]:
                print(f"       zoomed pane spills {spill} px past its box")
            page.screenshot(path="/tmp/clique-redraw/boxed-panel-open.png")
            page.evaluate("() => closePanel()")
            page.wait_for_timeout(400)
            res["boxed_fills_height_again"] = boxed_fill() > 0.9
            res["size_note_goes_quiet_again"] = size_note() == ""
            if fill <= 0.9:
                print(f"       boxed pane covered {fill:.0%} of its box with the panel open")

            # Two browsers, one session, different window sizes. Only one of
            # them owns the shared tmux window, and the other's grid changes
            # under it without its box moving. Nothing recomputed the zoom on
            # that path, so the loser drew at the scale that suited the grid it
            # used to have: a half-size picture in a full-width box, with
            # xterm's scrollbar floating where the picture stopped.
            second = ctx.new_page()
            second.goto(BASE, wait_until="domcontentloaded")
            second.wait_for_timeout(700)
            second.set_viewport_size({"width": 760, "height": 620})
            second.evaluate("async (id) => { await refresh(); await openSession(id); }", bid)
            second.wait_for_timeout(2000)
            page.bring_to_front()
            page.wait_for_timeout(1500)

            def underfill(pg):
                """Fraction of the pane's width the picture actually covers."""
                return pg.evaluate(
                    """(id) => {
                      const e = terms.get(id);
                      if (!e || !e.term || !e.term.element) return 0;
                      const w = e.term.element.getBoundingClientRect().width;
                      return e.el.clientWidth ? w / e.el.clientWidth : 0;
                    }""",
                    bid,
                )

            wide_fill, small_fill = underfill(page), underfill(second)
            res["both_panes_fill_their_width"] = wide_fill > 0.9 and small_fill > 0.9
            # And the smaller one says why it looks the way it does, rather
            # than leaving the person in front of it to guess.
            READ = (
                "() => { const e = document.querySelector('#sessionline .sl-size');"
                " return [e ? e.textContent : '',"
                "  (state.clients ?? 0) + '/' + openTabs.length,"
                "  (() => { const t = terms.get(activeId);"
                "     if (!t) return 'none';"
                "     const w = t.fit.proposeDimensions() || {};"
                "     return t.term.cols + 'x' + t.term.rows + ' want ' + w.cols + 'x' + w.rows; })()]; }"
            )
            # The strip refreshes on the 3s poll, and a busy box can be late
            # with one. Wait for the condition rather than for a number of
            # milliseconds, or this is a check that fails under load.
            first_note = second_note = ""
            for _ in range(16):
                first_note = page.evaluate(READ)[0]
                second_note = second.evaluate(READ)[0]
                if "another window" in first_note or "another window" in second_note:
                    break
                page.wait_for_timeout(700)
            told = [n for n in (first_note, second_note) if n.strip()]
            res["a_browser_is_told_another_owns_the_size"] = any(
                "another window" in n for n in told
            )
            print(f"       strips read: {first_note!r} / {second_note!r}")
            if not res["both_panes_fill_their_width"]:
                print(f"       widths covered: first {wide_fill:.0%}, second {small_fill:.0%}")
            second.screenshot(path="/tmp/clique-redraw/second-browser.png")
            second.close()
            page.wait_for_timeout(500)

            # The repaint reaches tmux, finds this browser's own client, and
            # finds nothing on the session itself — so it cannot hit anyone else.
            views = [s.mux for s in tmux_mod.list_sessions(SOCKET, prefix=tmux_mod.VIEW_PREFIX)]
            # One view per open terminal, never one on the session itself.
            res["viewer_exists"] = len(views) == 2
            if views:
                clients = tmux_mod._run(
                    ["list-clients", "-t", f"={views[0]}", "-F", "#{client_tty}"], SOCKET
                ).split()
                res["viewer_has_one_client"] = len(clients) == 1
                # tmux 3.4 errors rather than printing nothing when a target
                # session has no clients, so both shapes mean "none".
                try:
                    own = tmux_mod._run(
                        ["list-clients", "-t", f"={mux}", "-F", "#{client_tty}"], SOCKET
                    ).strip()
                except tmux_mod.TmuxError:
                    own = ""
                res["session_has_no_client"] = not own
                tmux_mod.refresh_client(views[0], SOCKET)
                res["refresh_client_survives"] = tmux_mod.exists(mux, SOCKET)

            # And the socket takes the message the browser actually sends.
            page.evaluate("() => repaintPane()")
            page.wait_for_timeout(300)
            res["repaint_keeps_socket"] = page.evaluate(
                "(id) => { const e = terms.get(id); return Boolean(e && e.ws && e.ws.readyState === 1); }",
                sid,
            )

            res["errors"] = errs[:6]
            browser.close()
    finally:
        proc.terminate()
        subprocess.run(
            ["tmux", "-L", SOCKET, "kill-server"],
            stdout=subprocess.DEVNULL,
            stderr=subprocess.DEVNULL,
        )
        shutil.rmtree(HOME, ignore_errors=True)
        shutil.rmtree(WORK, ignore_errors=True)

    ok = all(v for k, v in res.items() if k != "errors") and not res.get("errors")
    for key, value in res.items():
        good = (not value) if key == "errors" else bool(value)
        print(f"  {'ok  ' if good else 'FAIL'} {key}: {value}")
    print("redraw_check:", "PASS" if ok else "FAIL")
    return 0 if ok else 1


if __name__ == "__main__":
    sys.exit(main())