/* --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 #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include "cst_agent.h" #include "cst_attach.h" #include "ui/SessionImages.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++; } /* 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 *out) { if (!root) return; if (root->objectName() == name) out->append(root); const QList 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 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) { QList hits; collectNamed(win->contentItem(), name, &hits); /* What a finger can reach is the one the test means: a delegate on its way * out is still in the tree but invisible. */ for (QQuickItem *it : hits) if (it->isVisible()) return it; if (QQuickItem *it = win->findChild(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(); } 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?"); /* 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(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"); 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(engine.rootObjects().value(0)); if (!win) { std::printf("FAIL the root object is not a window\n"); return 1; } 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")); /* --- 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, 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()); clickMouse(win, QStringLiteral("fileOpen0")); const QString opened = win->property("statusText").toString(); check(opened.startsWith(QLatin1String("opened ")), QStringLiteral("Open hands the file to the system (%1)").arg(opened)); clickMouse(win, QStringLiteral("fileShare0")); const QString shared = win->property("statusText").toString(); check(shared.contains(QLatin1String("report.md")), QStringLiteral("Share names the file (%1)").arg(shared)); 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 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")); } /* --- 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(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(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); } } /* 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); clickMouse(win, QStringLiteral("sessionRow:") + fixture); check(core.sessionId() == fixture && list && list->property("count").toInt() == 6, QStringLiteral("switching back replays it again")); { bool promptImage = false; const QVariantList rows = core.history(); for (const QVariant &v : rows) { const QVariantMap r = v.toMap(); if (r.value(QStringLiteral("role")).toString() == QLatin1String("user") && r.value(QStringLiteral("images")).toString().contains( QLatin1String("|"))) promptImage = true; } check(promptImage, QStringLiteral("a stored prompt image comes back with its row")); /* 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")); QTest::qWait(150); 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")); 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(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")); } /* --- the image provider: only the session's own files ----------------- */ { const QString dir = core.attachmentDir(); /* one directory per session since v0.9.5: //attachments */ check(!dir.isEmpty() && dir.endsWith(QLatin1String("/attachments")) && dir.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(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")); 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")); QTest::qWait(150); 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")); 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")); QTest::qWait(150); 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")); 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