207 lines
8.4 KiB
Python
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")
|