diff --git a/tools/lab/wonderink-lesson-site.py b/tools/lab/wonderink-lesson-site.py index 46cec25..6f32178 100644 --- a/tools/lab/wonderink-lesson-site.py +++ b/tools/lab/wonderink-lesson-site.py @@ -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 +

More links for this lesson

""" +APP = """App +
+ + +
+""" + +LINKS = """Links +

Links in a lesson

+

Saul Welcome Question Slide

+

The lesson, no target

+

A picture on another site

+

The worksheet (PDF)

+

+

+

Go to the bottom

+

+

The bottom

+""" + + 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") diff --git a/tools/lab/wonderink-print-check b/tools/lab/wonderink-print-check index 4b599a8..247adbb 100755 --- a/tools/lab/wonderink-print-check +++ b/tools/lab/wonderink-print-check @@ -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'" diff --git a/tools/lab/wonderink-print-rig.py b/tools/lab/wonderink-print-rig.py index 089bfe7..f742c82 100644 --- a/tools/lab/wonderink-print-rig.py +++ b/tools/lab/wonderink-print-rig.py @@ -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 diff --git a/tools/lab/wonderink-workspace-check b/tools/lab/wonderink-workspace-check new file mode 100755 index 0000000..c062bf0 --- /dev/null +++ b/tools/lab/wonderink-workspace-check @@ -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 ]] diff --git a/tools/lab/wonderink-workspace-rig.py b/tools/lab/wonderink-workspace-rig.py new file mode 100644 index 0000000..9e080ae --- /dev/null +++ b/tools/lab/wonderink-workspace-rig.py @@ -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())