diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 214b57d..8c28db1 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -192,6 +192,9 @@ jobs: - name: Integration test (UI tests) run: nix build .#integration-test -L + - name: Shutdown test + run: nix build .#shutdown-test -L + # Guards the capability trust root end to end: asserts a NON-"core" # identity actually completes a capability-gated call chain, so a # logos-liblogos / default-module-loader pin that predates the @@ -266,6 +269,9 @@ jobs: - name: Integration test (UI tests) run: nix build .#integration-test-bundle -L + - name: Shutdown test + run: nix build .#shutdown-test -L + # macOS twin of the Linux job's host-services guard, run against the # real .app bundle (same pairing as integration-test-bundle above). - name: Host-services grant guard diff --git a/app/window.cpp b/app/window.cpp index c55eb5d..349a58f 100644 --- a/app/window.cpp +++ b/app/window.cpp @@ -492,7 +492,7 @@ void Window::closeEvent(QCloseEvent *event) ); } } else { - // If system tray is not available, quit normally + // No tray: close only hides — setQuitOnLastWindowClosed(false) keeps the app running. event->accept(); } } diff --git a/flake.nix b/flake.nix index 935826a..ffbff98 100644 --- a/flake.nix +++ b/flake.nix @@ -500,6 +500,15 @@ # aarch64-darwin host without one.) binBundleDir = withMainProgram (dirBundler appDistributed); binBundleDirInspector = withMainProgram (dirBundler appDistributedWithInspector); + + # Hoisted so shutdown-test can read the elapsed time for the combined PR-gate budget. + integrationTest = import ./nix/integration-test.nix { inherit pkgs src logosQtMcp; appPkg = app; }; + integrationTestBundle = import ./nix/integration-test.nix { + inherit pkgs src; + appPkg = macosAppTest; + inherit logosQtMcp; + appBin = "${macosAppTest}/LogosBasecamp.app/Contents/MacOS/LogosBasecamp"; + }; in { # Individual outputs. @@ -583,7 +592,7 @@ }; # Integration test (UI tests via Qt Inspector) - integration-test = import ./nix/integration-test.nix { inherit pkgs src logosQtMcp; appPkg = app; }; + integration-test = integrationTest; # Host-services grant guard. Asserts that a NON-"core" identity # (ui-host running package_manager_ui) actually completes a @@ -598,7 +607,11 @@ # Shutdown tests (SIGTERM, SIGINT, Ctrl+Q / ⌘Q). Spawns a fresh # app per case and asserts orderly exit (code 0). - shutdown-test = import ./nix/shutdown-test.nix { inherit pkgs src logosQtMcp; appPkg = app; }; + shutdown-test = import ./nix/shutdown-test.nix { + inherit pkgs src logosQtMcp; + appPkg = app; + uiTestRun = if pkgs.stdenv.isDarwin then integrationTestBundle else integrationTest; + }; # Default package default = app; diff --git a/nix/integration-test.nix b/nix/integration-test.nix index 6fc00f5..7c25241 100644 --- a/nix/integration-test.nix +++ b/nix/integration-test.nix @@ -3,9 +3,12 @@ # and runs UI tests (click buttons, verify text, etc.). # # Requires Node.js for the test runner and the Qt offscreen platform plugin. -{ pkgs, src, appPkg, logosQtMcp, appBin ? "${appPkg}/bin/LogosBasecamp", timeoutSec ? 120 }: +{ pkgs, src, appPkg, logosQtMcp, appBin ? "${appPkg}/bin/LogosBasecamp", timeoutSec ? 120 +# Combined PR-gate budget; elapsed goes to $out/elapsed-seconds, combined check in shutdown-test.nix. +, budgetSec ? 600 }: pkgs.runCommand "logos-basecamp-integration-test" { + MCP_TEST_BUDGET_SECONDS = toString budgetSec; nativeBuildInputs = [ pkgs.coreutils pkgs.nodejs ] ++ pkgs.lib.optionals pkgs.stdenv.isLinux [ pkgs.qt6.qtbase # provides the offscreen platform plugin @@ -30,10 +33,30 @@ pkgs.runCommand "logos-basecamp-integration-test" { # Point test framework at the nix-built logos-qt-mcp package export LOGOS_QT_MCP="${logosQtMcp}" + # Tee output into BASECAMP_APP_LOG for the G-ERR gate; exec keeps the framework-spawned PID. + export BASECAMP_APP_LOG="$out/app.log" + : > "$BASECAMP_APP_LOG" + cat > app-with-log.sh <<'WRAPPER' + #!${pkgs.bash}/bin/bash + # Process substitution below is a bashism; keep the shebang explicitly bash. + set -euo pipefail + exec ${appBin} "$@" \ + > >(tee -a "$BASECAMP_APP_LOG") \ + 2> >(tee -a "$BASECAMP_APP_LOG" >&2) + WRAPPER + chmod +x app-with-log.sh + echo "Running logos-basecamp integration tests (timeout: ${toString timeoutSec}s)..." + start=$(date +%s) timeout ${toString timeoutSec} \ - ${pkgs.nodejs}/bin/node ${src}/tests/ui-tests.mjs --ci ${appBin} --verbose + ${pkgs.nodejs}/bin/node ${src}/tests/ui-tests.mjs --ci "$PWD/app-with-log.sh" --verbose + elapsed=$(( $(date +%s) - start )) + echo "$elapsed" > $out/elapsed-seconds - echo "Integration tests passed" + echo "Integration tests passed in ''${elapsed}s (budget: ''${MCP_TEST_BUDGET_SECONDS}s for both PR-gate suites combined)" + if [ "$elapsed" -gt "$MCP_TEST_BUDGET_SECONDS" ]; then + echo "ERROR: ui-tests alone took ''${elapsed}s, over the ''${MCP_TEST_BUDGET_SECONDS}s combined budget" >&2 + exit 1 + fi '' diff --git a/nix/shutdown-test.nix b/nix/shutdown-test.nix index daf2bf7..a0bada0 100644 --- a/nix/shutdown-test.nix +++ b/nix/shutdown-test.nix @@ -4,9 +4,12 @@ # cleanly via the orderly teardown in app/main.cpp. # # Requires Node.js, the Qt offscreen platform, and the MCP inspector. -{ pkgs, src, appPkg, logosQtMcp, appBin ? "${appPkg}/bin/LogosBasecamp", timeoutSec ? 180 }: +{ pkgs, src, appPkg, logosQtMcp, appBin ? "${appPkg}/bin/LogosBasecamp", timeoutSec ? 180 +# Adds uiTestRun's elapsed-seconds; fails when the combined total exceeds budgetSec. +, budgetSec ? 600, uiTestRun ? null }: pkgs.runCommand "logos-basecamp-shutdown-test" { + MCP_TEST_BUDGET_SECONDS = toString budgetSec; nativeBuildInputs = [ pkgs.coreutils pkgs.nodejs ] ++ pkgs.lib.optionals pkgs.stdenv.isLinux [ pkgs.qt6.qtbase @@ -32,8 +35,21 @@ pkgs.runCommand "logos-basecamp-shutdown-test" { echo "Running logos-basecamp shutdown tests (timeout: ${toString timeoutSec}s)..." + start=$(date +%s) timeout ${toString timeoutSec} \ ${pkgs.nodejs}/bin/node ${src}/tests/shutdown-tests.mjs ${appBin} + elapsed=$(( $(date +%s) - start )) + echo "$elapsed" > $out/elapsed-seconds - echo "Shutdown tests passed" + combined=$elapsed + ${pkgs.lib.optionalString (uiTestRun != null) '' + prior=$(cat ${uiTestRun}/elapsed-seconds) + combined=$(( combined + prior )) + echo "Shutdown tests took ''${elapsed}s; combined with the integration test (''${prior}s): ''${combined}s" + ''} + echo "Shutdown tests passed (combined suite time: ''${combined}s, budget: ''${MCP_TEST_BUDGET_SECONDS}s)" + if [ "$combined" -gt "$MCP_TEST_BUDGET_SECONDS" ]; then + echo "ERROR: the PR-gate suites took ''${combined}s combined, over the ''${MCP_TEST_BUDGET_SECONDS}s budget" >&2 + exit 1 + fi '' diff --git a/src/Basecamp/AppManager/AddApplicationDialog.qml b/src/Basecamp/AppManager/AddApplicationDialog.qml index fe61ea6..062c55d 100644 --- a/src/Basecamp/AppManager/AddApplicationDialog.qml +++ b/src/Basecamp/AppManager/AddApplicationDialog.qml @@ -289,6 +289,7 @@ Dialog { } LogosIconButton { + objectName: "addApplicationDialog.closeButton" iconSource: LogosIcons.close size: 28 iconSize: 14 @@ -416,6 +417,7 @@ Dialog { LogosButton { id: actionButton + objectName: "addApplicationDialog.primaryButton" Layout.alignment: Qt.AlignVCenter Layout.preferredWidth: 110 Layout.preferredHeight: 40 @@ -494,6 +496,7 @@ Dialog { // On failure, show the error here; falls back to a generic line if the // backend didn't carry a reason. LogosText { + objectName: "addApplicationDialog.errorText" Layout.fillWidth: true Layout.leftMargin: Theme.spacing.large Layout.rightMargin: Theme.spacing.large @@ -510,6 +513,7 @@ Dialog { // Resolver couldn't resolve one or more deps. Surfaced as a // warning-tone line LogosText { + objectName: "addApplicationDialog.resolutionBanner" Layout.fillWidth: true Layout.leftMargin: Theme.spacing.large Layout.rightMargin: Theme.spacing.large diff --git a/src/Basecamp/AppManager/AppContextMenu.qml b/src/Basecamp/AppManager/AppContextMenu.qml index 81d4c99..fba1ad1 100644 --- a/src/Basecamp/AppManager/AppContextMenu.qml +++ b/src/Basecamp/AppManager/AppContextMenu.qml @@ -7,6 +7,8 @@ import Basecamp.Backend 1.0 LogosMenu { id: root + objectName: "appContextMenu" + // Plain object snapshot of the row: name, displayName, repositoryUrl, // isInstalled, installStage, installStatus, installType. Set via // openFor() — never bound to a delegate's `model`, which would dangle diff --git a/src/Basecamp/AppManager/AppManagerView.qml b/src/Basecamp/AppManager/AppManagerView.qml index 11f18fd..da01b99 100644 --- a/src/Basecamp/AppManager/AppManagerView.qml +++ b/src/Basecamp/AppManager/AppManagerView.qml @@ -108,6 +108,7 @@ Rectangle { LogosSearchBar { id: searchBar + objectName: "appManager.searchField" Layout.alignment: Qt.AlignRight Layout.preferredWidth: 605 Layout.minimumWidth: 200 @@ -161,6 +162,7 @@ Rectangle { delegate: LogosItemDelegate { id: cell + objectName: "appManager.category." + modelData width: ListView.view.width text: modelData highlighted: ListView.isCurrentItem @@ -205,6 +207,7 @@ Rectangle { // AND there are no local-only installed rows to fall back on. // If either exists, we render the grid instead. EmptyView { + objectName: "appManager.emptyView" Layout.fillWidth: true Layout.fillHeight: true visible: root.repositories.length === 0 diff --git a/src/Basecamp/Settings/PluginInterfaceView.qml b/src/Basecamp/Settings/PluginInterfaceView.qml index d18c972..fe84f3f 100644 --- a/src/Basecamp/Settings/PluginInterfaceView.qml +++ b/src/Basecamp/Settings/PluginInterfaceView.qml @@ -55,8 +55,9 @@ Item { spacing: 16 Button { + objectName: "pluginInterface.back" text: "← Back" - + contentItem: LogosText { text: parent.text color: "#ffffff" @@ -159,8 +160,9 @@ Item { Item { Layout.fillWidth: true } Button { + objectName: "pluginInterface.call." + (modelData.name || modelData) text: "Call" - + contentItem: LogosText { text: parent.text font.pixelSize: Theme.typography.secondaryText @@ -292,6 +294,7 @@ Item { clip: true TextArea { + objectName: "pluginInterface.result" text: root.resultText font.pixelSize: 12 font.family: Theme.typography.publicSans diff --git a/src/Basecamp/Settings/RepositoriesView.qml b/src/Basecamp/Settings/RepositoriesView.qml index 946e05b..4cc6be6 100644 --- a/src/Basecamp/Settings/RepositoriesView.qml +++ b/src/Basecamp/Settings/RepositoriesView.qml @@ -68,6 +68,7 @@ Item { } LogosButton { + objectName: "repositories.refreshButton" text: root.loading ? qsTr("Refreshing…") : qsTr("Refresh") enabled: !root.loading implicitWidth: 120 @@ -78,6 +79,7 @@ Item { // Error banner — shown until the next successful op. Rectangle { + objectName: "repositories.errorBanner" Layout.fillWidth: true visible: d.lastError.length > 0 radius: Theme.spacing.radiusSmall @@ -100,6 +102,7 @@ Item { wrapMode: Text.WordWrap } LogosIconButton { + objectName: "repositories.errorDismiss" iconSource: LogosIcons.close size: 20 iconSize: 14 @@ -134,12 +137,14 @@ Item { LogosTextField { id: urlInput + objectName: "repositories.urlField" Layout.fillWidth: true placeholderText: qsTr("https://example.com/logos-repo.json") text: d.newRepoUrl onTextChanged: if (text !== d.newRepoUrl) d.newRepoUrl = text } LogosButton { + objectName: "repositories.addButton" text: qsTr("Add") enabled: d.newRepoUrl.trim().length > 0 implicitWidth: 100 @@ -170,6 +175,7 @@ Item { readonly property bool isDefault: modelData.isDefault === true readonly property bool isEnabled: modelData.enabled !== false + objectName: "repositories.row." + url Layout.fillWidth: true implicitHeight: rowCol.implicitHeight + Theme.spacing.large * 2 radius: Theme.spacing.radiusLarge @@ -310,6 +316,7 @@ Item { leftActions: [ LogosButton { + objectName: "repositories.removeConfirm.cancel" text: qsTr("Cancel") onClicked: { d.pendingRemoveUrl = "" @@ -320,6 +327,7 @@ Item { rightActions: [ LogosButton { + objectName: "repositories.removeConfirm.confirm" text: qsTr("Remove") variant: LogosButton.Variant.Primary onClicked: { diff --git a/src/Basecamp/Settings/SettingsView.qml b/src/Basecamp/Settings/SettingsView.qml index 98682ab..582f701 100644 --- a/src/Basecamp/Settings/SettingsView.qml +++ b/src/Basecamp/Settings/SettingsView.qml @@ -104,6 +104,7 @@ Rectangle { LogosSearchBar { id: searchBar + objectName: "settings.searchField" visible: d.searchable Layout.alignment: Qt.AlignRight Layout.preferredWidth: 605 diff --git a/src/Basecamp/Shell/WelcomePage.qml b/src/Basecamp/Shell/WelcomePage.qml index 65a6286..b021691 100644 --- a/src/Basecamp/Shell/WelcomePage.qml +++ b/src/Basecamp/Shell/WelcomePage.qml @@ -46,6 +46,7 @@ Item { } LogosButton { + objectName: "welcomePage.installNow" Layout.alignment: Qt.AlignHCenter Layout.topMargin: Theme.spacing.medium Layout.preferredWidth: 200 diff --git a/src/Basecamp/Sidebar/SidebarAppDelegate.qml b/src/Basecamp/Sidebar/SidebarAppDelegate.qml index 548714d..764f3b4 100644 --- a/src/Basecamp/Sidebar/SidebarAppDelegate.qml +++ b/src/Basecamp/Sidebar/SidebarAppDelegate.qml @@ -26,6 +26,9 @@ AbstractButton { property bool hasMissingDeps: false property string appName: "" + // Test hook: whether this app is the front-most (active) one. + readonly property bool active: checked + implicitHeight: 50 hoverEnabled: true diff --git a/src/Basecamp/Sidebar/SidebarPanel.qml b/src/Basecamp/Sidebar/SidebarPanel.qml index 8bc780e..be16f4e 100644 --- a/src/Basecamp/Sidebar/SidebarPanel.qml +++ b/src/Basecamp/Sidebar/SidebarPanel.qml @@ -189,6 +189,7 @@ Control { // ("Dev build" / "Portable build"). Selectable so the release tag can // be copied out of the sidebar. LogosSelectableText { + objectName: "sidebar.buildLabel" Layout.fillWidth: true Layout.alignment: Qt.AlignHCenter horizontalAlignment: TextEdit.AlignHCenter diff --git a/src/WorkspaceArea.cpp b/src/WorkspaceArea.cpp index 9978471..f547051 100644 --- a/src/WorkspaceArea.cpp +++ b/src/WorkspaceArea.cpp @@ -169,6 +169,7 @@ constexpr int kTabBarInsetPx = 24; WorkspaceArea::WorkspaceArea(QObject* backend, QWidget* parent) : QMainWindow(parent) { + setObjectName(QStringLiteral("workspace")); setWindowFlags(Qt::Widget); setDockOptions(QMainWindow::AllowNestedDocks @@ -298,6 +299,7 @@ void WorkspaceArea::toggleLayoutModeForTesting() } QTimer::singleShot(0, this, [this]() { styleAllTabBars(); }); + emit dockLayoutChanged(); }); } @@ -442,6 +444,7 @@ void WorkspaceArea::addPluginDock(QWidget* pluginWidget, ensurePhantomTab(); updateQmlPluginActiveStates(); updateWelcomeVisibility(); + emit dockLayoutChanged(); } void WorkspaceArea::removePluginDock(const QString& name) @@ -466,6 +469,13 @@ void WorkspaceArea::removePluginDock(const QString& name) else ensurePhantomTab(); updateQmlPluginActiveStates(); updateWelcomeVisibility(); + emit dockLayoutChanged(); +} + +void WorkspaceArea::closeDock(const QString& moduleName) +{ + if (!m_docks.contains(moduleName)) return; + emit pluginClosed(moduleName); } void WorkspaceArea::activatePluginDock(const QString& moduleName) diff --git a/src/WorkspaceArea.h b/src/WorkspaceArea.h index c12244b..2808c0d 100644 --- a/src/WorkspaceArea.h +++ b/src/WorkspaceArea.h @@ -18,10 +18,23 @@ class WorkspaceArea : public QMainWindow { Q_OBJECT + // Read-only test hooks, read via inspector evaluate on objectName "workspace". + Q_PROPERTY(int dockCount READ dockCount NOTIFY dockLayoutChanged) + Q_PROPERTY(QStringList dockOrder READ dockOrder NOTIFY dockLayoutChanged) + Q_PROPERTY(QString layoutMode READ layoutMode NOTIFY dockLayoutChanged) + public: explicit WorkspaceArea(QObject* backend = nullptr, QWidget* parent = nullptr); ~WorkspaceArea() override; + int dockCount() const { return m_dockOrder.size(); } + QStringList dockOrder() const { return m_dockOrder; } + QString layoutMode() const { + return m_sideBySide ? QStringLiteral("sideBySide") + : QStringLiteral("tabbed"); + } + Q_INVOKABLE void closeDock(const QString& moduleName); + void addPluginDock(QWidget* pluginWidget, const QString& moduleName, const QString& displayLabel = {}); @@ -39,6 +52,7 @@ public: signals: void pluginClosed(const QString& moduleName); + void dockLayoutChanged(); void installClicked(); // Emitted when the front-most dock changes (tab click, close, activate). // Empty string when no real dock is current (welcome page). diff --git a/tests/fixtures/harness.mjs b/tests/fixtures/harness.mjs new file mode 100644 index 0000000..29b83db --- /dev/null +++ b/tests/fixtures/harness.mjs @@ -0,0 +1,317 @@ +// Shared suite helpers — the framework itself lives in logos-qt-mcp (not editable here). + +import { execFileSync } from "node:child_process"; +import { + existsSync, mkdirSync, readdirSync, readFileSync, statSync, +} from "node:fs"; +import { join, resolve } from "node:path"; +import { tmpdir } from "node:os"; + +export const SHUTDOWN_TIER = + (process.env.SHUTDOWN_TIER || "pr").trim().toLowerCase(); + +export function sleep(ms) { + return new Promise((r) => setTimeout(r, ms)); +} + +export async function withTimeout(promise, ms, what) { + let timer; + const timeout = new Promise((_, reject) => { + timer = setTimeout( + () => reject(new Error(`timed out after ${ms}ms waiting for ${what}`)), + ms); + }); + try { + return await Promise.race([promise, timeout]); + } finally { + clearTimeout(timer); + } +} + +export function waitForExit(child, ms) { + return new Promise((resolveExit) => { + if (child.exitCode !== null || child.signalCode !== null) { + return resolveExit({ code: child.exitCode, signal: child.signalCode }); + } + const timer = setTimeout(() => resolveExit(null), ms); + child.once("exit", (code, signal) => { + clearTimeout(timer); + resolveExit({ code, signal }); + }); + }); +} + +export async function findByObjectName(inspector, name) { + const res = await inspector.send("findByProperty", { + property: "objectName", value: name, + }); + return (res.matches ?? [])[0] || null; +} + +export async function expectAbsent(app, target, ms = 3000, opts = {}) { + const { intervalMs = 400, treeDepth = 50 } = opts; + const deadline = Date.now() + ms; + for (;;) { + const byName = await app.inspector.send("findByProperty", { + property: "objectName", value: target, + }); + if ((byName.matches ?? []).length > 0) { + throw new Error( + `expectAbsent: objectName "${target}" is present (expected absent)`); + } + const tree = await app.getTree({ depth: treeDepth }); + if (JSON.stringify(tree).includes(target)) { + throw new Error( + `expectAbsent: "${target}" appeared in the QML tree (expected absent)`); + } + if (Date.now() >= deadline) return; + await sleep(Math.min(intervalMs, Math.max(1, deadline - Date.now()))); + } +} + +// Returns distinct consecutive values so tests can assert transient stages. +export async function pollProperty(app, objectId, prop, ms, opts = {}) { + const { intervalMs = 100, until = null } = opts; + const start = Date.now(); + const samples = []; + for (;;) { + const res = await app.inspector.send("evaluate", { + objectId, expression: prop, + }); + if (res.error) { + throw new Error(`pollProperty: evaluate("${prop}") failed: ${res.error}`); + } + const value = res.result; + if (samples.length === 0 || samples[samples.length - 1].value !== value) { + samples.push({ t: Date.now() - start, value }); + } + if (until && until(value)) return samples; + if (Date.now() - start >= ms) return samples; + await sleep(intervalMs); + } +} + +export async function assertResponsive(app, opts = {}) { + const { timeoutMs = 5000 } = opts; + try { + await withTimeout(app.getTree({ depth: 1 }), timeoutMs, "inspector round-trip"); + } catch (e) { + throw new Error(`G-ALIVE: app is not responding to the inspector — ${e.message}`); + } +} + +// G-ERR: deliberately narrow — a broad log-regex tripped on unrelated stderr +// noise (nix/smoke-test.nix). File mode (BASECAMP_APP_LOG) no-ops when unset. +const QML_ERROR_RE = + /\.qml:\d+(?::\d+)?:?\s.*(Error|error:|is not a type|is not defined|No such file|Cannot assign|Unable to assign)/; + +export function scanForQmlErrors(text) { + return text.split("\n").filter((line) => QML_ERROR_RE.test(line)); +} + +const qmlErrorLogPath = process.env.BASECAMP_APP_LOG || null; +let qmlErrorBaselineOffset = 0; + +export function markQmlErrorBaseline() { + if (!qmlErrorLogPath) return; + try { + qmlErrorBaselineOffset = statSync(qmlErrorLogPath).size; + } catch { + qmlErrorBaselineOffset = 0; + } +} + +export function assertNoNewQmlErrors(text = null) { + let candidate = ""; + if (text !== null) { + candidate = text; + } else if (qmlErrorLogPath) { + try { + candidate = readFileSync(qmlErrorLogPath, "utf-8").slice(qmlErrorBaselineOffset); + } catch { + return; // log vanished — nothing to assert against + } + } else { + return; // no log source wired — G-ERR is a no-op in this configuration + } + const errors = scanForQmlErrors(candidate); + if (errors.length > 0) { + throw new Error( + `G-ERR: ${errors.length} new QML error(s):\n ${errors.join("\n ")}`); + } +} + +// Adds the G-ERR/G-ALIVE epilogue and { xfail } support (loud on unexpected pass). +export function makeTest(frameworkTest) { + return function test(name, body, opts = {}) { + const { xfail, ...frameworkOpts } = opts; + frameworkTest(name, async (app) => { + markQmlErrorBaseline(); + let bodyError = null; + try { + await body(app); + } catch (e) { + bodyError = e; + } + let epilogueError = null; + try { + await assertResponsive(app); + assertNoNewQmlErrors(); + } catch (e) { + epilogueError = e; + } + if (bodyError) { + if (xfail) { + console.log(` XFAIL (${xfail}): ${bodyError.message}`); + if (epilogueError) throw epilogueError; + return; + } + throw bodyError; // the body's failure outranks epilogue noise + } + if (xfail) { + throw new Error( + `XPASS: test passed but is marked xfail (${xfail}) — the fix ` + + `landed, remove the xfail marker`); + } + if (epilogueError) throw epilogueError; + }, frameworkOpts); + }; +} + +// G-EXIT: exit 0, no signal, complete final log line, no orphans or partial files. +export async function assertCleanTeardown(child, opts = {}) { + const { + waitMs = 10000, log = null, closed = null, + userDir = null, partialBaseline = null, + } = opts; + + const exit = await waitForExit(child, waitMs); + if (!exit) throw new Error(`G-EXIT: did not exit within ${waitMs}ms`); + if (exit.signal) { + throw new Error( + `G-EXIT: terminated by signal ${exit.signal} instead of orderly exit`); + } + if (exit.code !== 0) throw new Error(`G-EXIT: exit code ${exit.code}, want 0`); + + if (log) { + // 'exit' can fire with data still in flight — wait for the streams to close. + if (closed) await withTimeout(closed, 3000, "stdio streams to close"); + const text = log(); + if (text.length > 0 && !text.endsWith("\n")) { + throw new Error( + `G-EXIT: log truncated mid-line at exit — last bytes: ` + + JSON.stringify(text.slice(-120))); + } + } + + if (userDir) { + assertNoOrphanProcesses(userDir, [child.pid, process.pid]); + if (partialBaseline) assertNoNewPartialFiles(userDir, partialBaseline); + } +} + +export function assertNoOrphanProcesses(userDir, excludePids = []) { + let psOut; + try { + psOut = execFileSync("ps", ["-eo", "pid=,args="], { encoding: "utf-8" }); + } catch { + return; // ps unavailable (bare container) — nothing to assert against + } + const needle = resolve(userDir); + const orphans = []; + for (const line of psOut.split("\n")) { + if (!line.includes(needle)) continue; + const pid = parseInt(line.trim().split(/\s+/)[0], 10); + if (Number.isNaN(pid) || excludePids.includes(pid)) continue; + orphans.push(line.trim()); + } + if (orphans.length > 0) { + throw new Error( + `G-EXIT: orphan process(es) still on the user-dir:\n ${orphans.join("\n ")}`); + } +} + +const PARTIAL_FILE_RE = /\.(tmp|temp|part|partial|download)$|(^|\/)\.#/; + +export function snapshotPartialFiles(userDir) { + const found = new Set(); + if (!existsSync(userDir)) return found; + const walk = (dir) => { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const p = join(dir, entry.name); + if (entry.isDirectory()) walk(p); + else if (PARTIAL_FILE_RE.test(p)) found.add(p); + } + }; + walk(userDir); + return found; +} + +export function assertNoNewPartialFiles(userDir, baseline) { + const now = snapshotPartialFiles(userDir); + const fresh = [...now].filter((p) => !baseline.has(p)); + if (fresh.length > 0) { + throw new Error( + `G-EXIT: new temp/partial file(s) left behind:\n ${fresh.join("\n ")}`); + } +} + +let userDirCounter = 0; + +export function makeUserDir(prefix = "basecamp-test-user") { + const dir = join( + tmpdir(), `${prefix}-${process.pid}-${Date.now()}-${userDirCounter++}`); + mkdirSync(join(dir, "modules"), { recursive: true }); + mkdirSync(join(dir, "plugins"), { recursive: true }); + return dir; +} + +// Install a local .lgx via PMU's installLocalPackage + Basecamp's install gate. +// Requires the Package Manager view opened once (pmui.BackendStore is lazy). +export async function installViaPmu(app, lgxPath, opts = {}) { + const { confirm = true, timeoutMs = 30000, intervalMs = 300 } = opts; + const inspector = app.inspector; + + const store = await findByObjectName(inspector, "pmui.BackendStore"); + if (!store) { + throw new Error( + "installViaPmu: pmui.BackendStore not found — open the Package " + + "Manager view first (it is created lazily on first activation)"); + } + const fileUrl = lgxPath.startsWith("file://") + ? lgxPath + : `file://${resolve(lgxPath)}`; + const call = await inspector.send("callMethod", { + objectId: store.id, method: "installLocalPackage", args: [fileUrl], + }); + if (call.error) { + throw new Error(`installViaPmu: installLocalPackage failed: ${call.error}`); + } + + // Gate open = visible; closed dialog instances keep stale texts in the tree. + const gate = await findByObjectName(inspector, "confirmationDialog.installGate"); + if (!gate) throw new Error("installViaPmu: installGate dialog object not found"); + const deadline = Date.now() + timeoutMs; + for (;;) { + const res = await inspector.send("evaluate", { + objectId: gate.id, expression: "visible", + }); + if (res.result === true) break; + if (Date.now() >= deadline) { + throw new Error(`installViaPmu: install gate did not open within ${timeoutMs}ms`); + } + await sleep(intervalMs); + } + + const buttonName = confirm + ? "confirmationDialog.installGate.confirm" + : "confirmationDialog.installGate.cancel"; + const button = await findByObjectName(inspector, buttonName); + if (!button) throw new Error(`installViaPmu: ${buttonName} not found`); + const clicked = await inspector.send("callMethod", { + objectId: button.id, method: "clicked", + }); + if (clicked.error) { + throw new Error(`installViaPmu: clicking ${buttonName} failed: ${clicked.error}`); + } +} diff --git a/tests/fixtures/lgx.mjs b/tests/fixtures/lgx.mjs new file mode 100644 index 0000000..ff97b47 --- /dev/null +++ b/tests/fixtures/lgx.mjs @@ -0,0 +1,221 @@ +// LGX fixture generator: an .lgx is a gzipped ustar tar (manifest.json + payload +// under variants//); the lgx CLI (LGX_CLI) stamps the required content +// hashes. Core-module fixtures are manifest-level only, not loadable binaries. + +import { execFileSync } from "node:child_process"; +import { mkdirSync, writeFileSync, rmSync, existsSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { tmpdir } from "node:os"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const projectRoot = resolve(__dirname, "../.."); + +export const LGX_CLI = + process.env.LGX_CLI || resolve(projectRoot, "result-lgx/bin/lgx"); + +// The portable bundle installs plain variant names — no `-dev` suffix. +export function platformVariant() { + const key = `${process.platform} ${process.arch}`; + const map = { + "linux x64": "linux-x86_64", + "linux arm64": "linux-arm64", + "darwin arm64": "darwin-arm64", + "darwin x64": "darwin-x86_64", + }; + const variant = map[key]; + if (!variant) throw new Error(`unsupported platform: ${key}`); + return variant; +} + +function manifestFor({ name, displayName, version, type, dependencies, description }) { + const manifest = { + author: "", + category: "testing", + dependencies, + description, + display_name: displayName, + icon: "", + main: {}, + manifestVersion: "0.3.0", + name, + type, + version, + }; + if (type === "ui_qml") manifest.view = "Main.qml"; + return manifest; +} + +function metadataFor({ name, displayName, version, type, dependencies, description }) { + const metadata = { + name, + display_name: displayName, + version, + type, + category: "testing", + description, + dependencies, + }; + if (type === "ui_qml") metadata.view = "Main.qml"; + return metadata; +} + +function qmlViewFor({ name, displayName, version }) { + return `import QtQuick + +Rectangle { + id: root + color: "#1e1e1e" + Text { + anchors.centerIn: parent + text: "${displayName} (${name}) v${version}" + color: "#ffffff" + font.pixelSize: 24 + } +} +`; +} + +export function writePayload(payloadDir, spec) { + mkdirSync(payloadDir, { recursive: true }); + writeFileSync(join(payloadDir, "metadata.json"), + JSON.stringify(metadataFor(spec), null, 2) + "\n"); + if (spec.type === "ui_qml") { + writeFileSync(join(payloadDir, "Main.qml"), qmlViewFor(spec)); + writeFileSync(join(payloadDir, "qmldir"), + `module com.logos.module.${spec.name}\n`); + } +} + +export function makeLgx(opts) { + const { + outFile, + name, + version = "0.1.0", + displayName = name, + type = "ui_qml", + dependencies = [], + description = `basecamp test fixture package (${name})`, + lgx = LGX_CLI, + variant = platformVariant(), + verify = true, + } = opts; + if (!outFile || !name) throw new Error("makeLgx: outFile and name are required"); + if (!existsSync(lgx)) { + throw new Error( + `makeLgx: lgx CLI not found at ${lgx} — build it with ` + + `"nix build 'github:logos-co/logos-package#lgx' -o result-lgx" ` + + `or set LGX_CLI`); + } + const spec = { name, displayName, version, type, dependencies, description }; + + const work = join(tmpdir(), `lgx-build-${process.pid}-${name}-${version}`); + const seed = join(work, "seed"); + const payload = join(work, "payload"); + rmSync(work, { recursive: true, force: true }); + mkdirSync(join(seed, "variants"), { recursive: true }); + writeFileSync(join(seed, "manifest.json"), + JSON.stringify(manifestFor(spec), null, 2) + "\n"); + writePayload(payload, spec); + + mkdirSync(dirname(outFile), { recursive: true }); + // Plain ustar — the LGX reader doesn't speak pax extended headers. + execFileSync("tar", + ["--format", "ustar", "-C", seed, "-czf", outFile, "manifest.json", "variants"]); + execFileSync(lgx, ["add", outFile, "--variant", variant, "--files", payload, "-y"]); + if (verify) execFileSync(lgx, ["verify", outFile]); + + rmSync(work, { recursive: true, force: true }); + return outFile; +} + +// Valid tar, corrupt manifest — deliberately NOT run through lgx (it would refuse). +export function makeBadApp(outDir) { + const outFile = join(outDir, "bad_app.lgx"); + const work = join(tmpdir(), `lgx-build-${process.pid}-bad_app`); + rmSync(work, { recursive: true, force: true }); + mkdirSync(join(work, "variants"), { recursive: true }); + writeFileSync(join(work, "manifest.json"), + '{ "name": "bad_app", "version": "0.1.0", "type": "ui_qml", "depende'); + mkdirSync(outDir, { recursive: true }); + execFileSync("tar", + ["--format", "ustar", "-C", work, "-czf", outFile, "manifest.json", "variants"]); + rmSync(work, { recursive: true, force: true }); + return outFile; +} + +export function readLgxManifest(lgxPath) { + const out = execFileSync("tar", ["-xzOf", lgxPath, "manifest.json"], + { encoding: "utf-8" }); + return JSON.parse(out); +} + +export function makeFixtureSet(outDir, opts = {}) { + mkdirSync(outDir, { recursive: true }); + const version = opts.version || "0.1.0"; + const specs = { + // A→X,Y / B→Y: uninstalling Y cascades to both apps, X only to A. + mod_x: { type: "core", displayName: "Fixture Module X", dependencies: [] }, + mod_y: { type: "core", displayName: "Fixture Module Y", dependencies: [] }, + app_a: { type: "ui_qml", displayName: "Fixture App A", dependencies: ["mod_x", "mod_y"] }, + app_b: { type: "ui_qml", displayName: "Fixture App B", dependencies: ["mod_y"] }, + mod_z: { type: "core", displayName: "Fixture Module Z", dependencies: ["no_such_module"] }, + }; + const paths = {}; + for (const [name, spec] of Object.entries(specs)) { + paths[name] = makeLgx({ + outFile: join(outDir, `${name}-${version}.lgx`), + name, version, ...spec, ...opts, + }); + } + paths.bad_app = makeBadApp(outDir); + return paths; +} + +// Seed an installed package into a --user-dir; the scanner only reads manifest.json. +export function writeInstalledPlugin(userDir, spec) { + const full = { + version: "0.1.0", + displayName: spec.name, + type: "ui_qml", + dependencies: [], + description: `basecamp test fixture package (${spec.name})`, + ...spec, + }; + const bucket = full.type === "core" ? "modules" : "plugins"; + const dir = join(userDir, bucket, full.name); + writePayload(dir, full); + writeFileSync(join(dir, "manifest.json"), + JSON.stringify(manifestFor(full), null, 2) + "\n"); + return dir; +} + +// Local file:// repo: logos-repo.json + index listing the given .lgx files. +export function writeLocalRepo(dir, opts = {}) { + const { name = "Local Test Repo", packages = [] } = opts; + mkdirSync(dir, { recursive: true }); + + const indexPath = join(dir, "index.json"); + const repoPath = join(dir, "logos-repo.json"); + const indexUrl = pathToFileURL(indexPath).href; + const repoUrl = pathToFileURL(repoPath).href; + + const rows = packages.map((lgxPath) => { + const manifest = readLgxManifest(lgxPath); + return { + name: manifest.name, + repositoryUrl: repoUrl, + versions: [{ + rootHash: manifest.hashes?.root ?? "", + url: pathToFileURL(resolve(lgxPath)).href, + manifest, + }], + }; + }); + + writeFileSync(indexPath, + JSON.stringify({ packages: rows }, null, 2) + "\n"); + writeFileSync(repoPath, + JSON.stringify({ name, description: name, indexUrl }, null, 2) + "\n"); + return { repoUrl, indexUrl, repoPath, indexPath }; +} diff --git a/tests/shutdown-tests.mjs b/tests/shutdown-tests.mjs index a8af6c5..05c9d14 100644 --- a/tests/shutdown-tests.mjs +++ b/tests/shutdown-tests.mjs @@ -27,6 +27,13 @@ import { fileURLToPath } from "node:url"; import { dirname, resolve } from "node:path"; import { spawn } from "node:child_process"; import net from "node:net"; +import { + SHUTDOWN_TIER, + assertCleanTeardown as assertCleanTeardownImpl, + findByObjectName, + scanForQmlErrors, + waitForExit, +} from "./fixtures/harness.mjs"; const __dirname = dirname(fileURLToPath(import.meta.url)); const projectRoot = resolve(__dirname, ".."); @@ -73,31 +80,30 @@ async function waitForInspector() { throw new Error(`inspector never came up on ${HOST}:${PORT}`); } -function waitForExit(child, ms) { - return new Promise((resolve) => { - if (child.exitCode !== null || child.signalCode !== null) { - return resolve({ code: child.exitCode, signal: child.signalCode }); - } - const timer = setTimeout(() => resolve(null), ms); - child.once("exit", (code, signal) => { - clearTimeout(timer); - resolve({ code, signal }); - }); - }); -} - // Body-return protocol: throw = FAIL; return {skip: reason} = SKIP; else PASS. class SkipTest { constructor(reason) { this.reason = reason; } } function skipTest(reason) { return new SkipTest(reason); } -async function runTest(name, body) { +let currentRun = null; + +// opts: { xfail } — loud on unexpected pass; { tier: "full" } — nightly only. +async function runTest(name, body, opts = {}) { process.stdout.write(` ${name} ... `); + if (opts.tier === "full" && SHUTDOWN_TIER !== "full") { + console.log(`\x1b[33mSKIP\x1b[0m — tier "full" test under SHUTDOWN_TIER=${SHUTDOWN_TIER}`); + return "skip"; + } const child = spawnApp(); const logChunks = []; child.stdout.on("data", (d) => logChunks.push(d)); child.stderr.on("data", (d) => logChunks.push(d)); + const closed = new Promise((r) => child.once("close", r)); + currentRun = { + log: () => Buffer.concat(logChunks).toString("utf-8"), + closed, + }; let outcome = "pass"; let err = null; @@ -106,21 +112,41 @@ async function runTest(name, body) { await waitForInspector(); // Give plugins time to finish loading so shutdown exercises the full teardown path. await new Promise((r) => setTimeout(r, APP_WARMUP_MS)); + // G-ERR scans only startup output — teardown legitimately emits noise. + const startupChunkCount = logChunks.length; const ret = await body(child); if (ret instanceof SkipTest) { outcome = "skip"; skipReason = ret.reason; + } else { + const qmlErrors = scanForQmlErrors( + Buffer.concat(logChunks.slice(0, startupChunkCount)).toString("utf-8")); + if (qmlErrors.length > 0) { + throw new Error( + `G-ERR: QML error(s) during startup:\n ${qmlErrors.join("\n ")}`); + } } } catch (e) { outcome = "fail"; err = e; } finally { + currentRun = null; if (child.exitCode === null && child.signalCode === null) { child.kill("SIGKILL"); await waitForExit(child, 2000); } } + if (outcome === "pass" && opts.xfail) { + outcome = "fail"; + err = new Error( + `XPASS: test passed but is marked xfail (${opts.xfail}) — the fix ` + + `landed, remove the xfail marker`); + } else if (outcome === "fail" && opts.xfail) { + console.log(`\x1b[33mXFAIL\x1b[0m (${opts.xfail}) — ${err.message}`); + return "pass"; + } + if (outcome === "pass") { console.log("\x1b[32mOK\x1b[0m"); } else if (outcome === "skip") { @@ -134,49 +160,29 @@ async function runTest(name, body) { return outcome; } -async function assertGracefulExit(child) { - const exit = await waitForExit(child, SHUTDOWN_WAIT_MS); - if (!exit) throw new Error(`did not exit within ${SHUTDOWN_WAIT_MS}ms`); - if (exit.signal) throw new Error(`terminated by signal ${exit.signal} instead of orderly exit`); - if (exit.code !== 0) throw new Error(`exit code ${exit.code}, want 0`); -} - -async function findByObjectName(inspector, name) { - // findByProperty on objectName reliably finds the tagged object - // regardless of how its other properties (QKeySequence, etc.) get - // JSON-serialised by the MCP server. - const res = await inspector.send("findByProperty", { - property: "objectName", value: name, +async function assertCleanTeardown(child) { + await assertCleanTeardownImpl(child, { + waitMs: SHUTDOWN_WAIT_MS, + log: currentRun?.log, + closed: currentRun?.closed, }); - return (res.matches ?? [])[0] || null; } -// The hide-to-tray branch of Window::closeEvent only runs when a system -// tray is actually available. In offscreen CI (Xvfb without a tray daemon, -// or QT_QPA_PLATFORM=offscreen) there is no tray, so closing the window -// quits instead of hiding — that's correct behaviour, just not the branch -// we want to exercise. The tray-Quit action isn't even constructed when -// the tray is unavailable, so its object won't exist to find. -async function trayIsAvailable(inspector) { - const trayAction = await findByObjectName(inspector, "logosTrayQuitAction"); - return trayAction != null; -} - - // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- -console.log(`\nlogos-basecamp shutdown tests (${process.platform})\n`); +console.log(`\nlogos-basecamp shutdown tests (${process.platform}, tier: ${SHUTDOWN_TIER})\n`); +const suiteStart = Date.now(); const results = []; results.push(await runTest("SIGTERM triggers graceful shutdown", async (child) => { child.kill("SIGTERM"); - await assertGracefulExit(child); + await assertCleanTeardown(child); })); results.push(await runTest("SIGINT triggers graceful shutdown", async (child) => { child.kill("SIGINT"); - await assertGracefulExit(child); + await assertCleanTeardown(child); })); if (process.platform === "linux") { @@ -198,7 +204,7 @@ if (process.platform === "linux") { }); inspector.disconnect(); if (res.error) throw new Error(`callMethod(activated) failed: ${res.error}`); - await assertGracefulExit(child); + await assertCleanTeardown(child); })); } @@ -220,54 +226,29 @@ if (process.platform === "darwin") { }); inspector.disconnect(); if (res.error) throw new Error(`callMethod(trigger) failed: ${res.error}`); - await assertGracefulExit(child); + await assertCleanTeardown(child); })); } -// Window.close() covers Alt+F4 / window-X (all platforms) and ⌘W / red-button (macOS). -// Behaviour is platform-dependent: -// - Linux: closeEvent explicitly quits (matches GNOME/KDE convention). -// - macOS/Windows: closeEvent hides to tray when the tray is available -// (Discord/Slack tray-app convention). -if (process.platform === "linux") { - results.push(await runTest("Linux: Window.close() quits (Alt+F4 / X button convention)", async (child) => { - const inspector = new Inspector(); - await inspector.connect(); - const win = await findByObjectName(inspector, "logosMainWindow"); - if (!win) { - inspector.disconnect(); - throw new Error("Window objectName=logosMainWindow not found"); - } - const res = await inspector.send("callMethod", { objectId: win.id, method: "close" }); +// Since 3a73d33 closing the window never quits on any platform (tray/dock convention). +results.push(await runTest("Window.close() does not quit; app keeps running (tray/dock convention)", async (child) => { + const inspector = new Inspector(); + await inspector.connect(); + const win = await findByObjectName(inspector, "logosMainWindow"); + if (!win) { inspector.disconnect(); - if (res.error) throw new Error(`callMethod(close) failed: ${res.error}`); - await assertGracefulExit(child); - })); -} else { - results.push(await runTest("macOS/Windows: Window.close() hides to tray, does not quit", async (child) => { - const inspector = new Inspector(); - await inspector.connect(); - if (!(await trayIsAvailable(inspector))) { - inspector.disconnect(); - return skipTest("no system tray in this environment (offscreen without tray daemon)"); - } - const win = await findByObjectName(inspector, "logosMainWindow"); - if (!win) { - inspector.disconnect(); - throw new Error("Window objectName=logosMainWindow not found"); - } - const res = await inspector.send("callMethod", { objectId: win.id, method: "close" }); - inspector.disconnect(); - if (res.error) throw new Error(`callMethod(close) failed: ${res.error}`); - // Give the event loop time to (not) process a shutdown. - await new Promise((r) => setTimeout(r, 3000)); - if (child.exitCode !== null || child.signalCode !== null) { - throw new Error( - `app exited (code=${child.exitCode}, signal=${child.signalCode}) — closeEvent should have hidden to tray` - ); - } - })); -} + throw new Error("Window objectName=logosMainWindow not found"); + } + const res = await inspector.send("callMethod", { objectId: win.id, method: "close" }); + inspector.disconnect(); + if (res.error) throw new Error(`callMethod(close) failed: ${res.error}`); + await new Promise((r) => setTimeout(r, 3000)); + if (child.exitCode !== null || child.signalCode !== null) { + throw new Error( + `app exited (code=${child.exitCode}, signal=${child.signalCode}) — closing the window must not quit` + ); + } +})); // Tray "Quit" action: must terminate the process. Same wiring as ⌘Q on mac // / Ctrl+Q on Linux, but a distinct connection worth guarding. @@ -286,11 +267,12 @@ results.push(await runTest("Tray Quit QAction is wired and quits", async (child) const res = await inspector.send("callMethod", { objectId: action.id, method: "trigger" }); inspector.disconnect(); if (res.error) throw new Error(`callMethod(trigger) failed: ${res.error}`); - await assertGracefulExit(child); + await assertCleanTeardown(child); })); const passed = results.filter((r) => r === "pass").length; const skipped = results.filter((r) => r === "skip").length; const failed = results.filter((r) => r === "fail").length; console.log(`\n${passed} passed, ${skipped} skipped, ${failed} failed`); +console.log(`Total elapsed: ${((Date.now() - suiteStart) / 1000).toFixed(1)}s`); process.exit(failed > 0 ? 1 : 0); diff --git a/tests/ui-tests.mjs b/tests/ui-tests.mjs index 73a0041..ff51623 100644 --- a/tests/ui-tests.mjs +++ b/tests/ui-tests.mjs @@ -13,11 +13,32 @@ import { fileURLToPath } from "node:url"; import { dirname, resolve } from "node:path"; +import { writeSync } from "node:fs"; +import { makeTest } from "./fixtures/harness.mjs"; const __dirname = dirname(fileURLToPath(import.meta.url)); const projectRoot = resolve(__dirname, ".."); const qtMcpRoot = process.env.LOGOS_QT_MCP || resolve(projectRoot, "result-mcp"); -const { test, run } = await import(resolve(qtMcpRoot, "test-framework/framework.mjs")); +const { test: frameworkTest, run } = + await import(resolve(qtMcpRoot, "test-framework/framework.mjs")); + +// Adds the G-ERR/G-ALIVE epilogue and { xfail } support to every test. +const test = makeTest(frameworkTest); + +// run() exits the process itself; writeSync so the line isn't dropped at exit. +const suiteStart = Date.now(); +process.on("exit", () => { + const elapsed = `Total elapsed: ${((Date.now() - suiteStart) / 1000).toFixed(1)}s\n`; + try { + writeSync(1, elapsed); + } catch { + try { + writeSync(2, elapsed); + } catch { + // Best-effort shutdown logging only; never fail the suite on exit. + } + } +}); // Shared with the dedicated `host-services-test` check. Registered here as well // because this suite is the one CI already runs by name (`nix build @@ -45,6 +66,77 @@ async function openPlugin(app, name, expectedTexts, opts = {}) { ); } +// --- Welcome page (A1) — must run FIRST: asserts the pre-interaction state --- +async function findWelcomePage(app) { + const res = typeof app.findByType === "function" + ? await app.findByType("WelcomePage") + : await app.inspector.send("findByType", { typeName: "WelcomePage" }); + if (res.error) throw new Error(`findByType(WelcomePage) failed: ${res.error}`); + return (res.matches ?? [])[0] || null; +} + +test("welcome: first launch shows the welcome page", async (app) => { + let welcome = null; + await app.waitFor(async () => { + welcome = await findWelcomePage(app); + if (!welcome) throw new Error("no WelcomePage instance in the QML tree"); + }, { timeout: 10000, interval: 500, description: "WelcomePage instance to exist" }); + + const visRes = await app.inspector.send("evaluate", { + objectId: welcome.id, expression: "visible", + }); + if (visRes.error) throw new Error(`evaluate(visible) failed: ${visRes.error}`); + if (visRes.result !== true) { + throw new Error(`WelcomePage visible=${visRes.result} (expected true)`); + } + + // launcherApps populates asynchronously — check length + greeting in one retried step. + await app.waitFor(async () => { + const lenRes = await app.inspector.send("evaluate", { + objectId: welcome.id, expression: "backend.launcherApps.length", + }); + if (lenRes.error) { + throw new Error(`evaluate(backend.launcherApps.length) failed: ${lenRes.error}`); + } + if (typeof lenRes.result !== "number") { + throw new Error( + `backend.launcherApps.length=${JSON.stringify(lenRes.result)} (expected number)`); + } + const expected = lenRes.result === 0 ? "Welcome to Basecamp!" : "Welcome back"; + await app.expectTexts([expected]); + }, { timeout: 10000, interval: 500, description: "greeting to match backend.launcherApps" }); + + const greetingRes = await app.inspector.send("evaluate", { + objectId: welcome.id, + expression: `(() => { + const hasText = (node, expected) => { + if (!node) return false; + if (typeof node.text === "string" && node.text.includes(expected)) return true; + if (!node.children || typeof node.children.length !== "number") return false; + for (let i = 0; i < node.children.length; i += 1) { + if (hasText(node.children[i], expected)) return true; + } + return false; + }; + // The inspector serializes object results as "" — return JSON. + return JSON.stringify({ + hasFirstLaunch: hasText(this, "Welcome to Basecamp!"), + hasWelcomeBack: hasText(this, "Welcome back"), + }); + })()`, + }); + if (greetingRes.error) { + throw new Error(`evaluate(greeting presence) failed: ${greetingRes.error}`); + } + const greeting = JSON.parse(greetingRes.result); + const hasFirstLaunch = greeting?.hasFirstLaunch === true; + const hasWelcomeBack = greeting?.hasWelcomeBack === true; + if (hasFirstLaunch === hasWelcomeBack) { + throw new Error( + `greeting texts present: "Welcome to Basecamp!"=${hasFirstLaunch}, ` + + `"Welcome back"=${hasWelcomeBack} (expected exactly one)`); + } +}); // Click options that pin a click to a sidebar SECTION button and nothing else. // // qt-mcp's findAndClick is a breadth-first walk that SUBSTRING-matches the