Files
CST-agent/app/src/ui/UiTest.cpp
T

1400 lines
64 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 <QBuffer>
#include <QDateTime>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QImage>
#include <QPalette>
#include <QJsonDocument>
#include <QJsonObject>
#include <QPainter>
#include <QPointingDevice>
#include <QQuickItem>
#include <QQuickStyle>
#include <QQmlApplicationEngine>
#include <QQmlContext>
#include <QQuickWindow>
#include <QTest>
#include <QTextDocument>
#include <QVariantMap>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <memory>
#include "cst_agent.h"
#include "cst_attach.h"
#include "core/ToolProvider.h"
#include "ui/BackKey.h"
#include "ui/SessionImages.h"
#include "ui/ThemeIcons.h"
namespace app {
namespace {
int g_fail;
/* The run's own log file, opened once the workspace is known (see the top of
* the run below): every line stdout gets is written here as well. On a phone
* stdout reaches nobody, and an app started from adb cannot be handed
* CST_APP_SHOT or any other environment -- so `--uitest --workspace DIR` plus
* `adb pull DIR/uitest.log` is how a device run is read back. Empty when the
* file could not be made, in which case the run only prints. */
QFile g_runLog;
/* Qt's own messages go into the same log as the checks: this is where QML
* puts "TypeError: Cannot read property ... of undefined" and every other
* reason a step did nothing, and a device run has nowhere else to show it
* (logcat does not carry this build's Qt messages -- a plain qInfo from the
* app is not in the buffer). Messages before the log is opened still print. */
void logQtMessage(QtMsgType type, const QMessageLogContext &, const QString &msg) {
const char *kind = type == QtDebugMsg ? "debug"
: type == QtInfoMsg ? "info"
: type == QtWarningMsg ? "warn"
: type == QtCriticalMsg ? "crit"
: "fatal";
const QByteArray line = QByteArray(kind) + " " + msg.toUtf8() + "\n";
if (g_runLog.isOpen()) {
g_runLog.write(line);
g_runLog.flush();
}
fwrite(line.constData(), 1, static_cast<size_t>(line.size()), stderr);
if (type == QtFatalMsg)
abort();
}
void check(bool ok, const QString &what) {
std::printf("%s %s\n", ok ? "ok " : "FAIL", qPrintable(what));
std::fflush(stdout);
if (g_runLog.isOpen()) {
g_runLog.write(ok ? "ok " : "FAIL");
g_runLog.write(what.toUtf8());
g_runLog.write("\n");
g_runLog.flush();
}
if (!ok)
g_fail++;
}
/* A line in the run's log that is not a check: what the window looked like at
* a step, for the cases where "it did nothing" is all the checks can say. */
void note(const QString &what) {
std::printf("note %s\n", qPrintable(what));
std::fflush(stdout);
if (g_runLog.isOpen()) {
g_runLog.write("note ");
g_runLog.write(what.toUtf8());
g_runLog.write("\n");
g_runLog.flush();
}
}
/* Every item with this objectName in the visual tree. A view that has just
* been replayed can still hold the delegates it is about to drop, so a name
* can match more than one item for a moment. */
void collectNamed(QQuickItem *root, const QString &name,
QList<QQuickItem *> *out) {
if (!root)
return;
if (root->objectName() == name)
out->append(root);
const QList<QQuickItem *> kids = root->childItems();
for (QQuickItem *c : kids)
collectNamed(c, name, out);
}
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. */
bool onScreen(QQuickItem *it); /* defined below: isVisible() is not enough */
QQuickItem *item(QQuickWindow *win, const QString &name) {
QList<QQuickItem *> hits;
collectNamed(win->contentItem(), name, &hits);
/* What a finger can reach is the one the test means. Two things say
* "visible" without being reachable: a delegate on its way out, and a
* second copy of a control that lives in a closed drawer (the sidebar is
* drawn from the window and, when narrow, from a drawer off to the left).
* Which copy comes first in the tree is not the same on every platform, so
* the one whose centre is really on screen wins. */
for (QQuickItem *it : hits)
if (onScreen(it))
return it;
for (QQuickItem *it : hits)
if (it->isVisible())
return it;
if (QQuickItem *it = win->findChild<QQuickItem *>(name))
return it;
return hits.isEmpty() ? nullptr : hits.first();
}
/* 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();
}
/* Is this item's centre really reachable? isVisible() answers whether hiding
* was asked for, not whether a clipping ancestor (a scrolling view) has moved
* it out of sight -- and an event goes to whatever is at that point instead. */
bool onScreen(QQuickItem *it) {
if (!it || !it->isVisible() || it->width() <= 0 || it->height() <= 0)
return false;
QQuickWindow *win = it->window();
if (!win)
return false;
const QPointF p = it->mapToScene(QPointF(it->width() / 2, it->height() / 2));
if (!QRectF(0, 0, win->width(), win->height()).contains(p))
return false;
for (QQuickItem *a = it->parentItem(); a; a = a->parentItem()) {
if (!a->clip())
continue;
if (!a->mapRectToScene(QRectF(0, 0, a->width(), a->height())).contains(p))
return false;
}
return true;
}
/* An event lands on whatever is at that point, and isVisible() does not know
* about a scrolling ancestor's clipping (see onScreen): an item the view has
* scrolled past still says it is visible, and the click goes to the row that
* happens to be at that pixel -- which is a different row on a window whose
* font makes the rows taller (another platform, another Qt). So a click
* scrolls its target in first, which is what a person does. The nearest
* ancestor that both clips and scrolls (a ListView's own item) is the one to
* move; anchored to its middle, so a row at either edge still gets its whole
* centre inside the viewport. */
void scrollIntoView(QQuickItem *it, bool middle = false) {
if (!it || (!middle && onScreen(it)))
return;
for (QQuickItem *a = it->parentItem(); a; a = a->parentItem()) {
const QVariant contentY = a->property("contentY");
if (!a->clip() || !contentY.isValid() ||
!a->property("contentHeight").isValid())
continue;
const double y = it->mapToItem(a, QPointF(0, 0)).y() + contentY.toDouble();
const double lowest = a->property("contentHeight").toDouble() - a->height();
note(QStringLiteral("%1 is out of the viewport (contentY %2, at %3 of %4): "
"scrolling to it")
.arg(it->objectName())
.arg(contentY.toDouble())
.arg(y)
.arg(a->property("contentHeight").toDouble()));
a->setProperty("contentY", qBound(0.0, y - a->height() / 2, lowest));
QTest::qWait(150);
return;
}
}
/* One rendered frame: items are laid out, and a view's delegates are created,
* in the render loop's polish step. A platform whose window is never exposed
* -- offscreen on Windows, which is how this is run on a machine that has no
* Windows -- only runs that step when something asks for a picture, so
* without this the window can be a whole pass behind: a row still 0 wide, a
* card not created yet. A person looking at the window would see it settled,
* which is what the checks below are about. */
void frame(QQuickWindow *win) {
if (win)
win->grabWindow();
QTest::qWait(50);
}
void clickItem(QQuickWindow *win, QQuickItem *it); /* defined below */
void tapItem(QQuickWindow *win, QPointingDevice *dev, QQuickItem *it);
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;
}
clickItem(win, it);
}
/* The same click, on an item the caller already has: a name can match more
* than one item (a view keeps a delegate from the session before), and the
* one the test means is not always the first in the tree. */
void clickItem(QQuickWindow *win, QQuickItem *it) {
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;
}
tapItem(win, dev, it);
}
void tapItem(QQuickWindow *win, QPointingDevice *dev, QQuickItem *it) {
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?");
/* The prompt carries a picture, stored exactly the way the engine stores
* one (the file next to the session, the message naming it). */
{
QImage pic(320, 200, QImage::Format_RGB32);
pic.fill(Qt::darkCyan);
QPainter painter(&pic);
painter.fillRect(QRect(0, 0, 160, 100), Qt::darkYellow);
painter.end();
QByteArray png;
QBuffer buf(&png);
buf.open(QIODevice::WriteOnly);
pic.save(&buf, "PNG");
char aerr[256] = "";
m.attach = cst_attach_import_bytes(path, "shot.png", png.constData(),
static_cast<size_t>(png.size()), aerr,
sizeof(aerr));
m.nattach = m.attach ? 1 : 0;
}
cst_session_append(s, &m);
cst_attachments_free(m.attach, m.nattach);
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);
/* A delivered file (deliver_file): the copy the agent hands to the person,
* marked for_user -- the row a file card is drawn from. */
memset(&m, 0, sizeof(m));
m.role = CST_ROLE_ASSISTANT;
m.tool_calls_json = strdup(
"[{\"id\":\"call_2\",\"type\":\"function\",\"function\":"
"{\"name\":\"deliver_file\",\"arguments\":\"{\\\"path\\\":"
"\\\"report.md\\\"}\"}}]");
cst_session_append(s, &m);
memset(&m, 0, sizeof(m));
{
const QString src = ws + QStringLiteral("/report.md");
QFile rf(src);
if (rf.open(QIODevice::WriteOnly))
rf.write("# report\n");
rf.close();
char aerr[256] = "";
m.attach = cst_attach_import_artifact(path, src.toUtf8().constData(), NULL,
aerr, sizeof(aerr));
m.nattach = m.attach ? 1 : 0;
}
m.role = CST_ROLE_TOOL;
m.content = strdup("delivered report.md\n");
snprintf(m.tool_call_id, sizeof(m.tool_call_id), "%s", "call_2");
snprintf(m.tool_name, sizeof(m.tool_name), "%s", "deliver_file");
cst_session_append(s, &m);
cst_attachments_free(m.attach, m.nattach);
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;
}
}
/* A workspace is never empty on a second run: the engine opens the last
* session of the store (cst_agent_open()), so the window is created with a
* transcript already in the engine. Put one there before the QML loads,
* which is exactly what a restart looks like. */
/* A file card hands its file to the system: in a test run nothing may open
* for real, and the platform layer itself honours this (see ToolProvider.h). */
qputenv("CST_APP_NO_OPEN", "1");
/* From here on the run leaves a log in the workspace next to the files it
* makes, so a device run can be read back without logcat (see g_runLog). */
g_runLog.setFileName(core.workspace() + QStringLiteral("/uitest.log"));
g_runLog.open(QIODevice::WriteOnly | QIODevice::Truncate);
qInstallMessageHandler(logQtMessage);
const QString resumed =
writeFixtureSession(core.workspace(), QStringLiteral("20260101-115959-on"));
check(!resumed.isEmpty() && core.switchSession(resumed),
QStringLiteral("the engine has a session open before the window exists"));
QQmlApplicationEngine engine;
installThemeIcons(&engine); /* the window's icons, same as main() */
/* image://cstattach/: the session's stored images, pointed at the session
* the engine has open (and re-pointed on every switch). */
SessionImages *images = installSessionImages(&engine);
images->setDir(core.attachmentDir());
QObject::connect(&core, &AgentCore::sessionChanged, images,
[images, &core] { images->setDir(core.attachmentDir()); });
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;
}
/* The same handler main() installs: every Back key check below goes through
* the code the phone runs, not through a Shortcut that only the test has. */
installBackKey(win, engine.rootObjects().value(0));
QTest::qWait(250);
/* --- the window opens on the session the engine already had ----------- */
{
QQuickItem *tr = item(win, QStringLiteral("transcript"));
const int rows = tr ? tr->property("count").toInt() : -1;
check(rows == 6,
QStringLiteral("the open session is on screen at startup (%1 rows)")
.arg(rows));
}
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"));
/* Task controls must reach the same runtime as the model and HTTP API. */
clickMouse(win, QStringLiteral("taskPanelButton"));
QTest::qWait(150);
QObject *taskPanel = win->findChild<QObject *>(QStringLiteral("taskControlDialog"));
check(taskPanel && taskPanel->property("visible").toBool(),
QStringLiteral("Tasks opens the runtime control panel"));
clickMouse(win, QStringLiteral("taskReadOnly"));
clickMouse(win, QStringLiteral("taskMainOnly"));
QJsonObject taskState = QJsonDocument::fromJson(core.taskState().toUtf8()).object();
check(taskState.value(QStringLiteral("read_only")).toBool() &&
!taskState.value(QStringLiteral("delegation")).toBool(),
QStringLiteral("task panel enforces read-only and main-agent-only controls"));
core.taskControl(false, true, false);
if (taskPanel) QMetaObject::invokeMethod(taskPanel, "close");
QTest::qWait(150);
/* --- 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);
/* A row is not clickable until the layout pass that gives it its width has
* run (see frame()): a click on a row that is still 0 wide goes to whatever
* is behind it. */
frame(win);
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, agent + call, delivered
* file, 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() == 6,
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"));
}
/* --- a delivered file: a card with open and share --------------------- */
{
/* The view is re-laid out off the click; give it the frame it needs. */
QTest::qWait(150);
const int cards =
countNamed(win->contentItem(), QStringLiteral("fileCard"));
check(cards == 1,
QStringLiteral("a delivered file is a card in the tool row (%1)")
.arg(cards));
check(item(win, QStringLiteral("fileOpen0")) != nullptr &&
item(win, QStringLiteral("fileShare0")) != nullptr,
QStringLiteral("the card carries open and share"));
win->setProperty("statusText", QString());
/* A click goes to whatever is at that point, and the transcript replays
* from its bottom: on a phone-sized window the card can be above the
* visible part, where isVisible() still says yes (it does not know about
* the view's clipping). scrollIntoView puts it in the middle of the view --
* and stop the view following its tail (ChatView.stickBottom) first, or
* the scroll is undone by the next content change. */
if (list) {
/* Stop the view following its tail (ChatView.stickBottom) first: the
* scroll to the card below would be undone by the next content
* change. */
if (QQuickItem *chat = item(win, QStringLiteral("chatView")))
chat->setProperty("stickBottom", false);
}
QStringList rels;
QString cardSays;
QList<QQuickItem *> strips;
collectNamed(win->contentItem(), QStringLiteral("imageStrip"), &strips);
/* Which card is the session's own. The tree still holds the delegate of
* the session before -- a view keeps a row or two around -- and its card
* names a file of *that* session, so the first "fileOpen0" in the tree is
* not necessarily the one the check means. A rel this session can open is
* one that is really in its attachment directory. */
QQuickItem *openButton = nullptr;
QQuickItem *shareButton = nullptr;
const QString attachDir = core.attachmentDir();
for (QQuickItem *s : strips) {
const QVariantList files = s->property("files").toList();
for (int i = 0; i < files.size(); i++) {
const QVariant &f = files.at(i);
rels << f.toMap().value(QStringLiteral("rel")).toString();
if (openButton) {
continue;
}
const QString rel =
f.toMap().value(QStringLiteral("rel")).toString();
if (f.toMap().value(QStringLiteral("kind")).toString() == QLatin1String("img") || rel.isEmpty() ||
!QFileInfo::exists(attachDir + QLatin1Char('/') + rel))
continue;
QList<QQuickItem *> hits;
collectNamed(s, QStringLiteral("fileOpen%1").arg(i), &hits);
if (!hits.isEmpty()) {
openButton = hits.first();
hits.clear();
collectNamed(s, QStringLiteral("fileShare%1").arg(i), &hits);
shareButton = hits.isEmpty() ? nullptr : hits.first();
}
}
if (cardSays.isEmpty())
cardSays = s->property("errorText").toString();
}
if (!openButton)
openButton = item(win, QStringLiteral("fileOpen0"));
if (!shareButton)
shareButton = item(win, QStringLiteral("fileShare0"));
/* What the card itself hands the engine ("rel" is the name the store keeps
* the file under), and whether it is really on screen: a card outside the
* viewport takes the events meant for it, and the card's own line is where
* the engine puts its refusal. */
if (openButton) {
scrollIntoView(openButton, true);
frame(win);
clickItem(win, openButton);
} else {
check(false, QStringLiteral("the delivered file has a card to open"));
}
const bool byMouse = !win->property("statusText").toString().isEmpty();
if (!byMouse && openButton)
tapItem(win, touch, openButton);
const QString opened = win->property("statusText").toString();
check(opened.startsWith(QLatin1String("opened ")),
QStringLiteral("Open hands the file to the system (%1%2; cards hold '%3', "
"%4, card says '%5')")
.arg(opened, byMouse ? QString() : QStringLiteral(" after a tap"),
rels.join(QLatin1String(", ")),
onScreen(openButton)
? QStringLiteral("on screen")
: QStringLiteral("out of the viewport"),
cardSays));
win->setProperty("statusText", QString());
if (shareButton) {
scrollIntoView(shareButton, true);
frame(win);
clickItem(win, shareButton);
}
const bool shareByMouse = !win->property("statusText").toString().isEmpty();
if (!shareByMouse && shareButton)
tapItem(win, touch, shareButton);
const QString shared = win->property("statusText").toString();
check(shared.contains(QLatin1String("report.md")),
QStringLiteral("Share names the file (%1%2; card says '%3')")
.arg(shared, shareByMouse ? QString() : QStringLiteral(" after a tap"),
cardSays));
const QString refused =
core.openArtifact(QStringLiteral("art-not-one-of-ours.md"));
check(refused.contains(QLatin1String("no such file")),
QStringLiteral("a name this session does not own is refused (%1)")
.arg(refused));
/* And the card says it: a tap that cannot work must not look dead. */
QQuickItem *strip = nullptr;
{
QList<QQuickItem *> hits;
collectNamed(win->contentItem(), QStringLiteral("imageStrip"), &hits);
for (QQuickItem *it : hits)
if (it->isVisible())
strip = it;
}
bool called = false;
if (strip)
called = QMetaObject::invokeMethod(
strip, "openFile",
Q_ARG(QVariant, QStringLiteral("art-not-one-of-ours.md")));
const QVariant err = strip ? strip->property("errorText") : QVariant();
check(called && err.toString().contains(QLatin1String("no such file")),
QStringLiteral("the card shows why it could not open it (%1)")
.arg(err.toString()));
QTest::qWait(150);
QQuickItem *errText = item(win, QStringLiteral("fileError"));
check(errText && errText->isVisible() &&
errText->property("text").toString().contains(
QLatin1String("no such file")),
QStringLiteral("and the line is where the card is"));
}
/* The approval switch is not decoration: the engine asks for a tool only
* when its own confirm_* flags say so (needs_confirm() in agent_loop.c),
* and every front end that has a consent dialog has to set them -- the
* window used to leave all three at zero, which is why "ask" never asked. */
{
const int was = core.approval();
core.setApproval(CST_APPROVAL_ASK);
const cst_agent_config_t *live = cst_agent_config(core.agent());
check(live && live->approval == CST_APPROVAL_ASK && live->confirm_shell &&
live->confirm_write && live->confirm_net,
QStringLiteral("'ask' reaches the engine as the three confirm flags"));
core.setApproval(CST_APPROVAL_YOLO);
live = cst_agent_config(core.agent());
check(live && live->approval == CST_APPROVAL_YOLO && !live->confirm_shell &&
!live->confirm_write && !live->confirm_net,
QStringLiteral("and 'yolo' takes them all away"));
core.setApproval(was); /* leave the machine as it was found */
}
/* --- md_to_pdf: the front end's own document writer ------------------- */
{
const cst_tool_def_t *def = cst_tool_find("md_to_pdf");
check(def != nullptr && def->owner != nullptr &&
strncmp(def->owner, "app:qt", 6) == 0,
QStringLiteral("md_to_pdf is registered by the front end"));
if (def && def->run_ex) {
/* Run it the way the agent's turn would: through the registry, with a
* call and the engine's context. */
const QByteArray ws = core.workspace().toUtf8();
cst_tool_ctx_t ctx;
memset(&ctx, 0, sizeof(ctx));
ctx.workspace_root = ws.constData();
ctx.agent = core.agent();
ctl_tool_call_t call;
memset(&call, 0, sizeof(call));
snprintf(call.id, sizeof(call.id), "pdf1");
snprintf(call.name, sizeof(call.name), "md_to_pdf");
static const char *kMd =
"# 设备信息报告\n\n这是一段**中文**正文,用来确认字体和分页。\n\n"
"- 项目一\n- 项目二\n\n```\ncode block\n```\n";
static const char *kOut = "selftest.pdf";
snprintf(call.args[0].key, sizeof(call.args[0].key), "markdown");
call.args[0].value = const_cast<char *>(kMd);
call.args[0].value_len = strlen(kMd);
snprintf(call.args[1].key, sizeof(call.args[1].key), "out");
call.args[1].value = const_cast<char *>(kOut);
call.args[1].value_len = strlen(kOut);
call.nargs = 2;
cst_tool_result_t res;
memset(&res, 0, sizeof(res));
const int rc = cst_tool_run(&ctx, &call, &res);
check(res.exit_code == 0,
QStringLiteral("md_to_pdf writes the document (%1)")
.arg(QString::fromUtf8(res.out ? res.out : "")));
check(rc == 0 && res.nattach == 1,
QStringLiteral("and hands it over as a file for the user"));
QFile f(core.workspace() + QStringLiteral("/selftest.pdf"));
const bool opened = f.open(QIODevice::ReadOnly);
const QByteArray head = opened ? f.read(5) : QByteArray();
check(head == QByteArrayLiteral("%PDF-"),
QStringLiteral("the bytes are a PDF (%1)")
.arg(QString::fromLatin1(head)));
cst_tool_result_free(&res);
}
}
/* Paper is white whatever the window's palette says: markdown carries no
* colour, so a dark window turned every plain paragraph #eeeeee -- a PDF
* that looked blank. Draw it on a white image and look for ink. */
{
const QPalette was = QGuiApplication::palette();
QPalette darkPalette = was;
darkPalette.setColor(QPalette::Text, QColor(0xee, 0xee, 0xee));
darkPalette.setColor(QPalette::WindowText, QColor(0xee, 0xee, 0xee));
QGuiApplication::setPalette(darkPalette);
std::unique_ptr<QTextDocument> page(app::paperDocument(
QStringLiteral("<html><body>text that names no colour of its own"
"</body></html>")));
QImage paper(320, 60, QImage::Format_RGB32);
paper.fill(0xffffffffu);
QPainter ink(&paper);
page->drawContents(&ink);
ink.end();
int darkest = 255;
for (int y = 0; y < paper.height(); y++) {
const QRgb *row = reinterpret_cast<const QRgb *>(paper.constScanLine(y));
for (int x = 0; x < paper.width(); x++)
darkest = qMin(darkest, qGray(row[x]));
}
check(darkest < 64,
QStringLiteral("a dark window still lays out black ink on paper "
"(darkest pixel %1)")
.arg(darkest));
QGuiApplication::setPalette(was);
}
/* A tool that takes minutes -- a build, a wide search -- must not read as a
* window that has hung: while a card runs, its chip counts. */
{
QList<QQuickItem *> cards;
collectNamed(win->contentItem(), QStringLiteral("toolCard"), &cards);
check(!cards.isEmpty(),
QStringLiteral("the fixture session has a tool card to watch"));
if (!cards.isEmpty()) {
QQuickItem *card = cards.first();
card->setProperty("state", "running");
/* four minutes eighteen in, the point where "running" alone stopped
* being an answer */
card->setProperty(
"startedAt",
static_cast<double>(QDateTime::currentMSecsSinceEpoch()) - 258000.0);
card->setProperty("runningFor", 258);
QList<QQuickItem *> chips;
collectNamed(card, QStringLiteral("toolChip"), &chips);
const QString counting = chips.isEmpty()
? QString()
: chips.first()->property("text").toString();
check(counting.contains(QLatin1String("4:18")),
QStringLiteral("a running card says how long it has been going (%1)")
.arg(counting));
card->setProperty("state", "done");
const QString settled = chips.isEmpty()
? QString()
: chips.first()->property("text").toString();
check(!settled.contains(QLatin1String("4:18")),
QStringLiteral("and a finished card stops counting (%1)")
.arg(settled));
}
}
/* 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 six rows */
QTest::qWait(150);
/* The sidebar holds two more sessions by now, so the row may sit below the
* fold: brought into view first, or the click goes to the row that happens
* to be there (see scrollIntoView). */
if (QQuickItem *row = item(win, QStringLiteral("sessionRow:") + fixture)) {
scrollIntoView(row);
frame(win);
}
clickMouse(win, QStringLiteral("sessionRow:") + fixture);
check(core.sessionId() == fixture && list &&
list->property("count").toInt() == 6,
QStringLiteral("switching back replays it again"));
{
bool promptImage = false;
int promptImageRow = -1;
const QVariantList rows = core.history();
for (int i = 0; i < rows.size(); i++) {
const QVariant &v = rows.at(i);
const QVariantMap r = v.toMap();
if (r.value(QStringLiteral("role")).toString() == QLatin1String("user") &&
r.value(QStringLiteral("images")).toString().contains(
QLatin1String("|"))) {
promptImage = true;
if (promptImageRow < 0)
promptImageRow = i;
}
}
check(promptImage,
QStringLiteral("a stored prompt image comes back with its row"));
/* The view follows the tail (ChatView.stickBottom) and the picture is in
* the first prompt: on a window that fits fewer rows than this one it is
* above the visible part, where a delegate is not created at all. Put it
* on screen, which is what a person does before looking at it. */
if (list && promptImageRow >= 0)
QMetaObject::invokeMethod(list, "positionViewAtIndex",
Q_ARG(int, promptImageRow), Q_ARG(int, 0));
/* The rows are in the model by now; the cards -- and the thumbnails in
* them -- are built on the view's next layout pass. A frame forces it,
* and that frame is also the picture a person looks at. */
maybeShot(win, QStringLiteral("-images"));
frame(win);
if (qEnvironmentVariableIsSet("CST_APP_DUMP")) {
QVariant v;
QMetaObject::invokeMethod(win, "rowJson", Q_RETURN_ARG(QVariant, v),
Q_ARG(QVariant, 0));
std::printf("note: replayed row 0: %s\n", qPrintable(v.toString()));
std::printf("note: %d strips and %d thumbs in the tree\n",
countNamed(win->contentItem(), QStringLiteral("imageStrip")),
countNamed(win->contentItem(), QStringLiteral("imageThumb")));
}
check(countNamed(win->contentItem(), QStringLiteral("imageThumb")) >= 1,
QStringLiteral("the replayed transcript shows it as a thumbnail (%1)")
.arg(countNamed(win->contentItem(), QStringLiteral("imageThumb"))));
}
/* --- 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"));
/* Where the pieces actually are, whether or not that worked: a click goes to
* whatever is on top at that point, and a style lays a header out its own
* way -- the phone runs a different style from a desktop (Universal against
* Basic), which is where the header's content ended up 35 px below the
* header itself. */
{
QQuickItem *bar = item(win, QStringLiteral("appBar"));
const QMargins safe = win->property("safeAreaMargins").value<QMargins>();
note(QStringLiteral("style %1, window %2x%3, header y %4 h %5, "
"gear centre y %6, safe area %7/%8/%9/%10")
.arg(QQuickStyle::name())
.arg(win->width())
.arg(win->height())
.arg(bar ? bar->y() : -1)
.arg(bar ? bar->height() : -1)
.arg(item(win, QStringLiteral("settingsButton"))
? centre(item(win, QStringLiteral("settingsButton"))).y()
: -1)
.arg(safe.left())
.arg(safe.top())
.arg(safe.right())
.arg(safe.bottom()));
}
/* item() answers with an item that exists even when nothing is visible (the
* QObject-tree fallback), so "the dialog is not open" is the invisible case
* too. */
if (!panel || !panel->isVisible()) {
/* Two ways to be wrong -- a click that never lands, and a handler that
* does nothing -- and from the outside they look the same (this failed
* only on the phone, with no QML error at all). Say which, and go on with
* the dialog either way, so the checks below still say something. */
QQuickItem *gear = item(win, QStringLiteral("settingsButton"));
check(gear != nullptr && gear->isVisible(),
QStringLiteral("... the gear is %1, centre %2,%3 of %4x%5")
.arg(gear && gear->isVisible() ? QStringLiteral("reachable")
: QStringLiteral("not there"))
.arg(gear ? centre(gear).x() : -1)
.arg(gear ? centre(gear).y() : -1)
.arg(win->width())
.arg(win->height()));
QMetaObject::invokeMethod(win, "openSettings");
QTest::qWait(120);
panel = item(win, QStringLiteral("settingsPanel"));
check(panel && panel->isVisible(),
QStringLiteral("... and openSettings() opens it when called directly"));
}
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"));
}
/* --- advanced: a custom provider, added and removed ------------------- */
/* The run has a scratch config dir (main.cpp points XDG_CONFIG_HOME at it),
* so the engine's user model file is this run's own. */
check(core.genParams(QStringLiteral("deepseek")).value(QStringLiteral("temperature"))
.toDouble() == 0.0,
QStringLiteral("no generation parameters are saved to begin with"));
check(core.setGenParams(QStringLiteral("deepseek"), 0.2, 0.9, 4096).isEmpty(),
QStringLiteral("generation parameters are accepted"));
{
const QVariantMap g = core.genParams(QStringLiteral("deepseek"));
check(g.value(QStringLiteral("temperature")).toDouble() > 0.19 &&
g.value(QStringLiteral("max_tokens")).toInt() == 4096,
QStringLiteral("they are remembered per provider (%1 / %2 / %3)")
.arg(g.value(QStringLiteral("temperature")).toDouble())
.arg(g.value(QStringLiteral("top_p")).toDouble())
.arg(g.value(QStringLiteral("max_tokens")).toInt()));
}
check(core.addProvider(QStringLiteral("cst-scratch"), QStringLiteral("https://api.example.com/v1"),
QStringLiteral("CST_SCRATCH_KEY"), QStringLiteral("scratch-1"),
QStringLiteral("scratch-1, scratch-2"))
.isEmpty(),
QStringLiteral("a provider the presets do not know is added"));
{
bool found = false, custom = false;
QString base, models;
const QVariantList list = core.providers();
for (const QVariant &v : list) {
const QVariantMap p = v.toMap();
if (p.value(QStringLiteral("name")).toString() != QLatin1String("cst-scratch"))
continue;
found = true;
custom = p.value(QStringLiteral("custom")).toBool();
base = p.value(QStringLiteral("base")).toString();
models = core.modelChoices(QStringLiteral("cst-scratch")).join(QLatin1Char(','));
}
check(found && custom && base.contains(QLatin1String("api.example.com")),
QStringLiteral("it shows up in the provider list as a custom one"));
check(models.contains(QLatin1String("scratch-1")) &&
models.contains(QLatin1String("scratch-2")),
QStringLiteral("the models it lists are the picker's (%1)").arg(models));
}
check(!core.addProvider(QStringLiteral("bad name"), QStringLiteral("https://x/v1"),
QString(), QString(), QString())
.isEmpty(),
QStringLiteral("a name that is not a provider name is refused"));
check(core.removeProvider(QStringLiteral("cst-scratch")) == 1,
QStringLiteral("the custom provider can be removed again"));
{
bool gone = true;
const QVariantList list = core.providers();
for (const QVariant &v : list)
if (v.toMap().value(QStringLiteral("name")).toString() ==
QLatin1String("cst-scratch"))
gone = false;
check(gone, QStringLiteral("and it is gone from the list"));
}
check(core.removeProvider(QStringLiteral("deepseek")) == 0,
QStringLiteral("a preset cannot be removed (there is no user entry to drop)"));
check(core.setGenParams(QStringLiteral("deepseek"), 0, 0, 0).isEmpty() &&
core.genParams(QStringLiteral("deepseek"))
.value(QStringLiteral("temperature"))
.toDouble() == 0.0,
QStringLiteral("parameters can be cleared again"));
/* The dialog drives the same two engine calls: open it, fold the advanced
* section out and put a provider in through the form. */
clickMouse(win, QStringLiteral("settingsButton"));
QTest::qWait(150); /* the dialog's layout settles before it is clicked into */
if (panel && mfield) {
clickMouse(win, QStringLiteral("settingsAdvanced"));
QTest::qWait(150); /* the fold-out re-lays the body out */
QQuickItem *nfield = item(win, QStringLiteral("advName"));
QQuickItem *bfield = item(win, QStringLiteral("advBase"));
check(nfield && nfield->isVisible(), QStringLiteral("Advanced options opens in the dialog"));
maybeShot(win, QStringLiteral("-advanced"));
if (nfield && bfield) {
nfield->setProperty("text", QStringLiteral("cst-form"));
bfield->setProperty("text", QStringLiteral("https://form.example.com/v1"));
/* The form sits at the end of the dialog's scrolling body: scroll it
* into view so the click lands on the button, not on the frame. */
QQuickItem *scroller = item(win, QStringLiteral("settingsBody"));
if (scroller) {
scroller->setProperty("contentY", scroller->property("contentHeight").toReal() -
scroller->height());
QTest::qWait(120); /* the flickable settles */
}
clickMouse(win, QStringLiteral("settingsSaveProvider"));
bool found = false;
const QVariantList list = core.providers();
for (const QVariant &v : list)
if (v.toMap().value(QStringLiteral("name")).toString() ==
QLatin1String("cst-form"))
found = true;
check(found, QStringLiteral("the form in the dialog adds a provider"));
QQuickItem *rm = item(win, QStringLiteral("settingsRemoveProvider"));
check(rm && rm->property("enabled").toBool(),
QStringLiteral("its entry can be removed from the form"));
clickMouse(win, QStringLiteral("settingsRemoveProvider"));
check(core.removeProvider(QStringLiteral("cst-form")) == 0,
QStringLiteral("the form's provider is gone again"));
} else {
check(false, QStringLiteral("the advanced form has name and base URL fields"));
}
clickMouse(win, QStringLiteral("settingsClose"));
} else {
check(false, QStringLiteral("the settings dialog can be reopened"));
}
/* --- 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 !defined(Q_OS_ANDROID)
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"));
}
#else
/* Android's clipboard carries text and URIs, not images: setting one there
* does nothing, and a phone that "fails" this is not a phone that is
* broken. The paste path itself is the same code the desktop runs. */
(void)gui;
note(QStringLiteral("clipboard image checks skipped (Android)"));
#endif
/* --- 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"));
}
/* --- the image provider: only the session's own files ----------------- */
{
const QString dir = core.attachmentDir();
/* one directory per session since v0.9.5: <sessions>/<id>/attachments.
* The engine builds the path with the separator of the platform it runs
* on, so what arrives here is "…/sessions\<id>\attachments" on Windows:
* asked with forward slashes, which is what the check means. */
const QString shown = QDir::fromNativeSeparators(dir);
check(!shown.isEmpty() && shown.endsWith(QLatin1String("/attachments")) &&
shown.contains(QLatin1String("/sessions/")),
QStringLiteral("the agent names its image directory (%1)").arg(dir));
QImage pic(32, 16, QImage::Format_RGB32);
pic.fill(Qt::darkGreen);
QByteArray png;
QBuffer buf(&png);
buf.open(QIODevice::WriteOnly);
pic.save(&buf, "PNG");
char aerr[256] = "";
cst_attachment_t *stored = cst_attach_import_bytes(
cst_agent_session_path(core.agent()), "probe.png", png.constData(),
static_cast<size_t>(png.size()), aerr, sizeof(aerr));
check(stored != nullptr,
QStringLiteral("a picture can be stored next to the session"));
if (stored) {
QSize got;
const QImage shrunk =
images->requestImage(QString::fromUtf8(stored->rel),
&got, QSize(8, 8));
check(!shrunk.isNull() && shrunk.width() == 8 && shrunk.height() == 4,
QStringLiteral("the provider serves it, shrunk to what was asked "
"(%1x%2)")
.arg(shrunk.width())
.arg(shrunk.height()));
check(images->requestImage(QStringLiteral("../escape.png"), &got,
QSize(64, 64))
.isNull(),
QStringLiteral("a name outside the attachment directory is refused"));
check(images->requestImage(QStringLiteral("no-such-file.png"), &got,
QSize(64, 64))
.isNull(),
QStringLiteral("a file that is not there is refused"));
cst_attach_free(stored);
}
}
/* --- a tool that made a picture: its card shows it -------------------- */
{
const int before = list->property("count").toInt();
const int thumbsBefore =
countNamed(win->contentItem(), QStringLiteral("imageThumb"));
if (qEnvironmentVariableIsSet("CST_APP_DUMP") && list)
std::printf("note: tool card: model %d, contentY %.1f of %.1f (view %.1f), "
"%d strips, %d thumbs, %d cards\n", before,
list->property("contentY").toDouble(),
list->property("contentHeight").toDouble(), list->height(),
countNamed(win->contentItem(), QStringLiteral("imageStrip")),
thumbsBefore,
countNamed(win->contentItem(), QStringLiteral("card:")));
event4("engineToolEvent", QStringLiteral("open"), QStringLiteral("call-img"),
QStringLiteral("view_image"), QStringLiteral("{\"path\":\"shot.png\"}"));
const QString imgs =
QStringLiteral("img-fixture.png|shot.png|view_image|24x8");
check(QMetaObject::invokeMethod(win, "engineToolImages",
Q_ARG(QVariant, QStringLiteral("call-img")),
Q_ARG(QVariant, imgs)),
QStringLiteral("images a tool made reach the view"));
frame(win);
const QVariantMap card = rowAt(before);
check(card.value(QStringLiteral("images"))
.toString()
.contains(QLatin1String("img-fixture.png")) &&
card.value(QStringLiteral("name")).toString() ==
QLatin1String("view_image"),
QStringLiteral("the card of that call carries the image"));
check(countNamed(win->contentItem(), QStringLiteral("imageThumb")) >
thumbsBefore,
QStringLiteral("its thumbnail is in the card (%1 -> %2)").arg(thumbsBefore)
.arg(countNamed(win->contentItem(),
QStringLiteral("imageThumb"))));
maybeShot(win, QStringLiteral("-toolimage"));
}
/* --- the order the engine uses on a live turn: the result first, the
* files after it (a card is complete by then, which used to lose them) --- */
{
const int before = list->property("count").toInt();
const int cardsBefore =
countNamed(win->contentItem(), QStringLiteral("fileCard"));
event4("engineToolEvent", QStringLiteral("open"),
QStringLiteral("call-file"), QStringLiteral("deliver_file"),
QStringLiteral("{\"path\":\"report.md\"}"));
event4("engineToolEvent", QStringLiteral("done"),
QStringLiteral("call-file"), QStringLiteral("deliver_file"),
QStringLiteral("exit=0 bytes=120"));
event4("engineToolResult", QStringLiteral("call-file"),
QStringLiteral("deliver_file"), QVariant(0),
QStringLiteral("delivered report.md (text/markdown, 120 B)"));
const QString files =
QStringLiteral("file|art-0123456789abcdef.md|report.md|tool|"
"text/markdown|120|0x0");
check(QMetaObject::invokeMethod(win, "engineToolImages",
Q_ARG(QVariant, QStringLiteral("call-file")),
Q_ARG(QVariant, files)),
QStringLiteral("the files a tool delivered reach the view"));
frame(win);
const QVariantMap card = rowAt(before);
check(card.value(QStringLiteral("images"))
.toString()
.contains(QLatin1String("art-0123456789abcdef.md")),
QStringLiteral("a card that is already complete still takes them"));
check(countNamed(win->contentItem(), QStringLiteral("fileCard")) >
cardsBefore,
QStringLiteral("so the delivered file is on the card (%1 -> %2)")
.arg(cardsBefore)
.arg(countNamed(win->contentItem(), QStringLiteral("fileCard"))));
}
/* --- 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"));
/* A phone closes the drawer with the back key, not with a second tap. */
QTest::keyClick(win, Qt::Key_Back);
QTest::qWait(120);
check(navPanel && !navPanel->isVisible(),
QStringLiteral("back closes the drawer"));
clickMouse(win, QStringLiteral("navButton"));
check(navPanel && navPanel->isVisible(),
QStringLiteral("and the drawer button opens it again"));
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"));
/* --- the phone's back key closes what is open -------------------------- */
/* This one is last on purpose: on the offscreen platform a popup closed by a
* key leaves its modal overlay over the window -- the dialog says it is
* closed while the overlay still eats clicks. Nothing that clicks may come
* after it. */
{
QMetaObject::invokeMethod(win, "openSettings");
QTest::qWait(300);
QQuickItem *panel = item(win, QStringLiteral("settingsPanel"));
check(panel && panel->isVisible(),
QStringLiteral("back: the settings dialog is open to close"));
/* Which layer is open is asked of QML, not of an item's `visible`: the
* dialog's content item lives on after a close and still reports visible
* while it fades. backLayer() is what the Back key reads, so the test asks
* for exactly that. */
QVariant before;
QMetaObject::invokeMethod(win, "backLayer", Q_RETURN_ARG(QVariant, before));
check(before.toString() == QStringLiteral("settings"),
QStringLiteral("back: the settings layer is the one open"));
QTest::keyClick(win, Qt::Key_Back);
QTest::qWait(200);
QVariant after;
QMetaObject::invokeMethod(win, "backLayer", Q_RETURN_ARG(QVariant, after));
note(QStringLiteral("after back: layer \"%1\"").arg(after.toString()));
check(after.toString().isEmpty(),
QStringLiteral("back closes the dialog instead of the window"));
check(win->isVisible(), QStringLiteral("and the window is still there"));
/* With nothing open the key belongs to the platform: a phone leaves the
* window with it, so this side must not take it. On a desktop nothing
* happens and the window has to still be there; on a phone the window
* going away *is* the hand-over, so what is asked is that no layer was
* touched (whether the activity is already gone is the platform's own
* timing). This is the last thing the run does on a phone: the activity
* leaving takes the process with it, so the log can end on the check below
* without the closing line -- every line is flushed as it is written. */
QTest::keyClick(win, Qt::Key_Back);
QTest::qWait(120);
QVariant idle;
QMetaObject::invokeMethod(win, "backLayer", Q_RETURN_ARG(QVariant, idle));
#if defined(Q_OS_ANDROID)
note(QStringLiteral("after the last back: window visible %1")
.arg(win->isVisible() ? 1 : 0));
check(idle.toString().isEmpty(),
QStringLiteral("back with nothing open is left to the platform"));
#else
check(idle.toString().isEmpty() && win->isVisible(),
QStringLiteral("back with nothing open is left to the platform"));
#endif
}
/* A picture of the last state on request (see maybeShot). */
maybeShot(win, QString());
std::printf("--- %s\n", g_fail ? "FAILED" : "all UI checks passed");
if (g_runLog.isOpen()) {
g_runLog.write("--- ");
g_runLog.write(g_fail ? "FAILED" : "all UI checks passed");
g_runLog.write("\n");
g_runLog.close();
}
return g_fail ? 1 : 0;
}
} // namespace app