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))" }