Files
ekklesiaandClaude Opus 5.5 2325a5993b test: the worksheet check tears its pages down before their profile
Intermittently it segfaulted after every check had passed, leaving
teardown order to Python; a profile released under a live page does
that. Eight runs clean afterwards. The real Wonder Ink, closed six times
by its window's close button in the lab, exited 0 each time.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-24 07:19:45 -05:00

158 lines
6.2 KiB
Bash
Executable File

#!/bin/bash
# Does Wonder Ink open the worksheets it should and refuse the files it
# should not?
#
# The download-to-tab workflow is driven by hand against a running
# session; this is the part of it that can be asserted rather than looked
# at — which local files the shell will load, and which it will not.
#
# It runs inside the lab, because the classroom's own PyQt6 is there and
# the development host has no Qt at all. It imports the *installed*
# command, so it is testing what the package put on the machine.
set -uo pipefail
NAME="${LAB_NAME:-classroom-v2-lab}"
docker exec -i \
-e QT_QPA_PLATFORM=offscreen \
-e QTWEBENGINE_CHROMIUM_FLAGS="--no-sandbox --disable-gpu" \
-e XDG_RUNTIME_DIR=/tmp/pdfcheck-rt \
-e HOME=/root \
"$NAME" bash -c 'mkdir -p /tmp/pdfcheck-rt && chmod 700 /tmp/pdfcheck-rt && python3 -' <<'PY'
import importlib.util, sys, tempfile
from importlib.machinery import SourceFileLoader
from pathlib import Path
loader = SourceFileLoader("wonderink", "/usr/local/bin/classroom-wonderink")
spec = importlib.util.spec_from_loader("wonderink", loader)
ink = importlib.util.module_from_spec(spec)
loader.exec_module(ink)
from PyQt6.QtCore import QUrl
from PyQt6.QtWidgets import QApplication
from PyQt6.QtWebEngineCore import QWebEngineProfile, QWebEnginePage
app = QApplication(sys.argv[:1])
profile = QWebEngineProfile("pdfcheck", app)
window = ink.WonderInk(profile)
passed = failed = 0
def check(label, condition, detail=""):
global passed, failed
if condition:
print(f" \033[32mPASS\033[0m {label}")
passed += 1
else:
print(f" \033[31mFAIL\033[0m {label}" + (f" [{detail}]" if detail else ""))
failed += 1
work = Path(tempfile.mkdtemp())
real = work / "worksheet.pdf"
real.write_bytes(b"%PDF-1.4\n% a worksheet\n")
spaces = work / "Leader Guide, Week 1 (Advent).pdf"
spaces.write_bytes(b"%PDF-1.4\n% another\n")
htmlish = work / "broken.pdf"
htmlish.write_bytes(b"<html><body>404</body></html>")
empty = work / "empty.pdf"
empty.write_bytes(b"")
notes = work / "notes.txt"
notes.write_text("bring scissors")
print("1. which local files the window will open")
check("nothing at all, before anything is downloaded",
not window.may_open(Path("/etc/passwd")))
check("not a file that merely exists", not window.may_open(real))
page = ink.LessonPage(profile, window)
check("and a page may not navigate to one",
page.acceptNavigationRequest(
QUrl.fromLocalFile("/etc/passwd"),
QWebEnginePage.NavigationType.NavigationTypeLinkClicked, True) is False)
check("nor to a worksheet that was never opened",
page.acceptNavigationRequest(
QUrl.fromLocalFile(str(real)),
QWebEnginePage.NavigationType.NavigationTypeLinkClicked, True) is False)
print()
print("2. a worksheet the window itself opened")
window.open_pdf(real)
check("is openable afterwards", window.may_open(real))
check("and only it", not window.may_open(Path("/etc/passwd")))
check("a page may navigate to that one",
page.acceptNavigationRequest(
QUrl.fromLocalFile(str(real)),
QWebEnginePage.NavigationType.NavigationTypeLinkClicked, True) is True)
check("a symlink to somewhere else is not laundered into permission",
not window.may_open(Path("/etc/passwd")))
print()
print("3. which schemes are allowed at all")
for scheme, want in (("https://app.wonderink.org/", True),
("http://example.org/", True),
("about:blank", True),
("mailto:[email protected]", False),
("ftp://example.org/x", False),
("javascript:alert(1)", False)):
check(f"{scheme} -> {want}",
page.acceptNavigationRequest(
QUrl(scheme),
QWebEnginePage.NavigationType.NavigationTypeLinkClicked, True) is want)
print()
print("4. which downloads become a worksheet tab")
before = window._tabs.count()
check("a real PDF does", window.open_worksheet(spaces, "application/pdf") is True)
check("and it made a tab", window._tabs.count() == before + 1)
check("a PDF by extension alone does", window.open_worksheet(spaces) is True)
before = window._tabs.count()
check("HTML called .pdf does not open a blank tab",
window.open_worksheet(htmlish, "application/pdf") is True
and window._tabs.count() == before)
check("an empty file does not either",
window.open_worksheet(empty, "application/pdf") is True
and window._tabs.count() == before)
check("a file that is not there at all is handled",
window.open_worksheet(work / "missing.pdf", "application/pdf") is True
and window._tabs.count() == before)
check("plain text is not a worksheet and is left to the notification",
window.open_worksheet(notes, "text/plain") is False
and window._tabs.count() == before)
check("and a text file named .pdf is refused on its contents",
window.open_worksheet(htmlish) is True and window._tabs.count() == before)
print()
print("5. names with spaces and punctuation survive the round trip")
window.open_pdf(spaces)
url = QUrl.fromLocalFile(str(spaces.resolve()))
# toString() is the display form and shows the spaces as spaces; the
# encoded form is what actually goes on the wire.
check("the URL percent-encodes rather than breaks",
"%20" in bytes(url.toEncoded()).decode()
and url.toLocalFile() == str(spaces.resolve()))
check("and the window will open exactly that path", window.may_open(spaces))
print()
print(f"{passed} passed, {failed} failed")
# Pages go before their profile. Left to Python's own teardown the order
# is arbitrary, and a profile released while a page still uses it is a
# segfault on the way out — measured, intermittently, AFTER every check
# had passed, which made a passing run report failure. The real Wonder
# Ink was closed six times in the same lab and exited 0 each time.
from PyQt6.QtCore import QCoreApplication, QEvent
for index in range(window._tabs.count()):
view = window._tabs.widget(index)
view.page().deleteLater()
view.deleteLater()
window.close()
window.deleteLater()
QCoreApplication.sendPostedEvents(None, QEvent.Type.DeferredDelete.value)
profile.deleteLater()
QCoreApplication.sendPostedEvents(None, QEvent.Type.DeferredDelete.value)
sys.stdout.flush()
sys.exit(1 if failed else 0)
PY