Files
docview/tests/test_web_qml.cpp
T
2026-09-21 13:41:40 +09:00

284 lines
19 KiB
C++

#include "web/web_profile.h"
#include "web/resource_policy.h"
#include "web/renderer_watchdog.h"
#include <QFile>
#include <QFileInfo>
#include <QDir>
#include <QQmlEngine>
#include <QQmlContext>
#include <QTcpServer>
#include <QTcpSocket>
#include <QTemporaryDir>
#include <QUuid>
#include <QtQuickTest/quicktest.h>
#include <QtWebEngineQuick>
#ifdef Q_OS_LINUX
#include <sys/syscall.h>
#include <signal.h>
#include <unistd.h>
#endif
class WebTestReader : public QObject {
Q_OBJECT
Q_PROPERTY(QVariantMap settings READ settings NOTIFY settingsChanged)
Q_PROPERTY(QString format READ format CONSTANT)
Q_PROPERTY(QString context MEMBER context NOTIFY contextChanged)
Q_PROPERTY(bool loaded READ loaded NOTIFY loadedChanged)
Q_PROPERTY(int forbiddenConnections READ forbiddenConnections NOTIFY connectionsChanged)
Q_PROPERTY(QVariantMap index READ index NOTIFY indexChanged)
Q_PROPERTY(QVariantMap resourceIssues MEMBER resourceIssues NOTIFY resourceIssuesChanged)
Q_PROPERTY(bool allowNetworkProbe MEMBER allowNetworkProbe)
Q_PROPERTY(QUrl networkProbeUrl READ networkProbeUrl CONSTANT)
Q_PROPERTY(int interceptedRequests MEMBER interceptedRequests NOTIFY resourceIssuesChanged)
Q_PROPERTY(QString notice MEMBER notice NOTIFY noticeChanged)
Q_PROPERTY(int watchdogFailures MEMBER watchdogFailures NOTIFY watchdogChanged)
Q_PROPERTY(int heartbeatStarts MEMBER heartbeatStarts NOTIFY watchdogChanged)
Q_PROPERTY(bool deferCommit MEMBER deferCommit)
Q_PROPERTY(bool canPauseRenderers READ canPauseRenderers CONSTANT)
Q_PROPERTY(bool holdLateResources MEMBER holdLateResources)
Q_PROPERTY(int delayedResources READ delayedResources)
public:
explicit WebTestReader(QObject *parent = nullptr);
~WebTestReader() override { resumeRenderers(); }
QVariantMap settings() const;
QString format() const { return "html"; }
bool loaded() const { return loaded_; }
int forbiddenConnections() const { return connections_; }
QVariantMap index() const { return index_; }
QString context = "content", notice;
QVariantMap resourceIssues;
bool allowNetworkProbe = false;
int interceptedRequests = 0;
int watchdogFailures = 0, heartbeatStarts = 0;
bool deferCommit = false;
bool holdLateResources = false;
int delayedResources() const { return delayedCompletions_.size(); }
Q_INVOKABLE void releaseLateResources() {
holdLateResources = false;
const auto callbacks = std::exchange(delayedCompletions_, {});
for (const auto &done : callbacks) done(docview::ResourceFailure::None);
}
bool canPauseRenderers() const {
#if defined(Q_OS_LINUX) && defined(SYS_pidfd_open) && defined(SYS_pidfd_send_signal)
return true;
#else
return false;
#endif
}
Q_INVOKABLE void registerRenderer(const QString &token, qint64 pid, int slot) {
QString error;
if (!watchdog_.registerRenderer(token, pid, &error, slot)) qFatal("Cannot monitor test renderer: %s", qPrintable(error));
#ifdef Q_OS_LINUX
docview::RendererProcessInfo info;
QFile stat(QStringLiteral("/proc/%1/stat").arg(pid));
if (pid > 0 && stat.open(QIODevice::ReadOnly) && docview::parseRendererProcStat(stat.readAll(), quint64(::sysconf(_SC_PAGESIZE)), &info))
identities_[token + ':' + QString::number(slot)] = {pid, info.startTime};
else identities_.remove(token + ':' + QString::number(slot));
#endif
}
Q_INVOKABLE quint32 beginRendererProbe(const QString &token, int slot) {
const auto id = watchdog_.beginProbe(token, slot);
if (id) { ++heartbeatStarts; emit watchdogChanged(); }
return id;
}
Q_INVOKABLE void finishRendererProbe(const QString &token, int slot, quint32 probe) { watchdog_.acknowledgeProbe(token, slot, probe); }
Q_INVOKABLE bool pauseRenderer(const QString &token, int slot) {
#if defined(Q_OS_LINUX) && defined(SYS_pidfd_open) && defined(SYS_pidfd_send_signal)
const auto identity = identities_.value(token + ':' + QString::number(slot));
if (identity.first <= 1) { qWarning() << "No registered renderer" << slot << watchdog_.monitoredCount(); return false; }
// Only a renderer already accepted by the production ancestry/type
// verifier may be paused, and this pidfd pins its original start time.
const int fd = int(::syscall(SYS_pidfd_open, int(identity.first), 0));
if (fd < 0) { qWarning() << "pidfd_open failed" << errno; return false; }
docview::RendererProcessInfo info;
QFile stat(QStringLiteral("/proc/%1/stat").arg(identity.first));
const bool valid = stat.open(QIODevice::ReadOnly) && docview::parseRendererProcStat(stat.readAll(), quint64(::sysconf(_SC_PAGESIZE)), &info) && info.startTime == identity.second;
if (!valid || ::syscall(SYS_pidfd_send_signal, fd, SIGSTOP, nullptr, 0) != 0) { qWarning() << "Pause refused" << valid << errno; ::close(fd); return false; }
pausedHandles_.append(fd); return true;
#else
Q_UNUSED(token); Q_UNUSED(slot); return false;
#endif
}
Q_INVOKABLE bool hasRenderer(const QString &token, int slot) const { return identities_.contains(token + ':' + QString::number(slot)); }
Q_INVOKABLE void resumeRenderers() {
#if defined(Q_OS_LINUX) && defined(SYS_pidfd_send_signal)
for (const int fd : pausedHandles_) { ::syscall(SYS_pidfd_send_signal, fd, SIGCONT, nullptr, 0); ::close(fd); }
pausedHandles_.clear();
#endif
}
Q_INVOKABLE void cancelPending() {
if (pendingToken_.isEmpty() || pendingToken_ == activeToken_) return;
const auto token = pendingToken_; pendingToken_.clear();
watchdog_.removeSession(token); handlers_[token]->revoke(); emit disposeWeb(token);
loaded_ = !activeToken_.isEmpty(); emit loadedChanged();
}
QUrl networkProbeUrl() const { return QUrl(QString("http://127.0.0.1:%1/probe").arg(server_.serverPort())); }
Q_INVOKABLE void webResourceIssues(const QString &token, const QVariantMap &counts) {
if (token != activeToken_ && token != pendingToken_) return;
for (auto it = counts.begin(); it != counts.end(); ++it)
resourceIssues[it.key()] = resourceIssues.value(it.key()).toInt() + it.value().toInt();
emit resourceIssuesChanged();
}
Q_INVOKABLE QVariantMap webOptions(const QString &) const { return {{"fixed", fixed_}, {"rtl", rtl_}}; }
Q_INVOKABLE void changeReading(int size, double height, bool publisher) {
reading_ = {{"font_size", size}, {"line_height", height}, {"use_publisher_style", publisher}};
emit settingsChanged();
}
Q_INVOKABLE QString script(const QString &) const;
Q_INVOKABLE void open(const QString &relative);
Q_INVOKABLE bool navigationAllowed(const QUrl &url, int type, const QString &token);
Q_INVOKABLE void webLoaded(const QString &token, bool ok);
Q_INVOKABLE void webIndex(const QString &, const QVariantMap &value) { index_ = value; emit indexChanged(); }
Q_INVOKABLE void webLocation(const QString &, const QVariantMap &) {}
Q_INVOKABLE void webStarted(const QString &, const QString &, quint64) {}
Q_INVOKABLE void webApplied(const QString &, const QString &, quint64) {}
Q_INVOKABLE void notify(const QString &message) { notice = message; emit noticeChanged(); }
Q_INVOKABLE void renderStopped() { notify("Renderer stopped"); }
Q_INVOKABLE void execute(const QString &, const QVariantMap & = {}) {}
signals:
void resourceIssuesChanged();
void watchdogChanged();
void contextChanged(); void settingsChanged(); void loadedChanged(); void indexChanged(); void noticeChanged(); void connectionsChanged();
void stageWeb(QQuickWebEngineProfile *profile, QUrl url, QString token);
void activateWeb(QString token); void disposeWeb(QString token); void webCommand(QString id, QVariantMap args);
private:
QTemporaryDir root_;
docview::RendererWatchdog watchdog_{nullptr, docview::RendererWatchdog::DefaultLimitBytes, {}, 1500};
QHash<QString, QPair<qint64, quint64>> identities_;
QList<int> pausedHandles_;
QList<docview::ResourceHandler::Completion> delayedCompletions_;
QTcpServer server_;
QHash<QString, docview::ResourceHandler *> handlers_;
QString activeToken_, pendingToken_;
QVariantMap index_;
QVariantMap reading_{{"font_size", 18}, {"line_height", 1.6}, {"use_publisher_style", true}};
bool fixed_ = false, rtl_ = false;
bool loaded_ = false;
int connections_ = 0;
};
WebTestReader::WebTestReader(QObject *parent) : QObject(parent) {
connect(&watchdog_, &docview::RendererWatchdog::responseTimedOut, this, [this](const QString &token, const QString &code) {
++watchdogFailures;
if (handlers_.contains(token)) handlers_[token]->revoke();
if (token == activeToken_) activeToken_.clear();
if (token == pendingToken_) pendingToken_.clear();
emit disposeWeb(token);
loaded_ = !activeToken_.isEmpty(); emit loadedChanged();
notify(code); emit watchdogChanged();
});
if (!server_.listen(QHostAddress::LocalHost, 0)) qFatal("Cannot open local observation server");
connect(&server_, &QTcpServer::newConnection, this, [this] {
while (auto *socket = server_.nextPendingConnection()) { ++connections_; emit connectionsChanged(); socket->disconnectFromHost(); socket->deleteLater(); }
});
auto write = [this](const QString &path, const QByteArray &bytes) {
QFile file(root_.filePath(path)); if (!file.open(QIODevice::WriteOnly) || file.write(bytes) != bytes.size()) qFatal("Cannot write fixture");
};
write("style.css", "h1{color:rgb(17,34,51)} section{height:900px} .hidden{display:none}");
write("local.svg", "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"80\" height=\"40\"><rect width=\"80\" height=\"40\" fill=\"green\"/></svg>");
write("next.html", "<h1>Unexpected refresh destination</h1>");
write("base.html", "<html><head><base href='folder/other.html'></head><body><h1 id='one'>First</h1><nav role='doc-toc'><a href='#two'>Authored</a></nav><p style='height:1200px'>Body</p><h2 id='two'>Second</h2><p style='height:1200px'>End</p></body></html>");
write("headings.xhtml", "<html xmlns='http://www.w3.org/1999/xhtml' xmlns:epub='http://www.idpf.org/2007/ops'><head><title>XHTML headings</title></head><body>"
"<h1 id='one'>Native first</h1><nav epub:type='toc'><ol><li><a href='#one'>Parent</a><ul><li><a href='#two'>Child</a>"
"<ol><li><a href='#aria'>Grandchild</a></li></ol></li></ul></li></ol></nav><p style='height:1200px'>Body</p>"
"<h2 id='two'>Native second</h2><p style='height:1200px'>Body</p><div id='aria' role='heading' aria-level='3'>ARIA third</div>"
"<div role='heading' aria-level='0'>Invalid level</div><h2 hidden='hidden'>Hidden heading</h2><p style='height:1200px'>End</p></body></html>");
if (!QFile::copy(root_.filePath("headings.xhtml"), root_.filePath("headings.html"))) qFatal("Cannot copy heading fixture");
const QByteArray clampedHeadings = "<html xmlns='http://www.w3.org/1999/xhtml'><head><title>Clamped headings</title><style>"
"html,body{margin:0;padding:0}h1,h2,p{margin:0;font:20px/1 sans-serif}.gap{block-size:1200px}body.vertical{writing-mode:vertical-rl;height:500px}"
"</style></head><body CLASS><h1>First</h1><div class='gap'></div><h2>Middle</h2><div class='gap'></div>"
"<h2>Last A</h2><h2>Last B</h2><p>Short final section.</p></body></html>";
write("clamped-headings.xhtml", QByteArray(clampedHeadings).replace("CLASS", ""));
write("clamped-headings-vertical.xhtml", QByteArray(clampedHeadings).replace("CLASS", "class='vertical'"));
write("nested-headings.xhtml", "<html xmlns='http://www.w3.org/1999/xhtml'><head><title>Nested headings</title><style>"
"html,body{margin:0;padding:0}h1,h2,p{margin:0;font:20px/1 sans-serif}section{height:200px;overflow:auto}"
"</style></head><body><h1>Outer</h1><section id='scroller'><h2>Inner first</h2><div style='height:800px'></div>"
"<h2>Inner last</h2><p style='height:250px'>End</p></section></body></html>");
QByteArray html = R"HTML(<!doctype html><html><head><meta charset="UTF-8"><title>Web safety</title><link rel="stylesheet" href="style.css"><link rel="stylesheet" href="http://127.0.0.1:PORT/outside.css"><style>@font-face{font-family:outside;src:url(http://127.0.0.1:PORT/font.woff)}#font-probe{font-family:outside}</style><meta http-equiv="refresh" content="1;url=next.html"></head><body><h1 id="first">First heading</h1><nav role="doc-toc"><a href="#second">Second heading</a></nav><img id="local-image" src="local.svg"><img src="http://127.0.0.1:PORT/image"><img src="file:///etc/passwd"><img src="doc://other-document/local.svg"><p id="marker">SCRIPT BLOCKED</p><p id="font-probe">Font request must be blocked</p><script>window.executed=true;document.getElementById('marker').textContent='SCRIPT RAN';fetch('http://127.0.0.1:PORT/script');</script><iframe src="http://127.0.0.1:PORT/frame"></iframe><iframe src="file:///etc/passwd"></iframe><form action="http://127.0.0.1:PORT/form"><input id="input" value="ORIGINAL"><button id="submit">Send</button></form><section><h2 class="hidden">Hidden heading</h2><p>Filler text.</p></section><h2 id="second">Second heading</h2><p>This is a stable text quote for position restoration.</p><div role="heading" aria-level="3">ARIA heading</div></body></html>)HTML";
html.replace("PORT", QByteArray::number(server_.serverPort()));
write("index.html", html);
write("vertical.html", "<!doctype html><html><meta charset=\"UTF-8\"><style>body{writing-mode:vertical-rl;height:500px}section{width:1200px}</style><section><h1>Vertical first</h1></section><section><h2>Vertical second</h2></section></html>");
QByteArray reflow = "<!doctype html><html><meta charset=\"UTF-8\"><style>body{font-size:14px!important;line-height:1.3;margin:24px}p{max-width:42em;margin:1em 0}h1{font-size:24px}</style><h1>Logical position</h1>";
for (int i = 0; i < 100; ++i) {
reflow += "<p id='paragraph-" + QByteArray::number(i) + "'>Paragraph " + QByteArray::number(i) + ": ";
for (int j = 0; j < 5; ++j) reflow += "This distinct reading passage verifies the same logical text anchor after font and width changes. ";
reflow += "</p>";
}
write("reflow.html", reflow + "</html>");
QByteArray late = reflow;
late.replace("<h1>Logical position</h1>", "<style>html{overflow-anchor:none}@font-face{font-family:Late;src:url(late-layout.ttf);font-display:swap}</style><h1>Logical position</h1><img id='late-image'>");
write("late-layout.html", late + "</html>");
write("late-vertical.html", late + "<style>body{writing-mode:vertical-rl;height:500px}</style></html>");
write("late-image.svg", "<svg xmlns='http://www.w3.org/2000/svg' width='800' height='900'><rect width='800' height='900' fill='green'/></svg>");
if (!QFile::copy(QFileInfo(QString::fromUtf8(__FILE__)).absolutePath() + "/fixtures/web/late-layout.ttf", root_.filePath("late-layout.ttf"))) qFatal("Cannot copy generated test font");
write("fixed.html", "<!doctype html><html><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=800,height=600\"><style>html,body{margin:0;width:800px;height:600px;overflow:hidden}body{font-size:19px}h1{font-size:24px}#frame{position:absolute;left:0;top:0;width:800px;height:600px;box-sizing:border-box;border:8px solid green}</style><div id=\"frame\"><h1>Fixed viewport</h1><p>Fixed text size</p></div></html>");
write("rtl.html", "<!doctype html><html dir=\"rtl\"><meta charset=\"UTF-8\"><style>body{margin:24px}section{height:1200px}</style><section><h1>RTL first</h1><p>مرحبا بالعالم</p></section><section><h2>RTL second</h2><p>النص الثاني</p></section></html>");
write("search.html", "<!doctype html><html><meta charset=\"UTF-8\"><style>p{margin:400px 0}</style><h1>Find text</h1><p>needle one</p><p>NEEDLE two</p><p>needle three</p><p>日本語 検索</p><p>日本語 検索</p></html>");
for (const auto &name : {QStringLiteral("space name.html"), QStringLiteral("日本語.html"),
QStringLiteral("hash#percent%.html"), QStringLiteral("日本語 j k 123 #%.html")})
write(name, "<!doctype html><meta charset='utf-8'><meta http-equiv='refresh' content='1;url=next.html'>"
"<h1>Encoded path</h1><p id='value'>Correct document</p><div style='height:1500px'></div><h2 id='target'>Target</h2>");
}
QVariantMap WebTestReader::settings() const { return {{"reading", reading_}}; }
QString WebTestReader::script(const QString &) const {
QFile file(QFileInfo(QString::fromUtf8(__FILE__)).absolutePath() + "/../resources/reader.js");
return file.open(QIODevice::ReadOnly) ? QString::fromUtf8(file.readAll()) : QString();
}
void WebTestReader::open(const QString &relative) {
fixed_ = relative == "fixed.html"; rtl_ = relative == "rtl.html";
reading_ = {{"font_size", 18}, {"line_height", 1.6}, {"use_publisher_style", true}};
pendingToken_ = QUuid::createUuid().toString(QUuid::Id128);
resourceIssues.clear(); emit resourceIssuesChanged();
allowNetworkProbe = false; interceptedRequests = 0;
loaded_ = false; index_.clear(); emit loadedChanged();
auto *handler = new docview::ResourceHandler(pendingToken_, root_.path(), {},
[this](const QString &path, docview::ResourceHandler::Completion done) {
if (holdLateResources && (path == "late-image.svg" || path == "late-layout.ttf")) delayedCompletions_.append(std::move(done));
else done(docview::ResourceFailure::None);
});
connect(handler, &docview::ResourceHandler::blocked, this, [this](const QString &, const QString &category, int count) {
if (category == "network") { interceptedRequests += count; emit resourceIssuesChanged(); }
});
auto *profile = docview::createDocumentProfile(pendingToken_, handler, this);
handlers_.insert(pendingToken_, handler);
QUrl url; url.setScheme("doc"); url.setHost(pendingToken_); url.setPath("/" + relative);
handler->authorizeTopLevel(url);
emit stageWeb(profile, url, pendingToken_);
}
bool WebTestReader::navigationAllowed(const QUrl &url, int type, const QString &token) {
// Test-only bypass of the UI navigation gate isolates the independent
// profile interceptor. The production Controller has no such switch.
if (allowNetworkProbe && url == networkProbeUrl()) return true;
if (!handlers_.contains(token) || docview::pathFromDocumentUrl(url, token).isEmpty() || (type != 0 && type != 1 && type != 5)) return false;
handlers_[token]->authorizeTopLevel(url); return true;
}
void WebTestReader::webLoaded(const QString &token, bool ok) {
if (token != pendingToken_) return;
if (ok && deferCommit) return;
loaded_ = ok;
if (ok) {
const auto old = activeToken_; activeToken_ = token; pendingToken_.clear();
emit activateWeb(token);
if (!old.isEmpty()) { handlers_[old]->revoke(); emit disposeWeb(old); }
}
emit loadedChanged();
}
class WebTestSetup : public QObject {
Q_OBJECT
public slots:
void qmlEngineAvailable(QQmlEngine *engine) {
auto *reader = new WebTestReader(this);
engine->rootContext()->setContextProperty("reader", reader);
}
};
int main(int argc, char **argv) {
docview::registerDocumentScheme();
QtWebEngineQuick::initialize();
WebTestSetup setup;
const auto directory = QFileInfo(QString::fromUtf8(__FILE__)).absolutePath() + "/webqml";
return quick_test_main_with_setup(argc, argv, "web_security", directory.toUtf8().constData(), &setup);
}
#include "test_web_qml.moc"