Files
Hermit/app/src/ui/UiTest.cpp
T
WHC 3fd039cb51 v0.8.14: icons from the desktop's theme (Breeze), readable text, composer frame
The window drew its icons with unicode glyphs -- a clip emoji for attach, a
gear, a hamburger, a chevron, a multiplication sign for delete. Those render
in whichever font happens to cover them (colour emoji in a grey toolbar) and
are a box on a machine without that glyph. They now come from the icon theme,
the way every other desktop app gets them.

Icons
- app/src/ui/ThemeIcons.{h,cpp}: a QQuickImageProvider for
  image://csttheme/<name>?color=%23rrggbb -- QIcon::fromTheme() in the
  desktop's theme (whatever the platform theme set, else breeze-dark, breeze,
  hicolor), tinted with QPainter's SourceIn and rasterised at the requested
  size and the screen's device pixel ratio. Qt only builds its theme search
  paths from XDG once a theme name is set, so the provider fills them itself
  (GenericDataLocation/icons, ~/.icons and, on NixOS,
  /run/current-system/sw/share/icons, for a shell started outside the desktop
  session). An unknown name yields a null image, which QML reports as
  Image.Error.
- app/qml/ThemeIcon.qml: the one component that asks for an icon. Every use
  still names the old character as `glyph:`, so a host with no icon theme
  degrades to exactly what the window looked like before instead of going
  blank. installThemeIcons() is called by main() and by --uitest, so the test
  loads the same icons the app does.
- Replaced: nav (application-menu), settings (settings-configure), attach
  (mail-attachment), copy (edit-copy), session delete (edit-delete), new
  session (list-add), expand/collapse (arrow-right/arrow-down), jump to the end
  (go-down), the plan checklist (task-complete/box). A tool card also shows
  what kind of tool ran -- Theme.toolIcon(): pencil for patch/write, text page
  for read, folder for list/glob, magnifier for grep, braces for a shell,
  globe for web, list for a plan. Toolbar icons are 22 px (Breeze's toolbar
  size), card and row icons 14-16 px.

Colours (the "some text is unreadable" part)
- textFaint #6d757b -> #8d959c: ~3.4:1 -> ~5:1 on the card fill. It carries
  hints, provider URLs, session metadata and placeholders, so it is text, not
  decoration.
- A selected provider row and the open session row write on the accent fill;
  they now use accentText / accentTextDim / okOnAccent instead of faint grey
  and green on blue (named "accentText", not "onAccent": QML reads any `onFoo`
  property as a signal handler and refuses the assignment).
- The filled buttons (Send, Use this) take their ink from Theme.accentInk.

Composer
- The frame is one Theme.pad around its column on every side (anchors.fill,
  implicitHeight + 2*pad). With the top margin alone the last row -- the clip
  and Send -- hung over the lower border, which is what put the clip on the
  frame instead of inside it.

app/README.md gains an "Icons, colors" section (the provider, why the theme
search paths are filled by hand, the qtsvg requirement for rasterising Breeze,
the tool map, and the contrast rules).

Verified: --uitest 52/52, --selftest OK, and the 0.8.14 window relaunched in
the Plasma session (no QML warnings).

Version 0.8.14.

mode = coding
2026-10-11 12:53:49 +08:00

480 lines
20 KiB
C++

