diff --git a/app/README.md b/app/README.md index 50cdde9..7f634b0 100644 --- a/app/README.md +++ b/app/README.md @@ -150,6 +150,13 @@ creates and deletes sessions. On a machine without a screen, `CST_APP_SHOT=shot.png` makes the run save the window as a PNG when it is done; `CST_APP_DUMP=1` prints how many session rows the visual tree holds. +Every line the run prints also goes to `uitest.log` in the workspace, and the +verdict is the last line of it. That is how a run *on a phone* is read back: +stdout reaches nobody there, an app started from adb cannot be handed +environment variables, and logcat does not carry this build's Qt messages -- +so `--uitest --workspace /sdcard/cst-uitest` plus +`adb pull /sdcard/cst-uitest/uitest.log` is the whole round trip. + ## Provider, model, key The sidebar shows where the engine points (`provider · model`); the gear in the diff --git a/app/qml/ToolCard.qml b/app/qml/ToolCard.qml index ab5ec53..f2e2eef 100644 --- a/app/qml/ToolCard.qml +++ b/app/qml/ToolCard.qml @@ -29,6 +29,55 @@ Rectangle { : state === "unfinished" ? "no result" : hasResult && exitCode !== 0 ? "failed" : "done" + /* How long this call has been going. A tool that takes minutes -- a + * build, a wide search, a command nobody can predict -- must not read as + * a window that has hung: while the card runs, its chip counts. */ + property double startedAt: 0 + property int runningFor: 0 + + function elapsedText() { + const s = runningFor + if (s < 60) + return s + "s" + const m = Math.floor(s / 60) + if (m < 60) + return m + ":" + (s % 60 < 10 ? "0" : "") + (s % 60) + return Math.floor(m / 60) + "h" + (m % 60 < 10 ? "0" : "") + (m % 60) + } + + /* The clock starts when the card starts running -- and a card that is + * already running when it appears (a replayed transcript) starts one too, + * hence Component.onCompleted. */ + function noteRunning() { + if (running && startedAt === 0) + startedAt = Date.now() + else if (!running) + startedAt = 0 + } + + onRunningChanged: noteRunning() + Component.onCompleted: noteRunning() + + Timer { + interval: 1000 + repeat: true + running: card.running && card.startedAt > 0 + onTriggered: card.runningFor = Math.floor((Date.now() - card.startedAt) / 1000) + } + + /* What the chip says: the state, how long it has been running (from five + * seconds on -- before that it is noise), or the exit code once there is + * a result. "no result" is not an outcome, so no exit code belongs on + * it. */ + function chipText() { + if (running) + return runningFor >= 5 + ? stateText + " · " + elapsedText() + : stateText + return stateText + (hasResult && state !== "unfinished" + ? " · exit " + exitCode : "") + } + function lines() { const all = output.split("\n") return all.length > 500 ? all.slice(0, 500).concat(["… " + (all.length - 500) + " more lines"]) : all @@ -92,10 +141,9 @@ Rectangle { implicitHeight: chip.implicitHeight + 2 Text { id: chip + objectName: "toolChip" anchors.centerIn: parent - /* "no result" is not an outcome: no exit code belongs on it. */ - text: card.stateText + (card.hasResult && card.state !== "unfinished" - ? " · exit " + card.exitCode : "") + text: card.chipText() color: card.stateColor font.pixelSize: Theme.smallSize } diff --git a/app/src/ui/UiTest.cpp b/app/src/ui/UiTest.cpp index 6223988..fbee357 100644 --- a/app/src/ui/UiTest.cpp +++ b/app/src/ui/UiTest.cpp @@ -11,6 +11,7 @@ #include #include #include +#include #include #include #include @@ -42,9 +43,23 @@ 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; + 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++; } @@ -243,6 +258,10 @@ int runUiTest(AgentCore &core, const QString &size) { /* 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); const QString resumed = writeFixtureSession(core.workspace(), QStringLiteral("20260101-115959-on")); check(!resumed.isEmpty() && core.switchSession(resumed), @@ -516,6 +535,40 @@ int runUiTest(AgentCore &core, const QString &size) { 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 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(QDateTime::currentMSecsSinceEpoch()) - 258000.0); + card->setProperty("runningFor", 258); + QList 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")); @@ -954,6 +1007,12 @@ int runUiTest(AgentCore &core, const QString &size) { 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; }