test: the lab restores real tabs across real launches, and follows real links

tools/lab/wonderink-workspace-check launches the installed command again
and again against a stand-in for WonderInk's start page whose sign-in is
Basic auth, as WonderInk's is: restored after 5 and 90 minutes; fresh
after two hours and a minute, a day and a week; reopened untouched until
it lapses; a click after a restore counts and the restore does not; a
busy page and an untouched print preview do not; Print does; closed,
reordered, worksheet and missing-worksheet tabs; killed outright; a
restore that crashes twice; corrupt, future-format and half-written
files; the file holding no credential; restored tabs printing. 59 checks.

The print check follows every kind of link on a signed-in page with one
click — new tab in front for links and window.open, nothing for buttons
and anchors, same-tab for no target, Back, closing — and prints a PDF and
a picture reached by a link, and a page after several tabs. 0.2.6.3's
scenarios are unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
ekklesia
2026-09-26 21:12:17 -05:00
co-authored by Claude Opus 5.5
parent b5d9535467
commit 78a844968c
5 changed files with 651 additions and 2 deletions
+68 -1
View File
@@ -14,6 +14,18 @@ of picture a lesson might put under a teacher's pointer:
print that fetches it again gets a 404, not a picture
OTHER_PORT the same pictures from another origin, with no CORS
and a stand-in for WonderInk's own start page and sign-in, built the way
the real site's is (read from its code, and measured with a real
account): the sign-in is cookies its JavaScript writes, and rrf-auth is
"Basic " and the base64 of the email and password — so a check can prove
that nothing Wonder Ink writes down holds one. Sign out calls
/rrframework/dataStore/signout and then sets them to "".
/app "Signed in as …" if rrf-auth is set, else a sign-in form
/app?busy=1 the same, plus what a site does by itself: a request
every second and an animation, and no input at all
/links every kind of link a lesson has, for following them
Every request is logged, one JSON line each, so a check can say what was
fetched while it printed. What was measured with this, and why Print
image fetches nothing, is in classroom-wonderink's _take_picture.
@@ -21,6 +33,7 @@ image fetches nothing, is in classroom-wonderink's _take_picture.
import http.server
import json
import re
import sys
import threading
import time
@@ -31,7 +44,7 @@ from urllib.parse import urlparse
PICTURES = Path(sys.argv[1])
PORT, OTHER = int(sys.argv[2]), int(sys.argv[3])
LOG = Path(sys.argv[4])
TYPES = {".png": "image/png", ".jpg": "image/jpeg", ".webp": "image/webp"}
TYPES = {".png": "image/png", ".jpg": "image/jpeg", ".webp": "image/webp", ".pdf": "application/pdf"}
fetched_once = set()
lock = threading.Lock()
@@ -51,6 +64,7 @@ img, canvas { width: 180px; height: 120px; object-fit: contain; border: 1px soli
<canvas id="canvas" width="1600" height="1000"></canvas>
<img id="broken" src="/private/not-there.png">
<a href="/lesson#more"><img id="linked" src="/private/ark-tall.png"></a>
<p><a id="text-link" href="/links" target="_blank">More links for this lesson</a></p>
<script>
var waiting = 2;
function one() { if (--waiting === 0) window.__ready = true; }
@@ -63,6 +77,46 @@ art.src = '/private/ark-wide.png';
</script></body></html>"""
APP = """<html><head><title>App</title></head><body>
<div id="state"></div>
<input id="email" type="text"><input id="password" type="password">
<button id="signin">Sign In</button> <button id="signout">Sign Out</button>
<div id="spinner" style="width:20px;height:20px;background:#c00"></div>
<script>
function cookie(name) { var m = document.cookie.match('(^|; )' + name + '=([^;]*)'); return m ? m[2] : null; }
function show() { var t = cookie('rrf-auth');
document.getElementById('state').textContent = t ? 'Signed in as teacher' : 'Signed out'; document.title = t ? 'Signed in' : 'Sign-in'; }
document.getElementById('signin').onclick = function () {
// As the real site: the "token" is Basic auth — the email and password —
// in a cookie its JavaScript writes. Analytics cookies alongside, as there.
document.cookie = 'rrf-auth=' + encodeURIComponent('Basic ' + btoa('[email protected]:hunter2hunter2')) + '; path=/';
document.cookie = 'rrf-session-id=SID; path=/'; document.cookie = 'rrf_tsec_tok=T2; path=/';
document.cookie = '_ga=GA1.1.analytics; path=/'; document.cookie = '_fbp=fb.1.tracking; path=/'; show(); };
function server() { var x = new XMLHttpRequest(); x.open('GET', '/whoami', false); x.send(); return x.status; }
document.getElementById('signout').onclick = function () {
fetch('/rrframework/dataStore/signout').then(function () {
document.cookie = 'rrf-auth=; path=/'; document.cookie = 'rrf-session-id=; path=/'; document.cookie = 'rrf_tsec_tok=; path=/'; show(); }); };
show();
if (location.search.indexOf('busy=1') >= 0) {
setInterval(function () { fetch('/beacon?' + Date.now()); }, 1000);
var a = 0; (function spin() { a += 3; document.getElementById('spinner').style.transform = 'rotate(' + a + 'deg)'; requestAnimationFrame(spin); })();
}
</script></body></html>"""
LINKS = """<html><head><title>Links</title></head><body style="font: 16px sans-serif">
<h1 id="top">Links in a lesson</h1>
<p><a id="blank" target="_blank" rel="noopener noreferrer" href="/private/ark-wide.png">Saul Welcome Question Slide</a></p>
<p><a id="same" href="/lesson">The lesson, no target</a></p>
<p><a id="external" target="_blank" href="http://localhost:OTHER/img/ark-tall.png">A picture on another site</a></p>
<p><a id="pdf" target="_blank" href="/private/ark.pdf">The worksheet (PDF)</a></p>
<p><a id="linked-picture" target="_blank" href="/private/ark-tall.png"><img src="/private/ark-tall.png" width="120"></a></p>
<p><button id="button" onclick="this.textContent = 'Pressed'">A lesson button</button></p>
<p><a id="anchor" href="#bottom">Go to the bottom</a></p>
<p><button id="popup" onclick="window.open('/private/ark-clear.png', '_blank')">Open the picture</button></p>
<div style="height: 1500px"></div><p id="bottom">The bottom</p>
</body></html>"""
class Handler(http.server.BaseHTTPRequestHandler):
def log_message(self, *_):
pass
@@ -96,6 +150,16 @@ class Handler(http.server.BaseHTTPRequestHandler):
return self.picture(path[5:])
return self.answer(404)
if path == "/app":
return self.answer(200, APP.encode(), "text/html", [("Cache-Control", "no-store")])
if path == "/whoami":
token = re.search(r"(?:^|; )rrf-auth=([^;]+)", self.headers.get("Cookie", ""))
return self.answer(200 if token else 403, b"{}", "application/json")
if path in ("/rrframework/dataStore/signout", "/beacon"):
return self.answer(200, b"{}", "application/json")
if path == "/login":
return self.answer(302, headers=[
("Set-Cookie", "session=ok; Path=/; HttpOnly; SameSite=Lax"),
@@ -104,6 +168,9 @@ class Handler(http.server.BaseHTTPRequestHandler):
if not signed_in:
return self.answer(403, b"sign in first")
if path == "/links":
return self.answer(200, LINKS.replace("OTHER", str(OTHER)).encode(), "text/html")
if path == "/lesson":
page = LESSON.replace("NONCE", uuid.uuid4().hex).replace("OTHER", str(OTHER))
return self.answer(200, page.encode(), "text/html")
+47 -1
View File
@@ -186,6 +186,9 @@ PICTURES=(
# Black and white shown as it prints, and Color again (0.2.6.3).
COLOURS=(bw-web bw-pdf bw-colour-again bw-cancel)
# Links in a signed-in lesson, followed with one click (0.2.6.4).
LINKS=(links:follow links:pdf links:image links:image-rclick links:tabs-print)
# Right-click in a signed-in lesson (0.2.6.3):
# rclick-image:ELEMENT:SIZE:POSITION[:gray], the elements in
# wonderink-lesson-site.py.
@@ -209,7 +212,7 @@ RIGHT_CLICKS=(
scenarios=("$@")
[[ ${#scenarios[@]} -gt 0 ]] || scenarios=(web pdf pdf-options pdf-viewer-button inline
options bad-range broken cancel page-closes-itself close-window page-prints-again repeat
dark-page "${PICTURES[@]}" "${COLOURS[@]}" "${RIGHT_CLICKS[@]}")
dark-page "${PICTURES[@]}" "${COLOURS[@]}" "${RIGHT_CLICKS[@]}" "${LINKS[@]}")
# The inline scenario needs a PDF to serve; the worksheet scenario writes one.
lab 'test -f /opt/wonderink-check/www/ark.pdf' || { run pdf >/dev/null; lab 'cp "/home/teacher/Downloads/Wonder Ink/Colour the Ark.pdf" /opt/wonderink-check/www/ark.pdf; chmod a+r /opt/wonderink-check/www/ark.pdf'; }
@@ -340,6 +343,49 @@ for scenario in "${scenarios[@]}"; do
judge "CUPS was given exactly the page the preview showed" \
"lab 'cmp -s /tmp/rig-out/preview.pdf $(spool_file "$jobs")'"
judge "the teacher's clipboard is untouched" teacher_text_kept ;;
links:follow)
# link_is WHICH EXPECTED — the rig's line for that link contains EXPECTED.
link_is() { grep -o -- "LINK $1 .*" <<< "$out" | head -1 | grep -qF -- "$2"; }
judge "a text link to a picture opens it in a new tab, and Wonder Ink shows that tab" \
"link_is '#blank' \"tabs=2 current='/private/ark-wide.png' current_is_new=True\""
judge "another site's link: the same" \
"link_is '#external' \"tabs=3 current='http://localhost:8095/img/ark-tall.png' current_is_new=True\""
judge "a linked picture: the same" \
"link_is '#linked-picture' \"tabs=4 current='/private/ark-tall.png' current_is_new=True\""
judge "a worksheet PDF: a new tab, in the PDF viewer" \
"link_is '#pdf' \"tabs=5 current='/private/ark.pdf' current_is_new=True\""
judge "a lesson button that opens a window: a new tab, shown" \
"link_is '#popup' \"tabs=6 current='/private/ark-clear.png' current_is_new=True\""
judge "a lesson button that is not a link: no tab, no navigation" \
"link_is '#button' \"tabs=6 current='/links' current_is_new=False\""
judge "an in-page anchor: stays on the page, scrolled" \
"link_is '#anchor' \"tabs=6 current='/links#bottom' current_is_new=False\""
judge "a link with no target: followed in the same tab, as a browser does" \
"link_is '#same' \"tabs=6 current='/lesson' current_is_new=False\""
judge "and Back returns to the links" "link_is 'after Back' \"current='/links\""
judge "closing every tab the links opened leaves the lesson's own" \
"link_is 'after closing' \"tabs=1 current='/links\""
judge "every page and picture the links reached came through the signed-in session" \
"! lab \"grep '\\\"signed_in\\\": false' $SITE_LOG | grep -qE '\\\"path\\\": \\\"/(links|lesson|private)'\"" ;;
links:pdf)
judge "a worksheet reached by a link opens as a worksheet and prints" \
"said 'pages=2' && [[ \$(count) -eq 1 ]]"
judge "CUPS was given exactly the file the preview showed" \
"lab 'cmp -s /tmp/rig-out/preview.pdf $(spool_file "$jobs")'" ;;
links:image|links:image-rclick)
if [[ "$scenario" == links:image-rclick ]]; then
judge "right-click on the picture a link opened offers Print image" "said \"MENU .*'Print image…'\""
fi
judge "the picture a link opened gets the picture layout" "said 'PICTURE picture_box=True image=1600x1000'"
judge "one job" '[[ $(count) -eq 1 ]]'
judge "CUPS was given exactly the page the preview showed" \
"lab 'cmp -s /tmp/rig-out/preview.pdf $(spool_file "$jobs")'" ;;
links:tabs-print)
judge "three links opened and two closed again: two tabs left, back on the links" \
"said \"LINK before printing tabs=2 current='/links'\""
judge "the page they came from prints, one job" '[[ $(count) -eq 1 ]] && said "PREVIEW picture=False"'
judge "CUPS was given exactly the page the preview showed" \
"lab 'cmp -s /tmp/rig-out/preview.pdf $(spool_file "$jobs")'" ;;
rclick-page-gray)
judge "a lesson page full of pictures turns grey in the preview ($(lines 'GRAY PAGE' | grep -o 'took=[0-9.]*'))" \
"said 'GRAY PAGE .*shown_gray=True'"
+101
View File
@@ -177,6 +177,107 @@ if scenario.startswith("bw-"):
QTimer.singleShot(2500, begin)
else:
view.loadFinished.connect(begin)
elif scenario.startswith("links:"):
# links:follow every kind of link on the lab's signed-in links page,
# clicked in turn: what opens where, and what does not
# links:pdf a worksheet reached by a link, printed
# links:image a picture reached by a link, printed as a picture
# links:image-rclick the same picture, right-click Print image
# links:tabs-print several links opened, some closed, then the page printed
from PyQt6.QtCore import QEvent, QPointF, Qt
from PyQt6.QtGui import QMouseEvent
from PyQt6.QtWidgets import QApplication as Application
variant = scenario.split(":", 1)[1]
links_view = window.open_tab(); links_view.load(QUrl(f"{SITE}/login"))
def on_links():
return links_view.url().path() == "/lesson" or links_view.url().path() == "/links"
def click_in(view, selector, then, button=Qt.MouseButton.LeftButton):
js = ("(function(){var e=document.querySelector(%r); if(!e) return null; e.scrollIntoView({block:'center'});"
"var r=e.getBoundingClientRect(); return [r.x+r.width/2, r.y+r.height/2]})()" % selector)
def at(xy):
if not xy:
stamp(f"NO {selector}"); return finish(6)
zoom = view.zoomFactor(); point = QPointF(xy[0] * zoom, xy[1] * zoom)
for kind, buttons in ((QEvent.Type.MouseButtonPress, button), (QEvent.Type.MouseButtonRelease, Qt.MouseButton.NoButton)):
Application.sendEvent(view.focusProxy(), QMouseEvent(kind, point, QPointF(view.mapToGlobal(point)),
button, buttons, Qt.KeyboardModifier.NoModifier))
QTimer.singleShot(1500, then)
view.page().runJavaScript(js, at)
def tabs():
return [window._tabs.widget(i).url().toString().replace(SITE, "") for i in range(window._tabs.count())]
def report(label):
current = window.current()
stamp(f"LINK {label} tabs={len(tabs())} current={current.url().toString().replace(SITE, '')!r} "
f"current_is_new={current is not links_view} worksheet={current in window._pdf_views}")
def back_to_links():
window._tabs.setCurrentWidget(links_view)
def go_links(then):
# From the lesson the site sent us to after signing in, to the links page, in this tab.
links_view.load(QUrl(f"{SITE}/links"))
when(lambda: links_view.url().path() == "/links", lambda: QTimer.singleShot(1200, then), "the links page")
steps = []
if variant == "follow":
order = [("#blank", "text link, target=_blank, to a picture"), ("#external", "another site"),
("#linked-picture", "a linked picture"), ("#pdf", "a worksheet PDF"),
("#popup", "a lesson button that opens a window"), ("#button", "a lesson button"),
("#anchor", "an in-page anchor"), ("#same", "a link with no target")]
def next_link(i=0):
if i == len(order):
# Back, as the right-click menu's Back does, then close every tab the links opened.
links_view.page().triggerAction(links_view.page().WebAction.Back)
def closed_all():
report("after Back")
for view in [window._tabs.widget(j) for j in range(window._tabs.count())]:
if view is not links_view:
window.close_tab(window._tabs.indexOf(view))
QTimer.singleShot(800, lambda: (report("after closing the opened tabs"),
stamp(f"TABS {tabs()}"), finish()))
return QTimer.singleShot(2000, closed_all)
selector, label = order[i]
back_to_links()
click_in(links_view, selector, lambda: (report(f"{selector} ({label})"),
stamp(f"BUTTON {links_view.page().title()!r}") if selector == "#button" else None,
QTimer.singleShot(1500 if selector == "#pdf" else 200, lambda: next_link(i + 1))))
when(on_links, lambda: go_links(lambda: (report("start"), next_link())), "signed in")
elif variant in ("pdf", "image", "image-rclick", "tabs-print"):
target = {"pdf": "#pdf", "image": "#blank", "image-rclick": "#blank", "tabs-print": "#blank"}[variant]
def opened():
report(f"{target} opened")
view = window.current()
if variant == "pdf":
QTimer.singleShot(2500, lambda: (QTimer.singleShot(0, lambda: window.print_view(window.current())),
when(ready, lambda: (keep_preview(), press_print()), "worksheet preview")))
elif variant == "image":
QTimer.singleShot(0, lambda: window.print_view(view))
when(lambda: ready() and preview()._picture is not None, lambda: (
stamp(f"PICTURE picture_box={preview()._picture_box.isVisible()} image={preview()._picture.width()}x{preview()._picture.height()}"),
keep_preview(), press_print()), "picture preview")
elif variant == "image-rclick":
def menu_up(menu):
items = [a.text() for a in menu.actions() if a.text()]
stamp(f"MENU {items}")
action = next(a for a in menu.actions() if a.text() == "Print image…")
menu.close()
when(lambda: ready() and preview()._picture is not None, lambda: (
stamp(f"PICTURE picture_box={preview()._picture_box.isVisible()} image={preview()._picture.width()}x{preview()._picture.height()}"),
keep_preview(), press_print()), "picture preview")
QTimer.singleShot(0, action.trigger)
click_in(view, "img", lambda: when(lambda: Application.activePopupWidget() is not None,
lambda: menu_up(Application.activePopupWidget()), "menu"),
button=Qt.MouseButton.RightButton)
else:
# Three links opened, two of them closed again, then the page they came from printed.
def more():
back_to_links()
click_in(links_view, "#external", lambda: (back_to_links(), click_in(links_view, "#linked-picture", close_two)))
def close_two():
for view in [window._tabs.widget(j) for j in range(window._tabs.count())][-2:]:
window.close_tab(window._tabs.indexOf(view))
back_to_links(); report("before printing")
QTimer.singleShot(0, lambda: window.print_view(links_view))
when(ready, lambda: (keep_preview(), press_print()), "page preview")
more()
when(on_links, lambda: go_links(lambda: (back_to_links(), click_in(links_view, target, lambda: QTimer.singleShot(1500, opened)))), "signed in")
elif scenario.startswith("rclick"):
# A lesson behind a sign-in, with pictures in it; a real right-click.
# rclick-page on the words: the page prints
+234
View File
@@ -0,0 +1,234 @@
#!/bin/bash
# Temporary workspace restore: do a teacher's recent tabs come back — and
# only for a while, and never with their sign-in?
#
# Each scenario launches the INSTALLED classroom-wonderink one or more times
# (tools/lab/wonderink-workspace-rig.py), against the lab's stand-in for
# WonderInk's start page and a second origin standing in for its public
# resource files, and is judged by the tabs each launch opens and by the
# kept file itself. Time between launches is made to pass by moving the
# time of last use in the file; time within a launch is real, with the
# limit shortened by the test-only override where a scenario needs it.
#
# tools/lab/wonderink-workspace-check [SCENARIO...]
set -uo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
NAME="${LAB_NAME:-classroom-v2-lab}"
COMMAND="${WONDERINK:-/usr/local/bin/classroom-wonderink}"
DIR=/opt/wonderink-check
SITE=http://127.0.0.1:8094
SLIDE=http://localhost:8095/img/ark-wide.png
OTHER=http://localhost:8095/img/ark-tall.png
KEPT=/home/teacher/.local/share/ekklesia-classroom/wonderink-workspace.json
SHEETS="/home/teacher/Downloads/Wonder Ink"
lab() { docker exec -i "$NAME" bash -c "$1"; }
lab "mkdir -p $DIR"
for file in wonderink-workspace-rig.py wonderink-lesson-site.py wonderink-picture-measure.py; do
docker cp "$ROOT/tools/lab/$file" "$NAME:$DIR/$file" >/dev/null
done
lab "chmod -R a+rX $DIR; id teacher >/dev/null 2>&1 || useradd -m teacher; install -d -o teacher -m 700 /tmp/teacher-rt"
lab "test -f $DIR/www/ark-wide.webp || { mkdir -p $DIR/www; QT_QPA_PLATFORM=offscreen \
QTWEBENGINE_CHROMIUM_FLAGS='--no-sandbox --disable-gpu' python3 $DIR/wonderink-picture-measure.py make $DIR/www; }
chmod -R a+r $DIR/www"
lab "pkill -f '[w]onderink-lesson-site.py'; : > $DIR/site.log; chmod a+rw $DIR/site.log"
docker exec -d "$NAME" python3 "$DIR/wonderink-lesson-site.py" "$DIR/www" 8094 8095 "$DIR/site.log"
lab 'for _ in $(seq 20); do curl -s -o /dev/null http://127.0.0.1:8094/app && exit 0; sleep 0.3; done; exit 1' || {
echo "the lesson site did not start" >&2
exit 1
}
passed=0
failed=0
out=""
judge() {
if eval "$2"; then
printf ' \033[32mPASS\033[0m %s\n' "$1"
passed=$((passed + 1))
else
printf ' \033[31mFAIL\033[0m %s\n' "$1"
failed=$((failed + 1))
fi
}
# launch [limit=SECONDS] -- STEP... one Wonder Ink; its output appended to $out
launch() {
local env=""
[[ "$1" == limit=* ]] && { env="CLASSROOM_WONDERINK_TEST_WORKSPACE_SECONDS=${1#limit=}"; shift; }
[[ "$1" == -- ]] && shift
out+=$'\n'"=== launch $*"$'\n'
out+=$(lab "su teacher -c 'cd /tmp; XDG_RUNTIME_DIR=/tmp/teacher-rt QT_QPA_PLATFORM=offscreen \
QTWEBENGINE_CHROMIUM_FLAGS=\"--no-sandbox --disable-gpu\" $env \
timeout 180 python3 $DIR/wonderink-workspace-rig.py $COMMAND $SITE $* 2>&1'; echo \"exit=\$?\"")
}
# The time of last use in the kept file, made SECONDS older (negative: the future).
age() {
lab "su teacher -c \"python3 -c 'import json,sys; p=sys.argv[1]; d=json.load(open(p)); d[\\\"last_activity\\\"]-=float(sys.argv[2]); json.dump(d, open(p, \\\"w\\\"))' $KEPT $1\""
}
said() { grep -q -- "$1" <<< "$out"; }
nth() { grep -o -- "$1.*" <<< "$out" | sed -n "${2}p"; }
kept_file() { lab "test -e $KEPT"; }
# The age a STATE line reports for the kept file.
age_of() { grep -o 'age=[0-9]*' <<< "$1" | cut -d= -f2; }
clean() { lab "rm -f $KEPT $KEPT.new; rm -rf \"$SHEETS\"; install -d -o teacher \"$SHEETS\""; out=""; }
# Every launch ended by itself with 0, except one the scenario killed on purpose.
no_crash() {
! said Traceback && ! said Segmentation && \
! awk '/\] CRASH/ {killed=1} /^exit=/ {if ($0 != "exit=0" && !(killed && $0 == "exit=137")) bad=1; killed=0} END {exit !bad}' <<< "$out"
}
THREE="touch open:$SLIDE open:$OTHER front:1 touch"
RESTORED="tabs=\\['/app', '$SLIDE', '$OTHER'\\] active=1"
FRESH="tabs=\\['/app'\\] active=0 kept\\[none\\]"
scenarios=("$@")
[[ ${#scenarios[@]} -gt 0 ]] || scenarios=(restore-5min restore-90min expire-2h1m expire-next-day expire-next-week
reopen-does-not-refresh activity-refreshes background-ignored preview-ignored printing-counts
closed-stays-closed reordered worksheet worksheet-gone crash-then-restore restore-crashes
corrupt future-format half-written privacy restored-tabs-print)
for scenario in "${scenarios[@]}"; do
echo "$scenario"
clean
before=$failed
case "$scenario" in
restore-5min|restore-90min)
minutes=${scenario#restore-}; minutes=${minutes%min}
launch -- $THREE state quit
age $((minutes * 60))
launch -- state front:0 state quit
judge "three tabs, the middle one in front, closed; $minutes minutes later: all three back" \
"nth STATE 2 | grep -q \"$RESTORED\""
judge "in the same order, the same one in front" "nth START 2 | grep -q 'tabs=3'"
judge "the tabs behind wait to load until they are shown (a PDF behind opened at 10%)" \
"nth STATE 2 | grep -q 'unshown=2'"
judge "and one clicked loads: the start page, asking to sign in" \
"nth STATE 3 | grep -q \"pages=\\['Signed out'\\] unshown=1\"" ;;
expire-2h1m|expire-next-day|expire-next-week)
case "$scenario" in
expire-2h1m) seconds=$((2 * 3600 + 60)); label="two hours and a minute" ;;
expire-next-day) seconds=$((24 * 3600)); label="the next day" ;;
*) seconds=$((7 * 24 * 3600)); label="the next week" ;;
esac
launch -- $THREE quit
age $seconds
launch -- state quit
judge "$label: a fresh Wonder Ink, one tab on the start page" "nth STATE 1 | grep -q \"$FRESH\""
judge "the old workspace is gone, and says why" "said 'starting fresh (expired)' && ! kept_file" ;;
reopen-does-not-refresh)
launch limit=20 -- $THREE quit
launch limit=20 -- wait:8 quit
launch limit=20 -- wait:8 quit
launch limit=20 -- state quit
judge "reopened twice, nobody touching it: restored both times" \
"nth START 2 | grep -q 'tabs=3' && nth START 3 | grep -q 'tabs=3'"
judge "but reopening never renewed it: fresh once the limit had passed" \
"nth STATE 1 | grep -q \"$FRESH\"" ;;
activity-refreshes)
launch -- $THREE quit
age 600
launch -- wait:4 state touch quit
# The time of last use is written at most every 30 s, and at quit.
restored=$(age_of "$(nth STATE 1)"); touched=$(age_of "$(nth QUIT 2)")
judge "restoring it did not count as using it (age $restored s, from ten minutes)" "(( restored >= 600 ))"
judge "a click on the restored tabs does (age $touched s at quit)" "(( touched <= 5 ))" ;;
background-ignored)
launch -- "touch open:$SITE/app?busy=1 touch" quit
age 600
launch -- wait:20 state quit
judge "a page busy by itself for 20 s (a request a second, an animation) is not activity (age $(age_of "$(nth STATE 1)") s)" \
"(( \$(age_of \"\$(nth STATE 1)\") >= 615 ))"
judge "and it really was busy" "[[ \$(lab 'grep -c beacon $DIR/site.log') -gt 10 ]]" ;;
preview-ignored)
launch -- $THREE quit
age 600
launch -- preview:15 state quit
judge "a print preview left open 15 s untouched is not activity (age $(age_of "$(nth STATE 1)") s)" \
"(( \$(age_of \"\$(nth STATE 1)\") >= 610 ))" ;;
printing-counts)
launch -- $THREE quit
age 600
before_jobs=$(lab 'lpstat -W all -o 2>/dev/null | wc -l')
launch -- front:2 print state quit
judge "pressing Print is using Wonder Ink (age $(age_of "$(nth STATE 1)") s)" "(( \$(age_of \"\$(nth STATE 1)\") <= 5 ))"
judge "and the restored picture tab printed" "(( \$(lab 'lpstat -W all -o | wc -l') > before_jobs ))" ;;
closed-stays-closed)
launch -- $THREE close:1 touch quit
launch -- state quit
judge "a tab closed before closing Wonder Ink stays closed" \
"nth STATE 1 | grep -q \"tabs=\\['/app', '$OTHER'\\]\"" ;;
reordered)
launch -- $THREE move:0:2 touch quit
launch -- state quit
judge "tabs dragged into another order come back in that order" \
"nth STATE 1 | grep -q \"tabs=\\['$SLIDE', '$OTHER', '/app'\\]\"" ;;
worksheet)
launch -- touch worksheet:Colour-the-Ark open:$SLIDE front:1 touch quit
launch -- state quit
judge "a worksheet comes back as a worksheet, where it was, in front" \
"nth STATE 1 | grep -q \"tabs=\\['/app', 'worksheet:Colour-the-Ark.pdf', '$SLIDE'\\] active=1\"" ;;
worksheet-gone)
launch -- touch worksheet:Colour-the-Ark open:$SLIDE touch quit
lab "rm -f \"$SHEETS/Colour-the-Ark.pdf\""
launch -- state quit
judge "a worksheet whose file has gone is left out, and nothing else is" \
"nth STATE 1 | grep -q \"tabs=\\['/app', '$SLIDE'\\]\"" ;;
crash-then-restore)
launch -- $THREE wait:2 crash
launch -- state quit
judge "killed outright: the tabs come back all the same" "nth STATE 1 | grep -q \"$RESTORED\"" ;;
restore-crashes)
launch -- $THREE quit
launch -- crash
launch -- crash
launch -- state quit
judge "a restore that crashes is tried once more" "nth START 2 | grep -q 'tabs=3' && nth START 3 | grep -q 'tabs=3 attempts=2'"
judge "and then abandoned: fresh, without asking anybody" \
"said 'starting fresh (it failed to restore 2 times)' && nth STATE 1 | grep -q \"tabs=\\['/app'\\]\"" ;;
corrupt)
lab "su teacher -c 'mkdir -p $(dirname $KEPT); echo \"{not json\" > $KEPT'"
launch -- state quit
judge "an unreadable file: fresh, and Wonder Ink opens" "said 'starting fresh (unreadable)' && nth STATE 1 | grep -q \"$FRESH\"" ;;
future-format)
launch -- $THREE quit
lab "su teacher -c \"python3 -c 'import json,sys; p=sys.argv[1]; d=json.load(open(p)); d[\\\"format\\\"]=99; json.dump(d, open(p, \\\"w\\\"))' $KEPT\""
launch -- state quit
judge "a file from a later version: fresh" "said 'starting fresh (from another version)' && nth STATE 1 | grep -q \"$FRESH\"" ;;
half-written)
launch -- $THREE quit
lab "su teacher -c 'echo \"{\\\"format\\\": 1, \\\"last_act\" > $KEPT.new'"
launch -- state quit
judge "a write cut off half way leaves the last whole one, which is restored" "nth STATE 1 | grep -q \"$RESTORED\"" ;;
privacy)
launch -- signin touch state open:$SLIDE open:$OTHER touch quit
judge "signed in on the stand-in, whose sign-in is Basic auth as WonderInk's is" \
"nth STATE 1 | grep -q \"pages=\\['Signed in as teacher'\\]\""
judge "the kept file has no password, Basic-auth credential, cookie or header in it" \
"kept_file && ! lab \"grep -qiE 'hunter2|Basic|dGVhY2hlc|teacher@|rrf|cookie|x-rrf|authoriz|token' $KEPT\""
judge "only addresses, titles and times: the fields it is meant to have" \
"lab \"python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); sys.exit(0 if set(d)=={\\\"format\\\",\\\"last_activity\\\",\\\"active\\\",\\\"attempts\\\",\\\"tabs\\\"} and all(set(t)=={\\\"kind\\\",\\\"where\\\",\\\"title\\\"} for t in d[\\\"tabs\\\"]) else 1)' $KEPT\""
launch -- front:0 state quit
judge "and the restored start page is signed out: the site asks again" \
"said 'restored 3 recent tabs' && nth STATE 2 | grep -q \"pages=\\['Signed out'\\]\"" ;;
restored-tabs-print)
launch -- touch worksheet:Colour-the-Ark open:$SLIDE touch quit
before_jobs=$(lab 'lpstat -W all -o 2>/dev/null | wc -l')
launch -- front:1 print front:2 print state quit
judge "a restored worksheet tab and a restored picture tab both print" \
"(( \$(lab 'lpstat -W all -o | wc -l') >= before_jobs + 2 ))" ;;
esac
judge "Wonder Ink never crashed by itself" no_crash
[[ "$failed" -eq "$before" ]] || \
{ echo "$out" | grep -E 'STATE|START|QUIT|CRASH|PRINTED|exit=|Traceback|starting|restored|TEST' | tail -14 | sed 's/^/ /'; }
done
echo
echo "$passed passed, $failed failed"
[[ "$failed" -eq 0 ]]
+201
View File
@@ -0,0 +1,201 @@
"""One launch of Wonder Ink, its recent tabs restored or not, and a few steps.
usage: wonderink-workspace-rig.py WONDERINK_COMMAND SITE STEP...
Run by tools/lab/wonderink-workspace-check inside the lab, against the
command the package installed, started as main() starts it: the real
profile, downloads connected, then open_workspace() — which restores the
recent tabs or opens the start page. The start page is the lab's
stand-in for WonderInk's sign-in (tools/lab/wonderink-lesson-site.py,
/app) rather than the real site.
Steps, in order:
open:PATH open SITE+PATH (or a full http address) in a new tab, in front
worksheet:NAME a worksheet PDF downloaded and opened in a tab
signin click the stand-in's Sign In (a real click)
touch one real click on the page in front
front:N bring tab N to the front, as clicking it does (a real click)
close:N close tab N, as its × does
move:A:B drag tab A to where B is
preview:N the print preview open N seconds, nobody touching it
print Print, and the preview's Print button pressed by a real click
wait:N N seconds, nobody touching anything
state what the tabs are, and what the kept file says
quit close the window
crash SIGKILL, no chance to write anything
"""
import importlib.machinery
import importlib.util
import json
import os
import signal
import sys
import time
from pathlib import Path
loader = importlib.machinery.SourceFileLoader("wonderink", sys.argv[1])
spec = importlib.util.spec_from_loader("wonderink", loader)
wi = importlib.util.module_from_spec(spec)
loader.exec_module(wi)
from PyQt6.QtCore import QEvent, QEventLoop, QPointF, Qt, QTimer # noqa: E402
from PyQt6.QtGui import QMouseEvent, QPageSize, QPainter, QPdfWriter # noqa: E402
from PyQt6.QtWidgets import QApplication # noqa: E402
site, steps = sys.argv[2].rstrip("/"), sys.argv[3:]
wi.HOME_PAGE = f"{site}/app"
wi.notify = lambda summary, body: print("NOTIFY", summary, "|", body, flush=True)
app = QApplication(sys.argv[:1])
t0 = time.monotonic()
def stamp(message):
print(f"[{time.monotonic() - t0:6.2f}s] {message}", flush=True)
profile = wi.build_profile(app, "workspace-rig")
wi.DOWNLOADS.mkdir(parents=True, exist_ok=True)
window = wi.WonderInk(profile)
profile.downloadRequested.connect(window.on_download)
keeper = wi.open_workspace(window)
window.resize(1100, 700)
window.show()
stamp(f"START tabs={window._tabs.count()} attempts={keeper._attempts}")
def spin(seconds, until=None):
end = time.monotonic() + seconds
while time.monotonic() < end:
app.processEvents(QEventLoop.ProcessEventsFlag.AllEvents, 50)
if until is not None and until():
return True
time.sleep(0.01)
return False
def js(code, view=None):
box = []
(view or window.current()).page().runJavaScript(code, lambda result: box.append(result))
spin(5, lambda: bool(box))
return box[0] if box else None
def press(widget, point, button=Qt.MouseButton.LeftButton):
for kind, buttons in ((QEvent.Type.MouseButtonPress, button),
(QEvent.Type.MouseButtonRelease, Qt.MouseButton.NoButton)):
QApplication.sendEvent(widget, QMouseEvent(kind, point, QPointF(widget.mapToGlobal(point)),
button, buttons, Qt.KeyboardModifier.NoModifier))
def click_page(selector="body"):
view = window.current()
where = js("(function(){var e=document.querySelector(%s); if(!e) return [20, 20]; var r=e.getBoundingClientRect();"
"return [r.x+Math.min(r.width/2, 40), r.y+Math.min(r.height/2, 10)]})()" % json.dumps(selector)) or [20, 20]
zoom = view.zoomFactor()
press(view.focusProxy(), QPointF(where[0] * zoom, where[1] * zoom))
def where_of(view):
# A restored tab not yet shown is the tab it will load when it is.
if view in window._pending:
kept = keeper._pending_tabs[view]
if kept["kind"] == "worksheet":
return "worksheet:" + Path(kept["where"]).name
return kept["where"].replace(site, "")
if view in window._pdf_views:
return "worksheet:" + window._pdf_views[view].name
return view.url().toString().replace(site, "")
def state(label="STATE"):
tabs = [where_of(window._tabs.widget(i)) for i in range(window._tabs.count())]
# What the stand-in's start page says in each tab that shows it.
pages = [js("document.getElementById('state') ? document.getElementById('state').textContent : ''",
window._tabs.widget(i)) for i in range(window._tabs.count())]
kept = "none"
if wi.WORKSPACE_FILE.exists():
data = json.loads(wi.WORKSPACE_FILE.read_text())
kept = (f"tabs={len(data['tabs'])} active={data['active']} attempts={data['attempts']} "
f"age={time.time() - data['last_activity']:.0f}")
stamp(f"{label} tabs={tabs} active={window._tabs.currentIndex()} kept[{kept}] pages={[p for p in pages if p]} "
f"unshown={len(window._pending)}")
def make_worksheet(name):
path = wi.DOWNLOADS / f"{name}.pdf"
writer = QPdfWriter(str(path))
writer.setPageSize(QPageSize(QPageSize.PageSizeId.Letter))
painter = QPainter(writer)
painter.drawText(200, 400, f"WORKSHEET {name}")
painter.end()
return path
def run():
spin(3)
for step in steps:
name, _, rest = step.partition(":")
stamp(f"STEP {step}")
if name == "open":
window.open_tab(rest if rest.startswith("http") else site + rest)
spin(2.5)
elif name == "worksheet":
window.open_pdf(make_worksheet(rest))
spin(2.5)
elif name == "signin":
click_page("#signin")
spin(1.5)
elif name == "touch":
click_page()
spin(1.5)
elif name == "front":
bar = window._tabs.tabBar()
press(bar, QPointF(bar.tabRect(int(rest)).center()))
spin(1.5)
elif name == "close":
window.close_tab(int(rest))
spin(1.5)
elif name == "move":
a, b = (int(n) for n in rest.split(":"))
window._tabs.tabBar().moveTab(a, b)
spin(1.5)
elif name == "preview":
QTimer.singleShot(int(float(rest) * 1000), lambda: window._preview.reject() if window._preview else None)
window.print_view(window.current())
spin(1.5)
elif name == "print":
def pressed():
preview = window._preview
if preview is None or preview._pdf is None:
return QTimer.singleShot(200, pressed)
button = preview._print
press(button, QPointF(button.rect().center()))
QTimer.singleShot(500, pressed)
window.print_view(window.current())
spin(2)
stamp(f"PRINTED preview_open={window._preview is not None}")
elif name == "wait":
spin(float(rest))
elif name == "state":
state()
elif name == "quit":
window.close()
spin(1)
state("QUIT")
app.quit()
return
elif name == "crash":
stamp("CRASH")
os.kill(os.getpid(), signal.SIGKILL)
app.quit()
QTimer.singleShot(0, run)
QTimer.singleShot(240_000, lambda: (stamp("TIMEOUT"), app.exit(3)))
sys.exit(app.exec())