Files
owui-tools/tests/test_vm_live_view_collapse.py
T
2026-09-15 15:24:03 -04:00

207 lines
8.4 KiB
Python

#!/usr/bin/env python3
"""Each box can be clicked shut, and the live line is revealed, not dumped.
python3 tests/test_vm_live_view_collapse.py
WHAT WAS ASKED FOR
* the live line above the task list should arrive a few characters at a
time, so that its speed is the token rate rather than a poll artefact;
* clicking the task list should leave two lines - that live line, and
the step the session is on;
* clicking the screen should halve it, on black;
* clicking the feed should halve its height;
* clicking a second time should put any of them back.
THREE THINGS HERE WERE MEASURED IN A BROWSER, NOT REASONED ABOUT
1. `window.name` SURVIVES THE srcdoc SWAP AND NOTHING ELSE DOES. The view
is re-sent every couple of seconds and each send replaces the frame's
srcdoc, destroying the document. The frame is sandboxed without
`allow-same-origin`, so it has an opaque origin: localStorage and
cookies throw and there is no listener in Open WebUI to postMessage
to. `window.name` belongs to the browsing context, which survives.
Measured in that exact sandbox: the third load read back the name its
two predecessors wrote. Without it a box springs open a second after
being clicked shut.
2. THE STATE IS KEYED BY WHICH BOX IT IS. The screen box appears
mid-session and pushes the feed down a slot, so the frame that was the
feed becomes the screen - and would inherit its state.
3. `flex:none` ON THE FEED ROWS IS LOAD-BEARING. The rows sit in a flex
column so a collapsed feed keeps its newest lines against the bottom.
Flex items shrink by default, so without it the rows compressed into
the smaller box and the text overlapped itself: measured, twenty lines
crushed into ten lines of space instead of ten lines shown and ten
lost off the top.
Browser-verified, in a real sandboxed frame, before this file was
written: task box 196px -> 51px and back; screen 500px -> 269px with the
image exactly half its width on black; feed 364px -> 190px showing ten
rows of an unchanged 17.4px. All three survived two srcdoc swaps.
"""
import asyncio
import json
import os
import re
import sys
import types
HERE = os.path.dirname(os.path.abspath(__file__))
FILTER = os.path.join(HERE, "..", "owui-functions", "vm_agent_progress.py")
FAILS: list[str] = []
def check(label: str, condition: bool, detail: str = "") -> None:
if condition:
print(f"ok {label}")
else:
FAILS.append(f"{label}: {detail}")
print(f"FAIL {label}\n {detail}")
src = open(FILTER, encoding="utf-8").read()
aiohttp = types.ModuleType("aiohttp")
aiohttp.ClientError = Exception
aiohttp.ClientSession = object
aiohttp.ClientTimeout = object
sys.modules["aiohttp"] = aiohttp
pydantic = types.ModuleType("pydantic")
class _BaseModel:
def __init__(self, **kw):
for key, value in kw.items():
setattr(self, key, value)
pydantic.BaseModel = _BaseModel
pydantic.Field = lambda default=None, **kw: default
sys.modules["pydantic"] = pydantic
namespace: dict = {}
exec(compile(src, FILTER, "exec"), namespace)
Filter = namespace["Filter"]
class Valves:
show_terminal = True
show_screenshots = True
DATA = {
"tasks": "\n".join(f"[ ] {i + 1}. step {i + 1}" for i in range(6)),
"status": "3/6 · the step it is on",
"speech": "the model's own voice, mid-sentence",
"shot": "data:image/jpeg;base64,AAAA",
"caption": "the desktop",
"terminal": ["$ one", "$ two"],
}
out: list = []
async def _run():
async def emitter(event):
out.append(event)
await Filter._embed(emitter, DATA, Valves())
asyncio.run(_run())
boxes = out[-1]["data"]["embeds"]
check("three boxes once there is a screenshot", len(boxes) == 3, str(len(boxes)))
TASKS, SCREEN, FEED = boxes
def config(box: str) -> dict:
found = re.search(r'<script type="application/json" id="cfg">(.*?)</script>', box, re.S)
return json.loads(found.group(1).replace("<\\/", "</")) if found else {}
# --- every box says which one it is ---------------------------------------
check("each box carries its own config", all(config(b) for b in boxes))
check("and they are the three kinds, in order",
[config(b).get("k") for b in boxes] == ["tasks", "screen", "feed"],
str([config(b).get("k") for b in boxes]))
check("the config is JSON, not generated JavaScript",
'type="application/json"' in TASKS,
"the live line is the model's own words - a quote in it would end the script")
raw_cfg = re.search(r'id="cfg">(.*?)</script>', TASKS, re.S).group(1)
check("and a closing tag inside it cannot end the script early",
"</" not in raw_cfg,
"the HTML parser ends the block at the first </script>, wherever it appears, "
"so the JSON escapes it as <\\/ - which JSON reads back as a plain slash")
# --- the task box collapses to two lines ----------------------------------
check("the task box has a live line, the list, and the current step",
all(x in TASKS for x in ('id="live"', 'id="full"', 'id="cur"')))
check("the current step is the one the collapsed box will show",
"the step it is on" in TASKS,
"collapsed, the second line is the status - so it has to be in the box")
check("the step line is hidden until the box is clicked",
'id="cur" style="display:none' in TASKS)
check("the live line is right-anchored, because the server sends the tail",
"justify-content:flex-end" in TASKS,
"the end of the sentence is the part that is moving")
check("and it holds its line even before the model has spoken",
"min-height" in TASKS,
"otherwise the collapsed box is one line until the first token")
# --- the screen box halves onto black -------------------------------------
check("the screen box has a wrapper to blacken and an image to shrink",
'id="wrap"' in SCREEN and 'id="shot"' in SCREEN)
# --- the feed box halves its height ---------------------------------------
rows = FEED.count('<div style="white-space:pre;overflow:hidden;')
check("the feed is exactly as many rows as it claims",
rows == namespace["TERMINAL_LINES"], str(rows))
check("the rows are bottom-anchored, so a half feed keeps the newest lines",
"justify-content:flex-end" in FEED and 'id="rows"' in FEED)
check("AND THE ROWS CANNOT SHRINK",
FEED.count("flex:none") == namespace["TERMINAL_LINES"],
"flex items shrink by default: without this the twenty rows compress "
"into the smaller box and the text overlaps itself")
check("the feed knows how many rows it has, for the half",
config(FEED).get("n") == namespace["TERMINAL_LINES"])
# --- the script: state that outlives the document -------------------------
script = namespace["_VIEW_SCRIPT"]
check("state is read from and written to window.name",
"window.name" in script and "JSON.stringify(st)" in script,
"the only store that survives a srcdoc swap in an opaque origin")
check("state is discarded when the box is a different one",
"st.k!==kind" in script.replace(" ", ""),
"the screen box appears mid-session and pushes the feed down a slot")
check("a click toggles it, saves it, applies it and re-reports the height",
all(x in script.replace(" ", "") for x in
("addEventListener('click'", "st.c=st.c?0:1", "save();apply();post();")),
"the frame sizes itself, so a box that changes height must say so")
check("the height is still reported the only way that works here",
"iframe:height" in script and "postMessage" in script,
"contentDocument is cross-origin in this sandbox and fails silently")
check("no tooltip was added", "title=" not in TASKS and "title=" not in FEED)
# --- the script: the live line is revealed, not dumped --------------------
check("the line is appended a few characters at a time",
"setInterval" in script and "pending.slice" in script)
check("it continues the previous line rather than retyping it",
"st.d" in script,
"the text so far rides in window.name across the swap")
check("it works out what is new from the overlap with what is shown",
"target.slice(0,k)" in script.replace(" ", ""),
"the server sends a sliding window of the tail, not a growing string")
check("and the buffer it keeps is bounded",
"CAP" in script and "slice(-CAP)" in script.replace(" ", ""))
print()
if FAILS:
print(f"{len(FAILS)} failed")
for line in FAILS:
print(" -", line)
sys.exit(1)
print("every box can be clicked shut, and the live line arrives at the rate it was written")