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:
co-authored by
Claude Opus 5.5
parent
b5d9535467
commit
78a844968c
@@ -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")
|
||||
|
||||
@@ -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'"
|
||||
|
||||
@@ -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
|
||||
|
||||
Executable
+234
@@ -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 ]]
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user