From 5fea5e0ebe7cccb4e5c60b1c95c385dcd2f01387 Mon Sep 17 00:00:00 2001
From: Bee <302257494+thtbee@users.noreply.github.com>
Date: Tue, 20 Oct 2026 04:12:59 +0530
Subject: [PATCH] CI: open the PC app's main window and fail on QML warnings
MainWindow.qml stopped loading on main (fixed in e4b279a) and CI stayed
green: the QML tests use stubs and can't load the app's windows, and the
installer smoke test starts the app minimized.
`nectarlink-desktop --ui-check --data-dir
` starts the whole app on
its own data, opens the main window, quits after 8 seconds and fails if
QML reported a warning or an error, with Qt's messages on stderr instead
of the log file. scripts/ui-check.ps1 runs it on the staged package, and
the Windows CI job runs that after packaging.
Checked locally: it fails on the MainWindow.qml from before e4b279a
("Cannot assign to non-existent property") and passes on main.
---
.github/workflows/ci.yml | 3 ++
desktop/app/cpp/app_helpers.cpp | 41 +++++++++++++++++++++++++++
desktop/app/cpp/app_helpers.h | 2 ++
desktop/app/src/bridge/native.rs | 6 ++++
desktop/app/src/main.rs | 48 +++++++++++++++++++++++++++++++-
scripts/ui-check.ps1 | 46 ++++++++++++++++++++++++++++++
6 files changed, 145 insertions(+), 1 deletion(-)
create mode 100644 scripts/ui-check.ps1
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 77745268..86f9c814 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -85,6 +85,9 @@ jobs:
- name: Installer
shell: pwsh
run: ./scripts/package.ps1 -SkipBuild
+ - name: Open the main window, with no QML warnings
+ shell: pwsh
+ run: ./scripts/ui-check.ps1
- name: Install, run and uninstall it
shell: pwsh
run: ./scripts/smoke-installer.ps1
diff --git a/desktop/app/cpp/app_helpers.cpp b/desktop/app/cpp/app_helpers.cpp
index 687e7598..412d50d9 100644
--- a/desktop/app/cpp/app_helpers.cpp
+++ b/desktop/app/cpp/app_helpers.cpp
@@ -79,7 +79,37 @@ void register_qml_app_engine(QQmlApplicationEngine &engine)
#include
#include
+#include
+#include
+
namespace {
+std::atomic &qmlProblems()
+{
+ static std::atomic count { 0 };
+ return count;
+}
+
+// A warning or error from QML: a component that didn't load, a binding or
+// a script that failed.
+bool isQmlProblem(QtMsgType type, const QMessageLogContext &ctx, const QString &msg)
+{
+ if (type != QtWarningMsg && type != QtCriticalMsg && type != QtFatalMsg)
+ return false;
+ return (ctx.file && QByteArray(ctx.file).startsWith("qrc:/")) || msg.contains(QStringLiteral("qrc:/"));
+}
+
+// `--ui-check`: messages go to stderr, not the log file, and QML problems
+// are counted.
+void uiCheckMessageHandler(QtMsgType type, const QMessageLogContext &ctx, const QString &msg)
+{
+ if (type == QtDebugMsg)
+ return;
+ if (isQmlProblem(type, ctx, msg))
+ qmlProblems().fetch_add(1);
+ std::fprintf(stderr, "[%d] %s (%s:%d)\n", int(type), qUtf8Printable(msg), ctx.file ? ctx.file : "", ctx.line);
+ std::fflush(stderr);
+}
+
void qtLogMessageHandler(QtMsgType type, const QMessageLogContext &ctx, const QString &msg)
{
if (type == QtDebugMsg)
@@ -102,6 +132,17 @@ void prepare_qt()
prepare_qt_without_log();
}
+void prepare_qt_for_ui_check()
+{
+ qInstallMessageHandler(uiCheckMessageHandler);
+ prepare_qt_without_log();
+}
+
+int32_t qml_problem_count()
+{
+ return qmlProblems().load();
+}
+
void prepare_qt_without_log()
{
// Windows get an alpha channel so the Mica backdrop can show through, and
diff --git a/desktop/app/cpp/app_helpers.h b/desktop/app/cpp/app_helpers.h
index f16d9fea..1b6a9531 100644
--- a/desktop/app/cpp/app_helpers.h
+++ b/desktop/app/cpp/app_helpers.h
@@ -8,6 +8,8 @@
// Qt setup that must happen before QGuiApplication is created.
void prepare_qt();
void prepare_qt_without_log();
+void prepare_qt_for_ui_check();
+int32_t qml_problem_count();
// Adds one size of the app icon (straight-alpha RGBA, size x size).
void add_app_icon_image(int32_t size, rust::Slice rgba);
diff --git a/desktop/app/src/bridge/native.rs b/desktop/app/src/bridge/native.rs
index ecb8f05f..282df8cb 100644
--- a/desktop/app/src/bridge/native.rs
+++ b/desktop/app/src/bridge/native.rs
@@ -20,6 +20,12 @@ pub mod ffi {
/// Like `prepare_qt`, without the QML log file: for the installer
/// and uninstaller, which mustn't create the app's data folder.
fn prepare_qt_without_log();
+ /// Like `prepare_qt`, for `--ui-check`: Qt's messages go to stderr
+ /// instead of the log file, and QML warnings and errors are counted.
+ fn prepare_qt_for_ui_check();
+ /// How many QML warnings and errors there were since
+ /// `prepare_qt_for_ui_check`.
+ fn qml_problem_count() -> i32;
fn add_app_icon_image(size: i32, rgba: &[u8]);
fn apply_app_icon();
fn keep_running_without_windows();
diff --git a/desktop/app/src/main.rs b/desktop/app/src/main.rs
index ccbd9a0b..51da3979 100644
--- a/desktop/app/src/main.rs
+++ b/desktop/app/src/main.rs
@@ -234,8 +234,16 @@ struct Options {
/// The uninstaller's window (`--remove [--installed ]`).
remove: bool,
installed: Option,
+ /// `--ui-check` (needs `--data-dir`): open the main window, quit after
+ /// [`UI_CHECK_SECONDS`], and fail if QML reported a warning or an error.
+ /// Qt's messages go to stderr. For CI (`scripts/ui-check.ps1`).
+ ui_check: bool,
}
+/// How long `--ui-check` keeps the window open: long enough for the window
+/// to load, be restored and settle.
+const UI_CHECK_SECONDS: u64 = 8;
+
fn parse_options() -> Options {
parse_options_from(std::env::args_os().skip(1))
}
@@ -264,6 +272,10 @@ fn parse_options_from>(iter: I) -> Op
in_share = false;
options.uninstall = true;
}
+ Some("--ui-check") => {
+ in_share = false;
+ options.ui_check = true;
+ }
Some(setup::SETUP_ARG) => {
in_share = false;
options.setup = Some(PathBuf::new());
@@ -465,6 +477,11 @@ fn main() -> ExitCode {
}
};
}
+ // The check starts the whole app, so never on the user's own data.
+ if options.ui_check && options.data_dir.is_none() {
+ eprintln!("nectarlink: --ui-check needs --data-dir");
+ return ExitCode::FAILURE;
+ }
let data_dir = options.data_dir.clone().unwrap_or_else(default_data_dir);
if let Err(e) = std::fs::create_dir_all(&data_dir) {
eprintln!("nectarlink: can't create {}: {e}", data_dir.display());
@@ -595,7 +612,11 @@ fn main() -> ExitCode {
send_to::handle(request);
}
- bridge::native::ffi::prepare_qt();
+ if options.ui_check {
+ bridge::native::ffi::prepare_qt_for_ui_check();
+ } else {
+ bridge::native::ffi::prepare_qt();
+ }
let mut app = QGuiApplication::new();
if let Some(mut app) = app.as_mut() {
app.as_mut().set_application_name(&QString::from("Nectarlink"));
@@ -630,6 +651,12 @@ fn main() -> ExitCode {
bridge::native::ffi::register_qml_app_engine(engine.as_mut());
engine.load(&QUrl::from("qrc:/qt/qml/app/nectarlink/qml/App.qml"));
}
+ if options.ui_check {
+ std::thread::spawn(|| {
+ std::thread::sleep(Duration::from_secs(UI_CHECK_SECONDS));
+ bridge::app::request_quit();
+ });
+ }
let code = app.as_mut().map_or(1, |app| app.exec());
tracing::info!(code, "shutting down");
@@ -644,6 +671,14 @@ fn main() -> ExitCode {
win::net::stop();
core_host::shutdown();
drop(app);
+ if options.ui_check {
+ let problems = bridge::native::ffi::qml_problem_count();
+ if problems > 0 {
+ eprintln!("nectarlink: --ui-check: {problems} QML warning(s) or error(s), see above");
+ return ExitCode::FAILURE;
+ }
+ eprintln!("nectarlink: --ui-check: no QML warnings");
+ }
ExitCode::from(u8::try_from(code).unwrap_or(1))
}
@@ -672,6 +707,17 @@ mod tests {
);
}
+ #[test]
+ fn parse_options_recognizes_ui_check_flag() {
+ let opts = parse_options_from(
+ ["--ui-check", "--data-dir", r"C:\Temp\nectarlink-test"].into_iter().map(OsString::from),
+ );
+ assert!(opts.ui_check);
+ assert!(!opts.minimized);
+ assert_eq!(opts.data_dir, Some(PathBuf::from(r"C:\Temp\nectarlink-test")));
+ assert!(!parse_options_from(["--minimized"].into_iter().map(OsString::from)).ui_check);
+ }
+
#[test]
fn parse_options_recognizes_task_flag() {
let find_opts = parse_options_from(
diff --git a/scripts/ui-check.ps1 b/scripts/ui-check.ps1
new file mode 100644
index 00000000..0fced72d
--- /dev/null
+++ b/scripts/ui-check.ps1
@@ -0,0 +1,46 @@
+# SPDX-License-Identifier: GPL-3.0-or-later
+<#
+.SYNOPSIS
+ Opens the Windows app's main window for a few seconds and fails on any
+ QML warning or error.
+
+.DESCRIPTION
+ Runs nectarlink-desktop.exe --ui-check with a new data folder under
+ target\ui-check, so the user's own data is never touched, and without
+ USB (NECTARLINK_NO_ADB=1). The app shows the main window, quits after a
+ few seconds and exits with an error if QML reported a problem, such as a
+ component that didn't load. The QML tests can't catch that for the app's
+ windows, which need the Rust singletons and NativeWindow.
+
+ -Exe defaults to the staged package (scripts\package.ps1), which has its
+ Qt libraries next to it; for target\release, pass -Exe and have Qt's bin
+ folder on PATH.
+
+.EXAMPLE
+ .\scripts\package.ps1 -StageOnly; .\scripts\ui-check.ps1
+#>
+param(
+ [string]$Exe
+)
+
+$ErrorActionPreference = 'Stop'
+$repo = Split-Path -Parent $PSScriptRoot
+if (-not $Exe) { $Exe = Join-Path $repo 'target\package\Nectarlink\nectarlink-desktop.exe' }
+if (-not (Test-Path $Exe)) { throw "$Exe not found; run scripts\package.ps1 -StageOnly first, or pass -Exe" }
+
+$data = Join-Path $repo 'target\ui-check'
+if (Test-Path $data) { Remove-Item -Recurse -Force $data }
+New-Item -ItemType Directory -Force $data | Out-Null
+$stderr = Join-Path $data 'stderr.txt'
+
+$env:NECTARLINK_NO_ADB = '1'
+$app = Start-Process $Exe -ArgumentList '--ui-check', '--data-dir', "`"$data`"" `
+ -RedirectStandardError $stderr -PassThru -NoNewWindow
+# Without the handle, ExitCode stays empty after the process exits.
+$null = $app.Handle
+if (-not $app.WaitForExit(120000)) {
+ $app.Kill()
+ throw "the app didn't quit within 2 minutes"
+}
+Get-Content $stderr | Write-Host
+if ($app.ExitCode -ne 0) { throw "UI check failed (exit code $($app.ExitCode))" }