/* --uitest: drive the window with synthesized input.
*
* The GUI is checked the way a person uses it -- a mouse click on a segment,
* a finger tap on another, a narrowed window that has to collapse the
* sidebar -- instead of only loading the QML and hoping. Qt's test event
* synthesis does the pointing, so this runs on any platform, with or without
* a touchscreen, and can be scripted (see app/README.md). */
#include "UiTest.h"
#include <QGuiApplication>
#include <QClipboard>
#include <QFile>
#include <QImage>
#include <QJsonDocument>
#include <QJsonObject>
#include <QPointingDevice>
#include <QQuickItem>
#include <QQmlApplicationEngine>
#include <QQmlContext>
#include <QQuickWindow>
#include <QTest>
#include <QVariantMap>
#include <cstdio>
#include <cstring>
#include "cst_agent.h"
#include "ui/ThemeIcons.h"
namespace app {
namespace {
int g_fail;
void check(bool ok, const QString &what) {
std::printf("%s %s\n", ok ? "ok " : "FAIL", qPrintable(what));
std::fflush(stdout);
if (!ok)
g_fail++;
}
QQuickItem *byName(QQuickItem *root, const QString &name) {
if (!root)
return nullptr;
if (root->objectName() == name)
return root;
const QList<QQuickItem *> kids = root->childItems();
for (QQuickItem *c : kids)
if (QQuickItem *hit = byName(c, name))
return hit;
return nullptr;
}
int countNamed(QQuickItem *root, const QString &prefix) {
if (!root)
return 0;
int n = root->objectName().startsWith(prefix) ? 1 : 0;
const QList<QQuickItem *> kids = root->childItems();
for (QQuickItem *c : kids)
n += countNamed(c, prefix);
return n;
}
/* An item by objectName. The QObject tree is tried first (it holds the named
* controls); a view's delegates can be owned by the model instead, so the
* visual tree -- what a finger can actually reach -- is walked as well. */
QQuickItem *item(QQuickWindow *win, const QString &name) {
if (QQuickItem *it = win->findChild<QQuickItem *>(name))
return it;
return byName(win->contentItem(), name);
}
/* The centre of an item, in window coordinates. */
QPoint centre(QQuickItem *it) {
const QPointF p = it->mapToScene(QPointF(it->width() / 2, it->height() / 2));
return p.toPoint();
}
void clickMouse(QQuickWindow *win, const QString &name) {
QQuickItem *it = item(win, name);
if (!it || !it->isVisible()) {
check(false, QStringLiteral("mouse click %1: not there").arg(name));
return;
}
QTest::mouseClick(win, Qt::LeftButton, Qt::NoModifier, centre(it));
}
void tapTouch(QQuickWindow *win, QPointingDevice *dev, const QString &name) {
QQuickItem *it = item(win, name);
if (!it || !it->isVisible()) {
check(false, QStringLiteral("touch tap %1: not there").arg(name));
return;
}
const QPoint p = centre(it);
QTest::touchEvent(win, dev).press(0, p, win);
QTest::touchEvent(win, dev).release(0, p, win);
}
void resizeTo(QQuickWindow *win, int w, int h) {
win->resize(w, h);
QTest::qWait(250); /* let the layouts settle */
}
/* A stored session with a tool call in it, written through the store's own
* API, so the file is exactly what the engine reads back. */
QString writeFixtureSession(const QString &ws, const QString &id) {
const QByteArray w = ws.toUtf8();
char path[4096] = "";
if (cst_agent_store_path_for_id(w.constData(), id.toUtf8().constData(), path,
sizeof(path)) != 0)
return QString();
cst_session_t *s = cst_session_open(path);
if (!s)
return QString();
cst_message_t m;
memset(&m, 0, sizeof(m));
m.role = CST_ROLE_USER;
m.content = strdup("what is here?");
cst_session_append(s, &m);
memset(&m, 0, sizeof(m));
m.role = CST_ROLE_ASSISTANT;
m.content = strdup("let me look. ");
m.tool_calls_json = strdup(
"[{\"id\":\"call_1\",\"type\":\"function\",\"function\":"
"{\"name\":\"read_file\",\"arguments\":\"{\\\"path\\\":"
"\\\"README.md\\\"}\"}}]");
cst_session_append(s, &m);
memset(&m, 0, sizeof(m));
m.role = CST_ROLE_TOOL;
m.content = strdup("hello\n");
snprintf(m.tool_call_id, sizeof(m.tool_call_id), "%s", "call_1");
snprintf(m.tool_name, sizeof(m.tool_name), "%s", "read_file");
cst_session_append(s, &m);
memset(&m, 0, sizeof(m));
m.role = CST_ROLE_ASSISTANT;
m.reasoning = strdup("checked the file");
m.content = strdup("a repo.");
cst_session_append(s, &m);
cst_session_close(s);
return id;
}
/* A picture of an interesting state on request: the only way to look at the
* layout on a machine without a screen (CST_APP_SHOT=shot.png makes the run
* write shot.png at the end and shot-settings.png while the settings dialog
* is open). */
void maybeShot(QQuickWindow *win, const QString &suffix) {
if (!qEnvironmentVariableIsSet("CST_APP_SHOT"))
return;
const QString base = qEnvironmentVariable("CST_APP_SHOT");
const QString path =
suffix.isEmpty() ? base
: base.endsWith(QLatin1String(".png"))
? base.left(base.size() - 4) + suffix + QStringLiteral(".png")
: base + suffix + QStringLiteral(".png");
std::printf("%s %s\n",
win->grabWindow().save(path) ? "note wrote" : "note cannot write",
qPrintable(path));
}
} // namespace
int runUiTest(AgentCore &core, const QString &size) {
int width = 1000, height = 720;
const int x = size.indexOf(QLatin1Char('x'));
if (x > 0) {
const int w = size.left(x).toInt(), h = size.mid(x + 1).toInt();
if (w > 0 && h > 0) {
width = w;
height = h;
}
}
QQmlApplicationEngine engine;
installThemeIcons(&engine); /* the window's icons, same as main() */
engine.rootContext()->setContextProperty(QStringLiteral("agent"), &core);
engine.rootContext()->setContextProperty(QStringLiteral("initialWidth"), width);
engine.rootContext()->setContextProperty(QStringLiteral("initialHeight"), height);
engine.load(QUrl(QStringLiteral("qrc:/qt/qml/CstAgent/Main.qml")));
if (engine.rootObjects().isEmpty()) {
std::printf("FAIL the window did not load\n");
return 1;
}
QQuickWindow *win = qobject_cast<QQuickWindow *>(engine.rootObjects().value(0));
if (!win) {
std::printf("FAIL the root object is not a window\n");
return 1;
}
QTest::qWait(250);
QQuickItem *docked = item(win, QStringLiteral("dockedSidebar"));
QQuickItem *navButton = item(win, QStringLiteral("navButton"));
QQuickItem *navPanel = item(win, QStringLiteral("navPanel"));
QQuickItem *composer = item(win, QStringLiteral("composerInput"));
QPointingDevice *touch = QTest::createTouchDevice();
/* --- a desktop window: the sidebar is docked, the drawer is gone --- */
check(docked && docked->isVisible(), QStringLiteral("wide window: sidebar docked"));
check(navButton && !navButton->isVisible(),
QStringLiteral("wide window: no drawer button"));
check(core.mode() == QLatin1String("coding"),
QStringLiteral("engine starts in coding mode"));
/* --- mouse: pick the other mode --- */
clickMouse(win, QStringLiteral("modeGeneral"));
check(core.mode() == QLatin1String("general"),
QStringLiteral("mouse click on \"general\" reaches the engine"));
check(item(win, QStringLiteral("modeGeneral"))->property("current").toBool(),
QStringLiteral("mouse click marks \"general\" as current"));
/* --- touch: tap the first mode back --- */
tapTouch(win, touch, QStringLiteral("modeCoding"));
check(core.mode() == QLatin1String("coding"),
QStringLiteral("touch tap on \"coding\" reaches the engine"));
/* --- mouse: the plan switch is a two-way control --- */
clickMouse(win, QStringLiteral("planSwitch"));
check(core.planMode(), QStringLiteral("plan switch turns plan mode on"));
clickMouse(win, QStringLiteral("planSwitch"));
check(!core.planMode(), QStringLiteral("plan switch turns it off again"));
/* --- keyboard: typing in the composer arms Send --- */
QQuickItem *send = item(win, QStringLiteral("sendButton"));
if (composer && send) {
composer->forceActiveFocus();
/* QTest::keyClicks() is QWidget-only; a window takes one key at a time. */
for (const QChar &c : QStringLiteral("hello"))
QTest::keyClick(win, c.toLatin1());
check(send->property("enabled").toBool(),
QStringLiteral("typing arms the send button"));
QTest::keyClick(win, Qt::Key_A, Qt::ControlModifier);
QTest::keyClick(win, Qt::Key_Backspace);
check(!send->property("enabled").toBool(),
QStringLiteral("an empty composer disarms it"));
} else {
check(false, QStringLiteral("composer and send button are reachable"));
}
/* --- sessions: the sidebar is the store's face ------------------------ */
QQuickItem *list = item(win, QStringLiteral("transcript"));
const QString fixture =
writeFixtureSession(core.workspace(), QStringLiteral("20260101-120000-fix"));
check(!fixture.isEmpty() && list != nullptr,
QStringLiteral("a session fixture with a tool call is in the store"));
QMetaObject::invokeMethod(win, "reloadSessions");
QTest::qWait(150);
int rowsSeen = countNamed(win->contentItem(), QStringLiteral("sessionRow:"));
if (qEnvironmentVariableIsSet("CST_APP_DUMP"))
std::printf("note: %d session rows in the visual tree\n", rowsSeen);
const int listed = win->property("sessionsView").toList().size();
check(item(win, QStringLiteral("sessionRow:") + fixture) != nullptr,
QStringLiteral("the stored session shows up in the sidebar (%1 rows, %2 listed)")
.arg(rowsSeen)
.arg(listed));
check(core.sessions().size() >= 2,
QStringLiteral("the store lists it next to the open one"));
/* the fixture is: user, agent + call, result, thought, agent */
clickMouse(win, QStringLiteral("sessionRow:") + fixture);
check(core.sessionId() == fixture,
QStringLiteral("a click on a row switches the engine"));
check(list && list->property("count").toInt() == 5,
QStringLiteral("the reopened transcript is replayed into the view"));
{
bool toolOk = false;
const QVariantList rows = core.history();
for (const QVariant &v : rows) {
const QVariantMap r = v.toMap();
if (r.value(QStringLiteral("kind")).toString() == QLatin1String("tool") &&
r.value(QStringLiteral("name")).toString() == QLatin1String("read_file"))
toolOk = r.value(QStringLiteral("complete")).toBool() &&
r.value(QStringLiteral("output")).toString() ==
QLatin1String("hello\n");
}
check(toolOk, QStringLiteral("a tool result is paired with its call"));
}
/* new sessions: the engine moves, the view empties, the store grows */
const int before = core.sessions().size();
clickMouse(win, QStringLiteral("newSessionButton"));
const QString first = core.sessionId();
check(first != fixture, QStringLiteral("New starts another session"));
clickMouse(win, QStringLiteral("newSessionButton"));
const QString second = core.sessionId();
check(second != first, QStringLiteral("New starts another one each time"));
check(core.sessions().size() == before + 2,
QStringLiteral("both new sessions are in the store"));
check(list && list->property("count").toInt() == 0,
QStringLiteral("a fresh session starts with an empty view"));
/* the x drops a stored session and leaves the open one alone */
QTest::qWait(150);
clickMouse(win, QStringLiteral("sessionDelete:") + first);
check(core.sessions().size() == before + 1,
QStringLiteral("the x drops a stored session"));
check(core.sessionId() == second,
QStringLiteral("deleting another session leaves the open one alone"));
/* and back to the fixture, which still has its five rows */
QTest::qWait(150);
clickMouse(win, QStringLiteral("sessionRow:") + fixture);
check(core.sessionId() == fixture && list && list->property("count").toInt() == 5,
QStringLiteral("switching back replays it again"));
/* --- provider, model, key: the settings dialog ------------------------ */
check(core.providers().size() > 0 &&
!core.providers().first().toMap().value(QStringLiteral("name"))
.toString()
.isEmpty(),
QStringLiteral("the provider presets are readable"));
check(core.useProvider(QStringLiteral("no-such-provider"), QString(), QString())
.contains(QLatin1String("no such provider")),
QStringLiteral("switching to an unknown provider is refused"));
check(core.usage().contains(QStringLiteral("requests")),
QStringLiteral("the token counters are readable"));
clickMouse(win, QStringLiteral("settingsButton"));
QQuickItem *panel = item(win, QStringLiteral("settingsPanel"));
check(panel && panel->isVisible(), QStringLiteral("the gear opens the settings dialog"));
check(countNamed(win->contentItem(), QStringLiteral("providerRow:")) > 0,
QStringLiteral("the dialog lists the providers"));
maybeShot(win, QStringLiteral("-settings"));
QQuickItem *mfield = item(win, QStringLiteral("settingsModel"));
if (mfield && panel) {
mfield->setProperty("text", QStringLiteral("cst-test-model"));
clickMouse(win, QStringLiteral("settingsApply"));
check(core.model() == QLatin1String("cst-test-model"),
QStringLiteral("the model picked in the dialog reaches the engine"));
QQuickItem *note = item(win, QStringLiteral("settingsNote"));
check(note && note->property("text").toString().contains(
QLatin1String("cst-test-model")),
QStringLiteral("the dialog says what it is using now"));
clickMouse(win, QStringLiteral("settingsClose"));
check(!panel->isVisible(), QStringLiteral("Close puts the dialog away"));
} else {
check(false, QStringLiteral("the dialog has a model field"));
}
/* --- attachments: a file goes next to the session -------------------- */
const QString att = core.workspace() + QStringLiteral("/note.txt");
{
QFile f(att);
f.open(QIODevice::WriteOnly);
f.write("hello from a fixture\n");
}
check(core.attachFile(att).isEmpty(),
QStringLiteral("a file on disk can be attached"));
check(core.pendingAttachments().size() == 1,
QStringLiteral("the engine reports one attachment for the next prompt"));
check(core.pendingAttachments().first().contains(QLatin1String("note.txt")),
QStringLiteral("the attachment names the file it came from"));
check(!core.attachFile(QStringLiteral("/no/such/file-here")).isEmpty(),
QStringLiteral("a missing file is refused with a reason"));
QTest::qWait(120);
check(countNamed(win->contentItem(), QStringLiteral("attachChip")) == 1,
QStringLiteral("the composer lists it"));
maybeShot(win, QStringLiteral("-attach"));
clickMouse(win, QStringLiteral("clearAttachments"));
check(core.pendingAttachments().isEmpty(),
QStringLiteral("clear all drops them"));
/* the paste path: an image on the clipboard becomes an attachment */
QGuiApplication *gui = qobject_cast<QGuiApplication *>(QCoreApplication::instance());
if (gui && gui->clipboard()) {
QImage img(8, 8, QImage::Format_RGB32);
img.fill(Qt::red);
gui->clipboard()->setImage(img);
check(core.clipboardHasImage(), QStringLiteral("the clipboard image is seen"));
check(core.attachClipboard().isEmpty() &&
core.pendingAttachments().size() == 1,
QStringLiteral("an image on the clipboard is attached"));
core.clearPending();
} else {
check(false, QStringLiteral("there is a clipboard to paste from"));
}
/* --- tool calls: two calls of one tool are two cards ------------------ */
const int rows0 = list ? list->property("count").toInt() : -1;
auto rowAt = [&](int i) {
QVariant v;
QVariantMap r;
if (!QMetaObject::invokeMethod(win, "rowJson", Q_RETURN_ARG(QVariant, v),
Q_ARG(QVariant, i)))
return r;
return QJsonDocument::fromJson(v.toString().toUtf8()).object().toVariantMap();
};
auto event4 = [&](const char *fn, const QVariant &a, const QVariant &b,
const QVariant &c, const QVariant &d) {
return QMetaObject::invokeMethod(win, fn, Q_ARG(QVariant, a),
Q_ARG(QVariant, b), Q_ARG(QVariant, c),
Q_ARG(QVariant, d));
};
check(event4("engineToolEvent", QStringLiteral("open"), QStringLiteral("call-1"),
QStringLiteral("list_dir"), QStringLiteral("{\"path\":\"vendor\"}")) &&
list->property("count").toInt() == rows0 + 1,
QStringLiteral("a tool call opens a card"));
event4("engineToolEvent", QStringLiteral("run"), QStringLiteral("call-1"),
QStringLiteral("list_dir"), QString());
/* the same tool a second time, while the first call is still open: pairing
by name is what left the first card on "running" forever */
event4("engineToolEvent", QStringLiteral("open"), QStringLiteral("call-2"),
QStringLiteral("list_dir"), QStringLiteral("{\"path\":\"src\"}"));
event4("engineToolResult", QStringLiteral("call-1"), QStringLiteral("list_dir"),
QVariant(0), QStringLiteral("exit=0 bytes=532"));
check(list->property("count").toInt() == rows0 + 2,
QStringLiteral("two calls of one tool are two cards (%1 -> %2)")
.arg(rows0)
.arg(list->property("count").toInt()));
{
const QVariantMap first = rowAt(rows0), second = rowAt(rows0 + 1);
check(first.value(QStringLiteral("state")).toString() == QLatin1String("done") &&
first.value(QStringLiteral("output")).toString() ==
QLatin1String("exit=0 bytes=532"),
QStringLiteral("the result lands on the card of its own call "
"(state '%1', output '%2', keys [%3])")
.arg(first.value(QStringLiteral("state")).toString(),
first.value(QStringLiteral("output")).toString(),
first.keys().join(QLatin1Char(','))));
check(second.value(QStringLiteral("state")).toString() == QLatin1String("pending") &&
second.value(QStringLiteral("detail")).toString().contains(
QLatin1String("\"src\"")) &&
second.value(QStringLiteral("output")).toString().isEmpty(),
QStringLiteral("the other card is untouched (state '%1')")
.arg(second.value(QStringLiteral("state")).toString()));
}
/* and a turn that ends with the call still open settles it */
check(QMetaObject::invokeMethod(win, "engineTurnFinished", Q_ARG(QVariant, 0),
Q_ARG(QVariant, QString())),
QStringLiteral("the turn end is reachable too"));
{
const QVariantMap second = rowAt(rows0 + 1);
check(second.value(QStringLiteral("complete")).toBool() &&
second.value(QStringLiteral("state")).toString() ==
QLatin1String("unfinished"),
QStringLiteral("an open call is settled when the turn ends"));
}
/* --- the answer is rendered while it streams -------------------------- */
if (QMetaObject::invokeMethod(win, "engineAssistant", Q_ARG(QVariant, QStringLiteral("**bold** text")))) {
const int last = list->property("count").toInt() - 1;
QMetaObject::invokeMethod(win, "renderStreaming");
const QVariantMap row = rowAt(last);
check(row.value(QStringLiteral("kind")).toString() == QLatin1String("message") &&
!row.value(QStringLiteral("complete")).toBool(),
QStringLiteral("a delta opens a streaming answer"));
check(row.value(QStringLiteral("html")).toString().contains(QLatin1Char('<')) &&
row.value(QStringLiteral("text")).toString().contains(QLatin1String("bold")),
QStringLiteral("markdown is rendered while the answer streams"));
} else {
check(false, QStringLiteral("assistant deltas are reachable"));
}
/* --- a narrow window: dock -> drawer, and the button opens it --- */
resizeTo(win, 460, 900);
check(docked && !docked->isVisible(),
QStringLiteral("narrow window: the docked sidebar folds away"));
check(navButton && navButton->isVisible(),
QStringLiteral("narrow window: the drawer button appears"));
clickMouse(win, QStringLiteral("navButton"));
check(navPanel && navPanel->isVisible(),
QStringLiteral("drawer opens over the transcript"));
clickMouse(win, QStringLiteral("navButton"));
check(navPanel && !navPanel->isVisible(),
QStringLiteral("drawer closes again"));
resizeTo(win, 1200, 820);
check(docked && docked->isVisible(),
QStringLiteral("widening again docks the sidebar back"));
/* A picture of the last state on request (see maybeShot). */
maybeShot(win, QString());
std::printf("--- %s\n", g_fail ? "FAILED" : "all UI checks passed");
return g_fail ? 1 : 0;
}
} // namespace app