"""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 ", 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=. 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())