From f5c074ca85a43b06d883eabf40b53b7e45dd2018 Mon Sep 17 00:00:00 2001 From: romanzac Date: Wed, 19 Aug 2026 06:24:10 +0000 Subject: [PATCH 01/13] ci(tests): run shutdown-test in the PR gate and enforce the combined suite time budget --- .github/workflows/build.yml | 6 ++++++ flake.nix | 31 ++++++++++++++++++++++--------- nix/integration-test.nix | 17 +++++++++++++++-- nix/shutdown-test.nix | 23 +++++++++++++++++++++-- tests/shutdown-tests.mjs | 2 ++ tests/ui-tests.mjs | 11 +++++++++++ 6 files changed, 77 insertions(+), 13 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index dd62269..2dc31f7 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -183,6 +183,9 @@ jobs: - name: Integration test (UI tests) run: nix build .#integration-test -L + - name: Shutdown test + run: nix build .#shutdown-test -L + # Report-only non-blocking for now - name: Coverage report continue-on-error: true @@ -227,6 +230,9 @@ jobs: - name: Integration test (UI tests) run: nix build .#integration-test-bundle -L + - name: Shutdown test + run: nix build .#shutdown-test -L + release: if: github.event_name == 'push' && startsWith(github.ref, 'refs/heads/release/') needs: [build-appimage, build-macos-app, build-windows] diff --git a/flake.nix b/flake.nix index b4f0ca1..bf8dd2b 100644 --- a/flake.nix +++ b/flake.nix @@ -367,6 +367,18 @@ # this change and are identical with the bypass in place. binBundleDir = withMainProgram (dirBundler appDistributed); binBundleDirInspector = withMainProgram (dirBundler appDistributedWithInspector); + + # Hoisted so shutdown-test below can read the integration test's + # recorded elapsed time and enforce the combined PR-gate budget + # (MCP_TEST_BUDGET_SECONDS). On macOS the PR gate runs the bundle + # variant, so that is the run the budget combines with there. + 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 @@ -427,11 +439,17 @@ }; # Integration test (UI tests via Qt Inspector) - integration-test = import ./nix/integration-test.nix { inherit pkgs src logosQtMcp; appPkg = app; }; + integration-test = integrationTest; # 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; }; + # app per case and asserts orderly exit (code 0). Takes the + # platform's integration-test run as an input to enforce the + # combined PR-gate time budget. + 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; @@ -450,12 +468,7 @@ appPkg = macosApp; appBin = "${macosApp}/LogosBasecamp.app/Contents/MacOS/LogosBasecamp"; }; - integration-test-bundle = import ./nix/integration-test.nix { - inherit pkgs src; - appPkg = macosAppTest; - inherit logosQtMcp; - appBin = "${macosAppTest}/LogosBasecamp.app/Contents/MacOS/LogosBasecamp"; - }; + integration-test-bundle = integrationTestBundle; } ); diff --git a/nix/integration-test.nix b/nix/integration-test.nix index 6fc00f5..3002bbb 100644 --- a/nix/integration-test.nix +++ b/nix/integration-test.nix @@ -3,9 +3,15 @@ # 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 +# PR-gate wall-clock budget for BOTH suites (ui-tests + shutdown-tests) +# combined. This derivation records its elapsed time in $out/elapsed-seconds +# and can only fail early when its own run alone busts the budget; the +# combined check lives in nix/shutdown-test.nix, which reads that file. +, 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 @@ -32,8 +38,15 @@ pkgs.runCommand "logos-basecamp-integration-test" { 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 + 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..f716029 100644 --- a/nix/shutdown-test.nix +++ b/nix/shutdown-test.nix @@ -4,9 +4,15 @@ # 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 +# PR-gate wall-clock budget for BOTH suites (ui-tests + shutdown-tests) +# combined. `uiTestRun` is the platform's integration-test derivation; its +# $out/elapsed-seconds is added to this suite's elapsed time and the build +# fails when the combined total exceeds the budget. +, 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 +38,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/tests/shutdown-tests.mjs b/tests/shutdown-tests.mjs index e5ad6c1..6a6b839 100644 --- a/tests/shutdown-tests.mjs +++ b/tests/shutdown-tests.mjs @@ -165,6 +165,7 @@ async function trayIsAvailable(inspector) { // Tests // --------------------------------------------------------------------------- console.log(`\nlogos-basecamp shutdown tests (${process.platform})\n`); +const suiteStart = Date.now(); const results = []; results.push(await runTest("SIGTERM triggers graceful shutdown", async (child) => { @@ -291,4 +292,5 @@ 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 b6783ff..6292621 100644 --- a/tests/ui-tests.mjs +++ b/tests/ui-tests.mjs @@ -13,12 +13,23 @@ import { fileURLToPath } from "node:url"; import { dirname, resolve } from "node:path"; +import { writeSync } from "node:fs"; 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")); +// Total-elapsed report for the PR-gate time budget (MCP_TEST_BUDGET_SECONDS, +// enforced in nix/integration-test.nix + nix/shutdown-test.nix). The runner +// lives in logos-qt-mcp and exits the process from inside run(), so the only +// reliable "after the suite" hook is process exit; writeSync because async +// stdout writes to a pipe can be dropped during exit. +const suiteStart = Date.now(); +process.on("exit", () => { + writeSync(1, `Total elapsed: ${((Date.now() - suiteStart) / 1000).toFixed(1)}s\n`); +}); + // Helper: click a plugin's sidebar icon and wait for its UI to load. // Plugins load asynchronously after clicking, so we wait for expected // content to appear before proceeding. From d8d9404edf5f811fccfe07ee380a37453ea4b4ff Mon Sep 17 00:00:00 2001 From: romanzac Date: Wed, 19 Aug 2026 08:08:30 +0000 Subject: [PATCH 02/13] test(harness): add shared fixtures (LGX generator, G-ERR/G-ALIVE/G-EXIT gates, xfail runner) and rea --- src/Basecamp/Sidebar/SidebarAppDelegate.qml | 5 + src/WorkspaceArea.cpp | 14 + src/WorkspaceArea.h | 19 + tests/fixtures/harness.mjs | 429 ++++++++++++++++++++ tests/fixtures/lgx.mjs | 287 +++++++++++++ tests/shutdown-tests.mjs | 99 +++-- tests/ui-tests.mjs | 9 +- 7 files changed, 826 insertions(+), 36 deletions(-) create mode 100644 tests/fixtures/harness.mjs create mode 100644 tests/fixtures/lgx.mjs diff --git a/src/Basecamp/Sidebar/SidebarAppDelegate.qml b/src/Basecamp/Sidebar/SidebarAppDelegate.qml index 548714d..54c349f 100644 --- a/src/Basecamp/Sidebar/SidebarAppDelegate.qml +++ b/src/Basecamp/Sidebar/SidebarAppDelegate.qml @@ -26,6 +26,11 @@ AbstractButton { property bool hasMissingDeps: false property string appName: "" + // Read-only test hook: whether this app is the front-most visible one + // (the orange active marker). Lets UI automation assert the active app + // without decoding the background rectangle's color. + readonly property bool active: checked + implicitHeight: 50 hoverEnabled: true diff --git a/src/WorkspaceArea.cpp b/src/WorkspaceArea.cpp index 9978471..9e81828 100644 --- a/src/WorkspaceArea.cpp +++ b/src/WorkspaceArea.cpp @@ -169,6 +169,9 @@ constexpr int kTabBarInsetPx = 24; WorkspaceArea::WorkspaceArea(QObject* backend, QWidget* parent) : QMainWindow(parent) { + // Stable handle for UI automation — the dockCount/dockOrder/layoutMode + // test hooks are read via inspector evaluate on this objectName. + setObjectName(QStringLiteral("workspace")); setWindowFlags(Qt::Widget); setDockOptions(QMainWindow::AllowNestedDocks @@ -298,6 +301,7 @@ void WorkspaceArea::toggleLayoutModeForTesting() } QTimer::singleShot(0, this, [this]() { styleAllTabBars(); }); + emit dockLayoutChanged(); }); } @@ -442,6 +446,7 @@ void WorkspaceArea::addPluginDock(QWidget* pluginWidget, ensurePhantomTab(); updateQmlPluginActiveStates(); updateWelcomeVisibility(); + emit dockLayoutChanged(); } void WorkspaceArea::removePluginDock(const QString& name) @@ -466,6 +471,15 @@ void WorkspaceArea::removePluginDock(const QString& name) else ensurePhantomTab(); updateQmlPluginActiveStates(); updateWelcomeVisibility(); + emit dockLayoutChanged(); +} + +void WorkspaceArea::closeDock(const QString& moduleName) +{ + if (!m_docks.contains(moduleName)) return; + // Same path as the tab close button — UIPluginManager owns the + // actual teardown and calls back into removePluginDock. + emit pluginClosed(moduleName); } void WorkspaceArea::activatePluginDock(const QString& moduleName) diff --git a/src/WorkspaceArea.h b/src/WorkspaceArea.h index c12244b..5cca847 100644 --- a/src/WorkspaceArea.h +++ b/src/WorkspaceArea.h @@ -18,10 +18,26 @@ class WorkspaceArea : public QMainWindow { Q_OBJECT + // Read-only test hooks: non-textual dock state for UI automation + // (reached via the QML inspector's 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"); + } + // Test hook: close a dock by module name through the same pluginClosed + // path the tab close button takes. No-op for unknown names. + Q_INVOKABLE void closeDock(const QString& moduleName); + void addPluginDock(QWidget* pluginWidget, const QString& moduleName, const QString& displayLabel = {}); @@ -39,6 +55,9 @@ public: signals: void pluginClosed(const QString& moduleName); + // Fires when dockCount / dockOrder / layoutMode change (add, remove, + // layout toggle). Observability only — nothing in the app binds to it. + 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..d4b31d2 --- /dev/null +++ b/tests/fixtures/harness.mjs @@ -0,0 +1,429 @@ +// --------------------------------------------------------------------------- +// Shared test-harness helpers for the basecamp UI/shutdown/lifecycle suites. +// +// The test framework itself (test-framework/framework.mjs) belongs to +// logos-qt-mcp — an external flake input we cannot edit — so everything the +// suites need on top of it lives here: +// +// - expectAbsent(app, target, ms) negative wait (text or objectName) +// - pollProperty(app, id, prop, ms) sample a property over a window +// - assertResponsive(app) G-ALIVE: inspector round-trip +// - scanForQmlErrors(text) / G-ERR: QML engine error scan +// markQmlErrorBaseline() / +// assertNoNewQmlErrors() +// - makeTest(frameworkTest) xfail-capable wrapper around the +// framework's test() that appends the +// G-ERR/G-ALIVE epilogue to every test +// - assertCleanTeardown(child, opts) G-EXIT: exit code + log closed + +// no orphans / partial files +// - SHUTDOWN_TIER "pr" (default) or "full" +// - makeUserDir / snapshotPartialFiles --user-dir plumbing for suites that +// spawn the app themselves +// - findByObjectName / installViaPmu PMU-driven install for lifecycle +// flows (see the package-lifecycle +// doctest for the reference walk) +// --------------------------------------------------------------------------- + +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"; + +// --------------------------------------------------------------------------- +// SHUTDOWN_TIER — "pr" runs the fast PR-gate subset, "full" the nightly set. +// Suites gate tier-specific tests on this; nothing in the pr tier changes. +// --------------------------------------------------------------------------- +export const SHUTDOWN_TIER = + (process.env.SHUTDOWN_TIER || "pr").trim().toLowerCase(); + +// --------------------------------------------------------------------------- +// Small utilities +// --------------------------------------------------------------------------- +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) { + // findByProperty on objectName reliably finds the tagged object + // regardless of how its other properties get JSON-serialised. + const res = await inspector.send("findByProperty", { + property: "objectName", value: name, + }); + return (res.matches ?? [])[0] || null; +} + +// --------------------------------------------------------------------------- +// expectAbsent — negative wait. Fails if `target` (a visible text fragment or +// an objectName) shows up in the QML tree at any point during the window. +// Positive waits already exist in the framework; this is the "the dialog must +// NOT appear" half. +// --------------------------------------------------------------------------- +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()))); + } +} + +// --------------------------------------------------------------------------- +// pollProperty — sample `prop` on the object with inspector id `objectId` +// every `intervalMs` for up to `ms`. Returns the sequence of DISTINCT +// consecutive values observed (with timestamps), so a test can assert on +// transient stages (installStage, loadingRow, ...) it would otherwise race. +// Stops early when `until(value)` returns true. +// --------------------------------------------------------------------------- +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); + } +} + +// --------------------------------------------------------------------------- +// G-ALIVE — the app still answers the inspector. A hung event loop makes the +// round-trip time out; a dead process makes it error. Cheap enough to run +// after every test. +// --------------------------------------------------------------------------- +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 — QML engine errors. +// +// Matches only genuine QML engine error lines (a *.qml:line: location plus an +// error keyword), NOT generic stderr noise: the old smoke-test log-regex was +// removed for tripping on unrelated output (see nix/smoke-test.nix), so this +// stays deliberately narrow. +// +// Two sources: +// - scanForQmlErrors(text): scan a captured log chunk (shutdown suite). +// - file mode: when BASECAMP_APP_LOG points at the app's log file, +// markQmlErrorBaseline() records a byte offset at test start and +// assertNoNewQmlErrors() fails on error lines appended since. When the +// env var is unset (the default today) both are no-ops, so suites that +// don't capture the app's output lose nothing and gain the gate for free +// once the log is wired through. +// --------------------------------------------------------------------------- +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 ")}`); + } +} + +// --------------------------------------------------------------------------- +// makeTest — wraps logos-qt-mcp's test() so every test in the suite inherits +// the G-ERR/G-ALIVE epilogue and gains xfail support without per-test +// boilerplate: +// +// const test = makeTest(frameworkTest); +// test("name", body); // body + epilogue +// test("name", body, { xfail: "M1" }); // XFAIL on failure, +// // fails LOUDLY on unexpected pass +// +// Any extra opts (e.g. { skip: ["offscreen"] }) pass through to the framework. +// --------------------------------------------------------------------------- +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; + } + // Epilogue runs regardless of the body outcome so a hung or + // error-spewing app is caught even when the body "passed". + 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 — assertCleanTeardown. Extends the old assertGracefulExit (exit code +// 0, no killing signal) with: stdio closed on a complete line, no orphan +// process still on the user-dir, no new temp/partial files under it. +// +// await assertCleanTeardown(child, { +// waitMs: 10000, +// log: () => capturedOutput, // optional +// closed: promiseOfChildClose, // optional, gates the log check +// userDir: "/tmp/...", // optional +// partialBaseline: snapshotPartialFiles(userDir), // taken pre-launch +// }); +// --------------------------------------------------------------------------- +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) { + // Wait for the stdio streams to actually close so the captured log is + // complete — 'exit' can fire with data still in flight. + 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)$|(^|\/)\.#/; + +// Snapshot the temp/partial files currently under a user dir. Take one +// before launching the app, hand it to assertCleanTeardown afterwards. +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 ")}`); + } +} + +// --------------------------------------------------------------------------- +// --user-dir helper — a throwaway user dir shaped like the app expects +// (modules/ + plugins/, see the missing-deps doctest). Suites that spawn the +// app themselves pass it via `--user-dir `. +// --------------------------------------------------------------------------- +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; +} + +// --------------------------------------------------------------------------- +// installViaPmu — drive a local .lgx through the only install path the app +// has: package_manager_ui's installLocalPackage slot, then Basecamp's install +// gate. Mirrors the reference walk in +// doctests/basecamp-package-lifecycle.test.yaml. +// +// await installViaPmu(app, "/abs/path/pkg.lgx"); // confirm +// await installViaPmu(app, path, { confirm: false }); // cancel +// +// Preconditions: the Package Manager view has been opened at least once (the +// pmui.BackendStore hook is created lazily on first activation). +// --------------------------------------------------------------------------- +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}`); + } + + // Wait for the install gate to open (visible === true — the per-mode + // dialog instances keep stale texts in the tree while closed, so texts + // alone would false-positive). + 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`); + // Emitting clicked() runs the onClicked handler — same trick the + // shortcut-bridge test uses with QShortcut::activated. + 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..784716f --- /dev/null +++ b/tests/fixtures/lgx.mjs @@ -0,0 +1,287 @@ +// --------------------------------------------------------------------------- +// LGX fixture generator for the basecamp test suites. +// +// An .lgx is a gzipped ustar tar: manifest.json at the root plus a payload +// under variants//. This is a port of the inline shell generator in +// doctests/basecamp-package-lifecycle.test.yaml — hand-write the stamped +// manifest + payload, seed a plain-ustar tar, then let the `lgx` CLI inject +// the payload and stamp the manifest's content hashes (packages without them +// fail validation). +// +// Requires the lgx CLI for valid packages: +// nix build 'github:logos-co/logos-package#lgx' -o result-lgx +// Override its location with LGX_CLI (default: /result-lgx/bin/lgx). +// +// Named fixtures (makeFixtureSet): +// app_a → depends on mod_x, mod_y ┐ the A→X,Y / B→Y trio for +// app_b → depends on mod_y ┘ dependency-cascade flows +// mod_x, mod_y core modules the apps depend on +// mod_z → depends on no_such_module (a name nothing provides) +// bad_app corrupt manifest (invalid JSON) +// +// plus writeLocalRepo() — a local logos-repo.json (+ file:// package URLs) +// for repository-driven install flows. +// +// The core-module fixtures are manifest-level: they give the resolver a +// package identity and dependency edges, not a loadable binary (a compiled +// lib can't be hand-crafted here). That is all the dependency/cascade gates +// observe. +// --------------------------------------------------------------------------- + +import { execFileSync } from "node:child_process"; +import { mkdirSync, writeFileSync, rmSync, existsSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } 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 }) { + // The view prints its own identity + version so a test can assert which + // payload actually runs after an install or upgrade. + return `import QtQuick + +Rectangle { + id: root + color: "#1e1e1e" + Text { + anchors.centerIn: parent + text: "${displayName} (${name}) v${version}" + color: "#ffffff" + font.pixelSize: 24 + } +} +`; +} + +// Write the on-disk payload files for one package version into `payloadDir`. +// For ui_qml that is metadata.json + Main.qml + qmldir; for core modules +// just metadata.json (manifest-level fixture — see the header). +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`); + } +} + +// --------------------------------------------------------------------------- +// makeLgx — build one valid .lgx at outFile. +// +// makeLgx({ +// outFile: "/tmp/fixtures/app_a-0.1.0.lgx", +// name: "app_a", displayName: "App A", version: "0.1.0", +// type: "ui_qml", // or "core" +// dependencies: ["mod_x"], +// verify: true, // run `lgx verify` afterwards +// }) +// --------------------------------------------------------------------------- +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"]); + // lgx add injects the payload into the platform variant and stamps the + // manifest's content hashes (the Merkle root packages must carry). + execFileSync(lgx, ["add", outFile, "--variant", variant, "--files", payload, "-y"]); + if (verify) execFileSync(lgx, ["verify", outFile]); + + rmSync(work, { recursive: true, force: true }); + return outFile; +} + +// bad_app — a syntactically broken package: valid gzipped ustar tar, corrupt +// manifest.json (truncated JSON). Exercises the reject/error paths, so it is +// deliberately NOT run through lgx (which would refuse to touch it). +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; +} + +// Read the (possibly hash-stamped) manifest back out of an .lgx. +export function readLgxManifest(lgxPath) { + const out = execFileSync("tar", ["-xzOf", lgxPath, "manifest.json"], + { encoding: "utf-8" }); + return JSON.parse(out); +} + +// --------------------------------------------------------------------------- +// makeFixtureSet — build the whole named set into outDir. Returns +// { app_a, app_b, mod_x, mod_y, mod_z, bad_app } → absolute .lgx paths. +// --------------------------------------------------------------------------- +export function makeFixtureSet(outDir, opts = {}) { + mkdirSync(outDir, { recursive: true }); + const version = opts.version || "0.1.0"; + const specs = { + // The A→X,Y / B→Y trio: uninstalling Y cascades to both apps, + // uninstalling 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"] }, + // Z depends on a name no module anywhere provides. + 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; +} + +// --------------------------------------------------------------------------- +// writeInstalledPlugin — seed an already-installed package directly into a +// --user-dir (plugins/ for ui_qml, modules/ for core), the way the +// missing-deps doctest crafts broken end states. The package scanner only +// reads manifest.json, so this is indistinguishable from a real install. +// --------------------------------------------------------------------------- +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; +} + +// --------------------------------------------------------------------------- +// writeLocalRepo — a local repository the app can add by file:// URL: +// logos-repo.json (name + indexUrl) next to an index listing the given .lgx +// files with file:// download URLs. Row shape mirrors the default repo's +// catalog index (name / versions[] / rootHash / manifest — see +// tests/apps_model_test.cpp makeCatalogRow). +// +// const { repoUrl } = writeLocalRepo(dir, { packages: [paths.app_a] }); +// --------------------------------------------------------------------------- +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 = `file://${indexPath}`; + const repoUrl = `file://${repoPath}`; + + const rows = packages.map((lgxPath) => { + const manifest = readLgxManifest(lgxPath); + return { + name: manifest.name, + repositoryUrl: repoUrl, + versions: [{ + rootHash: manifest.hashes?.root ?? "", + url: `file://${resolve(lgxPath)}`, + 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 6a6b839..6e2532f 100644 --- a/tests/shutdown-tests.mjs +++ b/tests/shutdown-tests.mjs @@ -25,6 +25,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, ".."); @@ -71,31 +78,35 @@ 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) { +// Per-test context for assertCleanTeardown: the captured app output and a +// promise for the child's stdio-close, both owned by runTest. +let currentRun = null; + +// Supported opts: +// { xfail: "M1" } — report XFAIL on failure, fail LOUDLY on unexpected pass +// { tier: "full" } — only runs when SHUTDOWN_TIER=full (nightly); the PR +// gate runs with the default SHUTDOWN_TIER=pr +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; @@ -104,21 +115,43 @@ 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 the startup/warmup output: quitting is this suite's + // whole point, and teardown legitimately emits noise a log-regex would + // trip on (the lesson recorded in nix/smoke-test.nix). + 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") { @@ -132,21 +165,17 @@ 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, +// G-EXIT: the old assertGracefulExit (exit code 0, no killing signal), +// extended into assertCleanTeardown — the captured log must also end on a +// complete line once the stdio streams close. tests/fixtures/harness.mjs +// additionally checks orphan processes / partial files when a --user-dir is +// in play (none is here yet). +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 @@ -164,18 +193,18 @@ async function trayIsAvailable(inspector) { // --------------------------------------------------------------------------- // 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") { @@ -197,7 +226,7 @@ if (process.platform === "linux") { }); inspector.disconnect(); if (res.error) throw new Error(`callMethod(activated) failed: ${res.error}`); - await assertGracefulExit(child); + await assertCleanTeardown(child); })); } @@ -219,7 +248,7 @@ if (process.platform === "darwin") { }); inspector.disconnect(); if (res.error) throw new Error(`callMethod(trigger) failed: ${res.error}`); - await assertGracefulExit(child); + await assertCleanTeardown(child); })); } @@ -240,7 +269,7 @@ if (process.platform === "linux") { 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 assertGracefulExit(child); + await assertCleanTeardown(child); })); } else { results.push(await runTest("macOS/Windows: Window.close() hides to tray, does not quit", async (child) => { @@ -285,7 +314,7 @@ 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; diff --git a/tests/ui-tests.mjs b/tests/ui-tests.mjs index 6292621..dfc66db 100644 --- a/tests/ui-tests.mjs +++ b/tests/ui-tests.mjs @@ -14,11 +14,18 @@ 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")); + +// Every test inherits the G-ERR/G-ALIVE epilogue (assertNoNewQmlErrors + +// assertResponsive) and gains { xfail: "..." } support — the framework +// itself lives in logos-qt-mcp, so the wrapper is local. +const test = makeTest(frameworkTest); // Total-elapsed report for the PR-gate time budget (MCP_TEST_BUDGET_SECONDS, // enforced in nix/integration-test.nix + nix/shutdown-test.nix). The runner From f1d66a125f42520e112659354c643552b8c6e9b1 Mon Sep 17 00:00:00 2001 From: romanzac Date: Wed, 19 Aug 2026 08:12:59 +0000 Subject: [PATCH 03/13] test(ui): assert welcome page state on first launch before any interaction (A1) --- tests/ui-tests.mjs | 60 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/tests/ui-tests.mjs b/tests/ui-tests.mjs index dfc66db..a07d78f 100644 --- a/tests/ui-tests.mjs +++ b/tests/ui-tests.mjs @@ -48,6 +48,66 @@ async function openPlugin(app, name, expectedTexts, opts = {}) { ); } +// --- Welcome page (A1) --- +// +// Must run FIRST: it asserts the pre-interaction state of the shared app +// instance (welcome page up, nothing clicked yet). Read-only — no clicks, +// no state left behind. +async function findWelcomePage(app) { + // Prefer a framework findByType wrapper if one exists (mirrors + // app.findByProperty); otherwise talk to the inspector protocol directly. + const res = typeof app.findByType === "function" + ? await app.findByType("WelcomePage") + : await app.inspector.send("findByType", { type: "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) => { + // A WelcomePage instance exists (it is WorkspaceArea's central widget + // whenever no docks are open) and is visible. + 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)`); + } + + // Greeting follows backend.launcherApps.length: 0 ⇒ first-launch wording, + // otherwise "Welcome back". backend is a global context property, so the + // welcome page itself serves as the evaluate anchor. + 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]); + + // Exactly one of the two greetings is present — the title is a single + // ternary Text, so both appearing (or neither) means a rendering bug. + const tree = JSON.stringify(await app.getTree({ depth: 50 })); + const hasFirstLaunch = tree.includes("Welcome to Basecamp!"); + const hasWelcomeBack = tree.includes("Welcome back"); + if (hasFirstLaunch === hasWelcomeBack) { + throw new Error( + `greeting texts present: "Welcome to Basecamp!"=${hasFirstLaunch}, ` + + `"Welcome back"=${hasWelcomeBack} (expected exactly one)`); + } +}); + // --- Package Manager --- // // PMUI is no longer launched from the sidebar app launcher (filtered out From 0dead0a0042607312532d1b5a5ef33ea519f6e4c Mon Sep 17 00:00:00 2001 From: romanzac Date: Thu, 20 Aug 2026 02:19:47 +0000 Subject: [PATCH 04/13] fix(tests): retry the A1 greeting assertion so it cannot race the async launcherApps refresh --- tests/ui-tests.mjs | 32 +++++++++++++++++++------------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/tests/ui-tests.mjs b/tests/ui-tests.mjs index a07d78f..cd8fa24 100644 --- a/tests/ui-tests.mjs +++ b/tests/ui-tests.mjs @@ -82,19 +82,25 @@ test("welcome: first launch shows the welcome page", async (app) => { // Greeting follows backend.launcherApps.length: 0 ⇒ first-launch wording, // otherwise "Welcome back". backend is a global context property, so the - // welcome page itself serves as the evaluate anchor. - 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]); + // welcome page itself serves as the evaluate anchor. launcherApps populates + // asynchronously at startup (PackageCoordinator refresh → + // launcherAppsChanged), so read the length and assert the matching greeting + // in one retried step — a fixed pre-read would race the refresh on + // installations that have apps. + 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" }); // Exactly one of the two greetings is present — the title is a single // ternary Text, so both appearing (or neither) means a rendering bug. From 27f65e9a639c048334e2bcad69a9bec6fd345dd7 Mon Sep 17 00:00:00 2001 From: romanzac Date: Thu, 20 Aug 2026 02:39:10 +0000 Subject: [PATCH 05/13] ci(tests): tee the app log into BASECAMP_APP_LOG so the ui-tests G-ERR gate scans real output --- nix/integration-test.nix | 20 +++++++++++++++++++- tests/fixtures/harness.mjs | 8 ++++---- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/nix/integration-test.nix b/nix/integration-test.nix index 3002bbb..5757e9d 100644 --- a/nix/integration-test.nix +++ b/nix/integration-test.nix @@ -36,11 +36,29 @@ pkgs.runCommand "logos-basecamp-integration-test" { # Point test framework at the nix-built logos-qt-mcp package export LOGOS_QT_MCP="${logosQtMcp}" + # G-ERR wiring (tests/fixtures/harness.mjs): the harness's file-mode QML + # error gate scans the file named by BASECAMP_APP_LOG, taking a byte-offset + # baseline at each test's start. The framework (logos-qt-mcp — external + # flake input, not editable here) launches the app itself, so interpose a + # wrapper "binary" that tees the app's stdout/stderr into that file while + # passing both streams through unchanged (exec keeps the app on the PID the + # framework spawned, so its kill still lands). QT_FORCE_STDERR_LOGGING + # (above) keeps QML engine errors on stderr even though it is now a pipe. + export BASECAMP_APP_LOG="$out/app.log" + : > "$BASECAMP_APP_LOG" + cat > app-with-log.sh <<'WRAPPER' + #!${pkgs.runtimeShell} + 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 diff --git a/tests/fixtures/harness.mjs b/tests/fixtures/harness.mjs index d4b31d2..926c8f4 100644 --- a/tests/fixtures/harness.mjs +++ b/tests/fixtures/harness.mjs @@ -162,10 +162,10 @@ export async function assertResponsive(app, opts = {}) { // - scanForQmlErrors(text): scan a captured log chunk (shutdown suite). // - file mode: when BASECAMP_APP_LOG points at the app's log file, // markQmlErrorBaseline() records a byte offset at test start and -// assertNoNewQmlErrors() fails on error lines appended since. When the -// env var is unset (the default today) both are no-ops, so suites that -// don't capture the app's output lose nothing and gain the gate for free -// once the log is wired through. +// assertNoNewQmlErrors() fails on error lines appended since. The CI +// ui-tests run wires this up (nix/integration-test.nix tees the app's +// stdout/stderr into the file); when the env var is unset — e.g. running +// ui-tests by hand against an already-running app — both are no-ops. // --------------------------------------------------------------------------- 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)/; From 3303077e9401441a0712db80a0ec8c5a504c401f Mon Sep 17 00:00:00 2001 From: Roman Date: Thu, 20 Aug 2026 11:31:30 +0800 Subject: [PATCH 06/13] fix(tests): typename --- tests/ui-tests.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/ui-tests.mjs b/tests/ui-tests.mjs index cd8fa24..4132fdc 100644 --- a/tests/ui-tests.mjs +++ b/tests/ui-tests.mjs @@ -58,7 +58,7 @@ async function findWelcomePage(app) { // app.findByProperty); otherwise talk to the inspector protocol directly. const res = typeof app.findByType === "function" ? await app.findByType("WelcomePage") - : await app.inspector.send("findByType", { type: "WelcomePage" }); + : await app.inspector.send("findByType", { typeName: "WelcomePage" }); if (res.error) throw new Error(`findByType(WelcomePage) failed: ${res.error}`); return (res.matches ?? [])[0] || null; } From 7355c80d17f596850f671a33868bf6f0323eaacb Mon Sep 17 00:00:00 2001 From: Roman Date: Thu, 20 Aug 2026 19:01:05 +0800 Subject: [PATCH 07/13] fix(tests): shutdown test to follow behavior from 3a73d33 --- app/window.cpp | 5 ++- tests/shutdown-tests.mjs | 79 ++++++++++++---------------------------- 2 files changed, 28 insertions(+), 56 deletions(-) diff --git a/app/window.cpp b/app/window.cpp index a572778..bac88d9 100644 --- a/app/window.cpp +++ b/app/window.cpp @@ -501,7 +501,10 @@ void Window::closeEvent(QCloseEvent *event) ); } } else { - // If system tray is not available, quit normally + // No tray available: accept the close. This only closes the window — + // the app keeps running because of setQuitOnLastWindowClosed(false) + // (window close never quits, on any platform; quit goes through + // Ctrl+Q/⌘Q, the tray Quit action, or a signal). event->accept(); } } diff --git a/tests/shutdown-tests.mjs b/tests/shutdown-tests.mjs index 6e2532f..e35bd42 100644 --- a/tests/shutdown-tests.mjs +++ b/tests/shutdown-tests.mjs @@ -178,18 +178,6 @@ async function assertCleanTeardown(child) { }); } -// 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 // --------------------------------------------------------------------------- @@ -252,50 +240,31 @@ if (process.platform === "darwin") { })); } -// 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" }); +// Window.close() covers Alt+F4 / window-X (all platforms) and ⌘W / red-button +// (macOS). Since 3a73d33 ("revert x on linux closes app") the convention is +// the same on every platform: closing the window never quits. The app keeps +// running — hidden to the tray when one is available, otherwise windowless +// under setQuitOnLastWindowClosed(false) — matching the macOS dock / +// Discord/Slack tray-app 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 assertCleanTeardown(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}`); + // 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}) — 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. From ca56357450dc9bdcf83263a848a386ab92fcd591 Mon Sep 17 00:00:00 2001 From: Roman Date: Thu, 20 Aug 2026 20:04:09 +0800 Subject: [PATCH 08/13] fix(tests): reduce comments --- app/window.cpp | 5 +- flake.nix | 9 +- nix/integration-test.nix | 14 +-- nix/shutdown-test.nix | 5 +- src/Basecamp/Sidebar/SidebarAppDelegate.qml | 4 +- src/WorkspaceArea.cpp | 4 - src/WorkspaceArea.h | 7 +- tests/fixtures/harness.mjs | 132 ++------------------ tests/fixtures/lgx.mjs | 80 ++---------- tests/shutdown-tests.mjs | 24 +--- tests/ui-tests.mjs | 30 +---- 11 files changed, 32 insertions(+), 282 deletions(-) diff --git a/app/window.cpp b/app/window.cpp index bac88d9..8947aba 100644 --- a/app/window.cpp +++ b/app/window.cpp @@ -501,10 +501,7 @@ void Window::closeEvent(QCloseEvent *event) ); } } else { - // No tray available: accept the close. This only closes the window — - // the app keeps running because of setQuitOnLastWindowClosed(false) - // (window close never quits, on any platform; quit goes through - // Ctrl+Q/⌘Q, the tray Quit action, or a signal). + // No tray: close only hides — setQuitOnLastWindowClosed(false) keeps the app running. event->accept(); } } diff --git a/flake.nix b/flake.nix index bf8dd2b..84dba44 100644 --- a/flake.nix +++ b/flake.nix @@ -368,10 +368,7 @@ binBundleDir = withMainProgram (dirBundler appDistributed); binBundleDirInspector = withMainProgram (dirBundler appDistributedWithInspector); - # Hoisted so shutdown-test below can read the integration test's - # recorded elapsed time and enforce the combined PR-gate budget - # (MCP_TEST_BUDGET_SECONDS). On macOS the PR gate runs the bundle - # variant, so that is the run the budget combines with there. + # 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; @@ -442,9 +439,7 @@ integration-test = integrationTest; # Shutdown tests (SIGTERM, SIGINT, Ctrl+Q / ⌘Q). Spawns a fresh - # app per case and asserts orderly exit (code 0). Takes the - # platform's integration-test run as an input to enforce the - # combined PR-gate time budget. + # app per case and asserts orderly exit (code 0). shutdown-test = import ./nix/shutdown-test.nix { inherit pkgs src logosQtMcp; appPkg = app; diff --git a/nix/integration-test.nix b/nix/integration-test.nix index 5757e9d..6f7bdaf 100644 --- a/nix/integration-test.nix +++ b/nix/integration-test.nix @@ -4,10 +4,7 @@ # # Requires Node.js for the test runner and the Qt offscreen platform plugin. { pkgs, src, appPkg, logosQtMcp, appBin ? "${appPkg}/bin/LogosBasecamp", timeoutSec ? 120 -# PR-gate wall-clock budget for BOTH suites (ui-tests + shutdown-tests) -# combined. This derivation records its elapsed time in $out/elapsed-seconds -# and can only fail early when its own run alone busts the budget; the -# combined check lives in nix/shutdown-test.nix, which reads that file. +# Combined PR-gate budget; elapsed goes to $out/elapsed-seconds, combined check in shutdown-test.nix. , budgetSec ? 600 }: pkgs.runCommand "logos-basecamp-integration-test" { @@ -36,14 +33,7 @@ pkgs.runCommand "logos-basecamp-integration-test" { # Point test framework at the nix-built logos-qt-mcp package export LOGOS_QT_MCP="${logosQtMcp}" - # G-ERR wiring (tests/fixtures/harness.mjs): the harness's file-mode QML - # error gate scans the file named by BASECAMP_APP_LOG, taking a byte-offset - # baseline at each test's start. The framework (logos-qt-mcp — external - # flake input, not editable here) launches the app itself, so interpose a - # wrapper "binary" that tees the app's stdout/stderr into that file while - # passing both streams through unchanged (exec keeps the app on the PID the - # framework spawned, so its kill still lands). QT_FORCE_STDERR_LOGGING - # (above) keeps QML engine errors on stderr even though it is now a pipe. + # 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' diff --git a/nix/shutdown-test.nix b/nix/shutdown-test.nix index f716029..a0bada0 100644 --- a/nix/shutdown-test.nix +++ b/nix/shutdown-test.nix @@ -5,10 +5,7 @@ # # Requires Node.js, the Qt offscreen platform, and the MCP inspector. { pkgs, src, appPkg, logosQtMcp, appBin ? "${appPkg}/bin/LogosBasecamp", timeoutSec ? 180 -# PR-gate wall-clock budget for BOTH suites (ui-tests + shutdown-tests) -# combined. `uiTestRun` is the platform's integration-test derivation; its -# $out/elapsed-seconds is added to this suite's elapsed time and the build -# fails when the combined total exceeds the budget. +# Adds uiTestRun's elapsed-seconds; fails when the combined total exceeds budgetSec. , budgetSec ? 600, uiTestRun ? null }: pkgs.runCommand "logos-basecamp-shutdown-test" { diff --git a/src/Basecamp/Sidebar/SidebarAppDelegate.qml b/src/Basecamp/Sidebar/SidebarAppDelegate.qml index 54c349f..764f3b4 100644 --- a/src/Basecamp/Sidebar/SidebarAppDelegate.qml +++ b/src/Basecamp/Sidebar/SidebarAppDelegate.qml @@ -26,9 +26,7 @@ AbstractButton { property bool hasMissingDeps: false property string appName: "" - // Read-only test hook: whether this app is the front-most visible one - // (the orange active marker). Lets UI automation assert the active app - // without decoding the background rectangle's color. + // Test hook: whether this app is the front-most (active) one. readonly property bool active: checked implicitHeight: 50 diff --git a/src/WorkspaceArea.cpp b/src/WorkspaceArea.cpp index 9e81828..f547051 100644 --- a/src/WorkspaceArea.cpp +++ b/src/WorkspaceArea.cpp @@ -169,8 +169,6 @@ constexpr int kTabBarInsetPx = 24; WorkspaceArea::WorkspaceArea(QObject* backend, QWidget* parent) : QMainWindow(parent) { - // Stable handle for UI automation — the dockCount/dockOrder/layoutMode - // test hooks are read via inspector evaluate on this objectName. setObjectName(QStringLiteral("workspace")); setWindowFlags(Qt::Widget); @@ -477,8 +475,6 @@ void WorkspaceArea::removePluginDock(const QString& name) void WorkspaceArea::closeDock(const QString& moduleName) { if (!m_docks.contains(moduleName)) return; - // Same path as the tab close button — UIPluginManager owns the - // actual teardown and calls back into removePluginDock. emit pluginClosed(moduleName); } diff --git a/src/WorkspaceArea.h b/src/WorkspaceArea.h index 5cca847..2808c0d 100644 --- a/src/WorkspaceArea.h +++ b/src/WorkspaceArea.h @@ -18,8 +18,7 @@ class WorkspaceArea : public QMainWindow { Q_OBJECT - // Read-only test hooks: non-textual dock state for UI automation - // (reached via the QML inspector's evaluate on objectName "workspace"). + // 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) @@ -34,8 +33,6 @@ public: return m_sideBySide ? QStringLiteral("sideBySide") : QStringLiteral("tabbed"); } - // Test hook: close a dock by module name through the same pluginClosed - // path the tab close button takes. No-op for unknown names. Q_INVOKABLE void closeDock(const QString& moduleName); void addPluginDock(QWidget* pluginWidget, @@ -55,8 +52,6 @@ public: signals: void pluginClosed(const QString& moduleName); - // Fires when dockCount / dockOrder / layoutMode change (add, remove, - // layout toggle). Observability only — nothing in the app binds to it. void dockLayoutChanged(); void installClicked(); // Emitted when the front-most dock changes (tab click, close, activate). diff --git a/tests/fixtures/harness.mjs b/tests/fixtures/harness.mjs index 926c8f4..29b83db 100644 --- a/tests/fixtures/harness.mjs +++ b/tests/fixtures/harness.mjs @@ -1,28 +1,4 @@ -// --------------------------------------------------------------------------- -// Shared test-harness helpers for the basecamp UI/shutdown/lifecycle suites. -// -// The test framework itself (test-framework/framework.mjs) belongs to -// logos-qt-mcp — an external flake input we cannot edit — so everything the -// suites need on top of it lives here: -// -// - expectAbsent(app, target, ms) negative wait (text or objectName) -// - pollProperty(app, id, prop, ms) sample a property over a window -// - assertResponsive(app) G-ALIVE: inspector round-trip -// - scanForQmlErrors(text) / G-ERR: QML engine error scan -// markQmlErrorBaseline() / -// assertNoNewQmlErrors() -// - makeTest(frameworkTest) xfail-capable wrapper around the -// framework's test() that appends the -// G-ERR/G-ALIVE epilogue to every test -// - assertCleanTeardown(child, opts) G-EXIT: exit code + log closed + -// no orphans / partial files -// - SHUTDOWN_TIER "pr" (default) or "full" -// - makeUserDir / snapshotPartialFiles --user-dir plumbing for suites that -// spawn the app themselves -// - findByObjectName / installViaPmu PMU-driven install for lifecycle -// flows (see the package-lifecycle -// doctest for the reference walk) -// --------------------------------------------------------------------------- +// Shared suite helpers — the framework itself lives in logos-qt-mcp (not editable here). import { execFileSync } from "node:child_process"; import { @@ -31,16 +7,9 @@ import { import { join, resolve } from "node:path"; import { tmpdir } from "node:os"; -// --------------------------------------------------------------------------- -// SHUTDOWN_TIER — "pr" runs the fast PR-gate subset, "full" the nightly set. -// Suites gate tier-specific tests on this; nothing in the pr tier changes. -// --------------------------------------------------------------------------- export const SHUTDOWN_TIER = (process.env.SHUTDOWN_TIER || "pr").trim().toLowerCase(); -// --------------------------------------------------------------------------- -// Small utilities -// --------------------------------------------------------------------------- export function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); } @@ -73,20 +42,12 @@ export function waitForExit(child, ms) { } export async function findByObjectName(inspector, name) { - // findByProperty on objectName reliably finds the tagged object - // regardless of how its other properties get JSON-serialised. const res = await inspector.send("findByProperty", { property: "objectName", value: name, }); return (res.matches ?? [])[0] || null; } -// --------------------------------------------------------------------------- -// expectAbsent — negative wait. Fails if `target` (a visible text fragment or -// an objectName) shows up in the QML tree at any point during the window. -// Positive waits already exist in the framework; this is the "the dialog must -// NOT appear" half. -// --------------------------------------------------------------------------- export async function expectAbsent(app, target, ms = 3000, opts = {}) { const { intervalMs = 400, treeDepth = 50 } = opts; const deadline = Date.now() + ms; @@ -108,13 +69,7 @@ export async function expectAbsent(app, target, ms = 3000, opts = {}) { } } -// --------------------------------------------------------------------------- -// pollProperty — sample `prop` on the object with inspector id `objectId` -// every `intervalMs` for up to `ms`. Returns the sequence of DISTINCT -// consecutive values observed (with timestamps), so a test can assert on -// transient stages (installStage, loadingRow, ...) it would otherwise race. -// Stops early when `until(value)` returns true. -// --------------------------------------------------------------------------- +// 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(); @@ -136,11 +91,6 @@ export async function pollProperty(app, objectId, prop, ms, opts = {}) { } } -// --------------------------------------------------------------------------- -// G-ALIVE — the app still answers the inspector. A hung event loop makes the -// round-trip time out; a dead process makes it error. Cheap enough to run -// after every test. -// --------------------------------------------------------------------------- export async function assertResponsive(app, opts = {}) { const { timeoutMs = 5000 } = opts; try { @@ -150,23 +100,8 @@ export async function assertResponsive(app, opts = {}) { } } -// --------------------------------------------------------------------------- -// G-ERR — QML engine errors. -// -// Matches only genuine QML engine error lines (a *.qml:line: location plus an -// error keyword), NOT generic stderr noise: the old smoke-test log-regex was -// removed for tripping on unrelated output (see nix/smoke-test.nix), so this -// stays deliberately narrow. -// -// Two sources: -// - scanForQmlErrors(text): scan a captured log chunk (shutdown suite). -// - file mode: when BASECAMP_APP_LOG points at the app's log file, -// markQmlErrorBaseline() records a byte offset at test start and -// assertNoNewQmlErrors() fails on error lines appended since. The CI -// ui-tests run wires this up (nix/integration-test.nix tees the app's -// stdout/stderr into the file); when the env var is unset — e.g. running -// ui-tests by hand against an already-running app — both are no-ops. -// --------------------------------------------------------------------------- +// 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)/; @@ -206,18 +141,7 @@ export function assertNoNewQmlErrors(text = null) { } } -// --------------------------------------------------------------------------- -// makeTest — wraps logos-qt-mcp's test() so every test in the suite inherits -// the G-ERR/G-ALIVE epilogue and gains xfail support without per-test -// boilerplate: -// -// const test = makeTest(frameworkTest); -// test("name", body); // body + epilogue -// test("name", body, { xfail: "M1" }); // XFAIL on failure, -// // fails LOUDLY on unexpected pass -// -// Any extra opts (e.g. { skip: ["offscreen"] }) pass through to the framework. -// --------------------------------------------------------------------------- +// 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; @@ -229,8 +153,6 @@ export function makeTest(frameworkTest) { } catch (e) { bodyError = e; } - // Epilogue runs regardless of the body outcome so a hung or - // error-spewing app is caught even when the body "passed". let epilogueError = null; try { await assertResponsive(app); @@ -256,19 +178,7 @@ export function makeTest(frameworkTest) { }; } -// --------------------------------------------------------------------------- -// G-EXIT — assertCleanTeardown. Extends the old assertGracefulExit (exit code -// 0, no killing signal) with: stdio closed on a complete line, no orphan -// process still on the user-dir, no new temp/partial files under it. -// -// await assertCleanTeardown(child, { -// waitMs: 10000, -// log: () => capturedOutput, // optional -// closed: promiseOfChildClose, // optional, gates the log check -// userDir: "/tmp/...", // optional -// partialBaseline: snapshotPartialFiles(userDir), // taken pre-launch -// }); -// --------------------------------------------------------------------------- +// 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, @@ -284,8 +194,7 @@ export async function assertCleanTeardown(child, opts = {}) { if (exit.code !== 0) throw new Error(`G-EXIT: exit code ${exit.code}, want 0`); if (log) { - // Wait for the stdio streams to actually close so the captured log is - // complete — 'exit' can fire with data still in flight. + // '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")) { @@ -324,8 +233,6 @@ export function assertNoOrphanProcesses(userDir, excludePids = []) { const PARTIAL_FILE_RE = /\.(tmp|temp|part|partial|download)$|(^|\/)\.#/; -// Snapshot the temp/partial files currently under a user dir. Take one -// before launching the app, hand it to assertCleanTeardown afterwards. export function snapshotPartialFiles(userDir) { const found = new Set(); if (!existsSync(userDir)) return found; @@ -349,11 +256,6 @@ export function assertNoNewPartialFiles(userDir, baseline) { } } -// --------------------------------------------------------------------------- -// --user-dir helper — a throwaway user dir shaped like the app expects -// (modules/ + plugins/, see the missing-deps doctest). Suites that spawn the -// app themselves pass it via `--user-dir `. -// --------------------------------------------------------------------------- let userDirCounter = 0; export function makeUserDir(prefix = "basecamp-test-user") { @@ -364,18 +266,8 @@ export function makeUserDir(prefix = "basecamp-test-user") { return dir; } -// --------------------------------------------------------------------------- -// installViaPmu — drive a local .lgx through the only install path the app -// has: package_manager_ui's installLocalPackage slot, then Basecamp's install -// gate. Mirrors the reference walk in -// doctests/basecamp-package-lifecycle.test.yaml. -// -// await installViaPmu(app, "/abs/path/pkg.lgx"); // confirm -// await installViaPmu(app, path, { confirm: false }); // cancel -// -// Preconditions: the Package Manager view has been opened at least once (the -// pmui.BackendStore hook is created lazily on first activation). -// --------------------------------------------------------------------------- +// 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; @@ -396,9 +288,7 @@ export async function installViaPmu(app, lgxPath, opts = {}) { throw new Error(`installViaPmu: installLocalPackage failed: ${call.error}`); } - // Wait for the install gate to open (visible === true — the per-mode - // dialog instances keep stale texts in the tree while closed, so texts - // alone would false-positive). + // 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; @@ -418,8 +308,6 @@ export async function installViaPmu(app, lgxPath, opts = {}) { : "confirmationDialog.installGate.cancel"; const button = await findByObjectName(inspector, buttonName); if (!button) throw new Error(`installViaPmu: ${buttonName} not found`); - // Emitting clicked() runs the onClicked handler — same trick the - // shortcut-bridge test uses with QShortcut::activated. const clicked = await inspector.send("callMethod", { objectId: button.id, method: "clicked", }); diff --git a/tests/fixtures/lgx.mjs b/tests/fixtures/lgx.mjs index 784716f..dbc74c5 100644 --- a/tests/fixtures/lgx.mjs +++ b/tests/fixtures/lgx.mjs @@ -1,32 +1,6 @@ -// --------------------------------------------------------------------------- -// LGX fixture generator for the basecamp test suites. -// -// An .lgx is a gzipped ustar tar: manifest.json at the root plus a payload -// under variants//. This is a port of the inline shell generator in -// doctests/basecamp-package-lifecycle.test.yaml — hand-write the stamped -// manifest + payload, seed a plain-ustar tar, then let the `lgx` CLI inject -// the payload and stamp the manifest's content hashes (packages without them -// fail validation). -// -// Requires the lgx CLI for valid packages: -// nix build 'github:logos-co/logos-package#lgx' -o result-lgx -// Override its location with LGX_CLI (default: /result-lgx/bin/lgx). -// -// Named fixtures (makeFixtureSet): -// app_a → depends on mod_x, mod_y ┐ the A→X,Y / B→Y trio for -// app_b → depends on mod_y ┘ dependency-cascade flows -// mod_x, mod_y core modules the apps depend on -// mod_z → depends on no_such_module (a name nothing provides) -// bad_app corrupt manifest (invalid JSON) -// -// plus writeLocalRepo() — a local logos-repo.json (+ file:// package URLs) -// for repository-driven install flows. -// -// The core-module fixtures are manifest-level: they give the resolver a -// package identity and dependency edges, not a loadable binary (a compiled -// lib can't be hand-crafted here). That is all the dependency/cascade gates -// observe. -// --------------------------------------------------------------------------- +// 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"; @@ -87,8 +61,6 @@ function metadataFor({ name, displayName, version, type, dependencies, descripti } function qmlViewFor({ name, displayName, version }) { - // The view prints its own identity + version so a test can assert which - // payload actually runs after an install or upgrade. return `import QtQuick Rectangle { @@ -104,9 +76,6 @@ Rectangle { `; } -// Write the on-disk payload files for one package version into `payloadDir`. -// For ui_qml that is metadata.json + Main.qml + qmldir; for core modules -// just metadata.json (manifest-level fixture — see the header). export function writePayload(payloadDir, spec) { mkdirSync(payloadDir, { recursive: true }); writeFileSync(join(payloadDir, "metadata.json"), @@ -118,17 +87,6 @@ export function writePayload(payloadDir, spec) { } } -// --------------------------------------------------------------------------- -// makeLgx — build one valid .lgx at outFile. -// -// makeLgx({ -// outFile: "/tmp/fixtures/app_a-0.1.0.lgx", -// name: "app_a", displayName: "App A", version: "0.1.0", -// type: "ui_qml", // or "core" -// dependencies: ["mod_x"], -// verify: true, // run `lgx verify` afterwards -// }) -// --------------------------------------------------------------------------- export function makeLgx(opts) { const { outFile, @@ -164,8 +122,6 @@ export function makeLgx(opts) { // Plain ustar — the LGX reader doesn't speak pax extended headers. execFileSync("tar", ["--format", "ustar", "-C", seed, "-czf", outFile, "manifest.json", "variants"]); - // lgx add injects the payload into the platform variant and stamps the - // manifest's content hashes (the Merkle root packages must carry). execFileSync(lgx, ["add", outFile, "--variant", variant, "--files", payload, "-y"]); if (verify) execFileSync(lgx, ["verify", outFile]); @@ -173,9 +129,7 @@ export function makeLgx(opts) { return outFile; } -// bad_app — a syntactically broken package: valid gzipped ustar tar, corrupt -// manifest.json (truncated JSON). Exercises the reject/error paths, so it is -// deliberately NOT run through lgx (which would refuse to touch it). +// 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`); @@ -190,28 +144,21 @@ export function makeBadApp(outDir) { return outFile; } -// Read the (possibly hash-stamped) manifest back out of an .lgx. export function readLgxManifest(lgxPath) { const out = execFileSync("tar", ["-xzOf", lgxPath, "manifest.json"], { encoding: "utf-8" }); return JSON.parse(out); } -// --------------------------------------------------------------------------- -// makeFixtureSet — build the whole named set into outDir. Returns -// { app_a, app_b, mod_x, mod_y, mod_z, bad_app } → absolute .lgx paths. -// --------------------------------------------------------------------------- export function makeFixtureSet(outDir, opts = {}) { mkdirSync(outDir, { recursive: true }); const version = opts.version || "0.1.0"; const specs = { - // The A→X,Y / B→Y trio: uninstalling Y cascades to both apps, - // uninstalling X only to A. + // 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"] }, - // Z depends on a name no module anywhere provides. mod_z: { type: "core", displayName: "Fixture Module Z", dependencies: ["no_such_module"] }, }; const paths = {}; @@ -225,12 +172,7 @@ export function makeFixtureSet(outDir, opts = {}) { return paths; } -// --------------------------------------------------------------------------- -// writeInstalledPlugin — seed an already-installed package directly into a -// --user-dir (plugins/ for ui_qml, modules/ for core), the way the -// missing-deps doctest crafts broken end states. The package scanner only -// reads manifest.json, so this is indistinguishable from a real install. -// --------------------------------------------------------------------------- +// 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", @@ -248,15 +190,7 @@ export function writeInstalledPlugin(userDir, spec) { return dir; } -// --------------------------------------------------------------------------- -// writeLocalRepo — a local repository the app can add by file:// URL: -// logos-repo.json (name + indexUrl) next to an index listing the given .lgx -// files with file:// download URLs. Row shape mirrors the default repo's -// catalog index (name / versions[] / rootHash / manifest — see -// tests/apps_model_test.cpp makeCatalogRow). -// -// const { repoUrl } = writeLocalRepo(dir, { packages: [paths.app_a] }); -// --------------------------------------------------------------------------- +// 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 }); diff --git a/tests/shutdown-tests.mjs b/tests/shutdown-tests.mjs index e35bd42..742bee9 100644 --- a/tests/shutdown-tests.mjs +++ b/tests/shutdown-tests.mjs @@ -84,14 +84,9 @@ class SkipTest { } function skipTest(reason) { return new SkipTest(reason); } -// Per-test context for assertCleanTeardown: the captured app output and a -// promise for the child's stdio-close, both owned by runTest. let currentRun = null; -// Supported opts: -// { xfail: "M1" } — report XFAIL on failure, fail LOUDLY on unexpected pass -// { tier: "full" } — only runs when SHUTDOWN_TIER=full (nightly); the PR -// gate runs with the default SHUTDOWN_TIER=pr +// 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") { @@ -115,9 +110,7 @@ async function runTest(name, body, opts = {}) { 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 the startup/warmup output: quitting is this suite's - // whole point, and teardown legitimately emits noise a log-regex would - // trip on (the lesson recorded in nix/smoke-test.nix). + // G-ERR scans only startup output — teardown legitimately emits noise. const startupChunkCount = logChunks.length; const ret = await body(child); if (ret instanceof SkipTest) { @@ -165,11 +158,6 @@ async function runTest(name, body, opts = {}) { return outcome; } -// G-EXIT: the old assertGracefulExit (exit code 0, no killing signal), -// extended into assertCleanTeardown — the captured log must also end on a -// complete line once the stdio streams close. tests/fixtures/harness.mjs -// additionally checks orphan processes / partial files when a --user-dir is -// in play (none is here yet). async function assertCleanTeardown(child) { await assertCleanTeardownImpl(child, { waitMs: SHUTDOWN_WAIT_MS, @@ -240,12 +228,7 @@ if (process.platform === "darwin") { })); } -// Window.close() covers Alt+F4 / window-X (all platforms) and ⌘W / red-button -// (macOS). Since 3a73d33 ("revert x on linux closes app") the convention is -// the same on every platform: closing the window never quits. The app keeps -// running — hidden to the tray when one is available, otherwise windowless -// under setQuitOnLastWindowClosed(false) — matching the macOS dock / -// Discord/Slack tray-app convention. +// 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(); @@ -257,7 +240,6 @@ results.push(await runTest("Window.close() does not quit; app keeps running (tra 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( diff --git a/tests/ui-tests.mjs b/tests/ui-tests.mjs index 4132fdc..d7ad0ff 100644 --- a/tests/ui-tests.mjs +++ b/tests/ui-tests.mjs @@ -22,16 +22,10 @@ const qtMcpRoot = process.env.LOGOS_QT_MCP || resolve(projectRoot, "result-mcp") const { test: frameworkTest, run } = await import(resolve(qtMcpRoot, "test-framework/framework.mjs")); -// Every test inherits the G-ERR/G-ALIVE epilogue (assertNoNewQmlErrors + -// assertResponsive) and gains { xfail: "..." } support — the framework -// itself lives in logos-qt-mcp, so the wrapper is local. +// Adds the G-ERR/G-ALIVE epilogue and { xfail } support to every test. const test = makeTest(frameworkTest); -// Total-elapsed report for the PR-gate time budget (MCP_TEST_BUDGET_SECONDS, -// enforced in nix/integration-test.nix + nix/shutdown-test.nix). The runner -// lives in logos-qt-mcp and exits the process from inside run(), so the only -// reliable "after the suite" hook is process exit; writeSync because async -// stdout writes to a pipe can be dropped during exit. +// run() exits the process itself; writeSync so the line isn't dropped at exit. const suiteStart = Date.now(); process.on("exit", () => { writeSync(1, `Total elapsed: ${((Date.now() - suiteStart) / 1000).toFixed(1)}s\n`); @@ -48,14 +42,8 @@ async function openPlugin(app, name, expectedTexts, opts = {}) { ); } -// --- Welcome page (A1) --- -// -// Must run FIRST: it asserts the pre-interaction state of the shared app -// instance (welcome page up, nothing clicked yet). Read-only — no clicks, -// no state left behind. +// --- Welcome page (A1) — must run FIRST: asserts the pre-interaction state --- async function findWelcomePage(app) { - // Prefer a framework findByType wrapper if one exists (mirrors - // app.findByProperty); otherwise talk to the inspector protocol directly. const res = typeof app.findByType === "function" ? await app.findByType("WelcomePage") : await app.inspector.send("findByType", { typeName: "WelcomePage" }); @@ -64,8 +52,6 @@ async function findWelcomePage(app) { } test("welcome: first launch shows the welcome page", async (app) => { - // A WelcomePage instance exists (it is WorkspaceArea's central widget - // whenever no docks are open) and is visible. let welcome = null; await app.waitFor(async () => { welcome = await findWelcomePage(app); @@ -80,13 +66,7 @@ test("welcome: first launch shows the welcome page", async (app) => { throw new Error(`WelcomePage visible=${visRes.result} (expected true)`); } - // Greeting follows backend.launcherApps.length: 0 ⇒ first-launch wording, - // otherwise "Welcome back". backend is a global context property, so the - // welcome page itself serves as the evaluate anchor. launcherApps populates - // asynchronously at startup (PackageCoordinator refresh → - // launcherAppsChanged), so read the length and assert the matching greeting - // in one retried step — a fixed pre-read would race the refresh on - // installations that have apps. + // 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", @@ -102,8 +82,6 @@ test("welcome: first launch shows the welcome page", async (app) => { await app.expectTexts([expected]); }, { timeout: 10000, interval: 500, description: "greeting to match backend.launcherApps" }); - // Exactly one of the two greetings is present — the title is a single - // ternary Text, so both appearing (or neither) means a rendering bug. const tree = JSON.stringify(await app.getTree({ depth: 50 })); const hasFirstLaunch = tree.includes("Welcome to Basecamp!"); const hasWelcomeBack = tree.includes("Welcome back"); From 59d08af088c50326501679bbc589b2a5cd3158c0 Mon Sep 17 00:00:00 2001 From: Roman Zajic Date: Fri, 21 Aug 2026 08:36:41 +0800 Subject: [PATCH 09/13] fix: potential flakiness Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- tests/ui-tests.mjs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/tests/ui-tests.mjs b/tests/ui-tests.mjs index d7ad0ff..c626953 100644 --- a/tests/ui-tests.mjs +++ b/tests/ui-tests.mjs @@ -28,7 +28,16 @@ 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", () => { - writeSync(1, `Total elapsed: ${((Date.now() - suiteStart) / 1000).toFixed(1)}s\n`); + 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. + } + } }); // Helper: click a plugin's sidebar icon and wait for its UI to load. From 31b9f542f19df56b97c72c493ceba660b5c7c0dc Mon Sep 17 00:00:00 2001 From: Roman Zajic Date: Fri, 21 Aug 2026 09:32:32 +0800 Subject: [PATCH 10/13] fix: replace full QML tree serialization Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- tests/ui-tests.mjs | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/tests/ui-tests.mjs b/tests/ui-tests.mjs index c626953..077748e 100644 --- a/tests/ui-tests.mjs +++ b/tests/ui-tests.mjs @@ -91,9 +91,29 @@ test("welcome: first launch shows the welcome page", async (app) => { await app.expectTexts([expected]); }, { timeout: 10000, interval: 500, description: "greeting to match backend.launcherApps" }); - const tree = JSON.stringify(await app.getTree({ depth: 50 })); - const hasFirstLaunch = tree.includes("Welcome to Basecamp!"); - const hasWelcomeBack = tree.includes("Welcome back"); + 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; + }; + return { + hasFirstLaunch: hasText(this, "Welcome to Basecamp!"), + hasWelcomeBack: hasText(this, "Welcome back"), + }; + })()`, + }); + if (greetingRes.error) { + throw new Error(`evaluate(greeting presence) failed: ${greetingRes.error}`); + } + const hasFirstLaunch = greetingRes.result?.hasFirstLaunch === true; + const hasWelcomeBack = greetingRes.result?.hasWelcomeBack === true; if (hasFirstLaunch === hasWelcomeBack) { throw new Error( `greeting texts present: "Welcome to Basecamp!"=${hasFirstLaunch}, ` + From 04481266a04a115c1a330e640d2da2542277315a Mon Sep 17 00:00:00 2001 From: Roman Date: Fri, 21 Aug 2026 09:53:24 +0800 Subject: [PATCH 11/13] fix: file URL construction --- tests/fixtures/lgx.mjs | 8 ++++---- tests/ui-tests.mjs | 10 ++++++---- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/tests/fixtures/lgx.mjs b/tests/fixtures/lgx.mjs index dbc74c5..ff97b47 100644 --- a/tests/fixtures/lgx.mjs +++ b/tests/fixtures/lgx.mjs @@ -5,7 +5,7 @@ import { execFileSync } from "node:child_process"; import { mkdirSync, writeFileSync, rmSync, existsSync } from "node:fs"; import { dirname, join, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; +import { fileURLToPath, pathToFileURL } from "node:url"; import { tmpdir } from "node:os"; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -197,8 +197,8 @@ export function writeLocalRepo(dir, opts = {}) { const indexPath = join(dir, "index.json"); const repoPath = join(dir, "logos-repo.json"); - const indexUrl = `file://${indexPath}`; - const repoUrl = `file://${repoPath}`; + const indexUrl = pathToFileURL(indexPath).href; + const repoUrl = pathToFileURL(repoPath).href; const rows = packages.map((lgxPath) => { const manifest = readLgxManifest(lgxPath); @@ -207,7 +207,7 @@ export function writeLocalRepo(dir, opts = {}) { repositoryUrl: repoUrl, versions: [{ rootHash: manifest.hashes?.root ?? "", - url: `file://${resolve(lgxPath)}`, + url: pathToFileURL(resolve(lgxPath)).href, manifest, }], }; diff --git a/tests/ui-tests.mjs b/tests/ui-tests.mjs index 077748e..b11cf99 100644 --- a/tests/ui-tests.mjs +++ b/tests/ui-tests.mjs @@ -103,17 +103,19 @@ test("welcome: first launch shows the welcome page", async (app) => { } return false; }; - return { + // 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 hasFirstLaunch = greetingRes.result?.hasFirstLaunch === true; - const hasWelcomeBack = greetingRes.result?.hasWelcomeBack === true; + 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}, ` + From b4ef120d8af84be0630f74ce19faaabc8226253b Mon Sep 17 00:00:00 2001 From: Roman Date: Fri, 21 Aug 2026 10:23:42 +0800 Subject: [PATCH 12/13] fix: use bash explicitly --- nix/integration-test.nix | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/nix/integration-test.nix b/nix/integration-test.nix index 6f7bdaf..7c25241 100644 --- a/nix/integration-test.nix +++ b/nix/integration-test.nix @@ -37,7 +37,9 @@ pkgs.runCommand "logos-basecamp-integration-test" { export BASECAMP_APP_LOG="$out/app.log" : > "$BASECAMP_APP_LOG" cat > app-with-log.sh <<'WRAPPER' - #!${pkgs.runtimeShell} + #!${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) From f65b8e6c4c193fcba63befad6a6ff40ebc3c6e17 Mon Sep 17 00:00:00 2001 From: romanzac Date: Fri, 21 Aug 2026 04:11:12 +0000 Subject: [PATCH 13/13] test(ui): welcome Install now navigates to Applications (A2) Runs right after the A1 welcome test, since it clicks the welcome page away. Clicks the CTA at signal level (offscreen hit-testing is fragile), then asserts the Applications view renders within 10s, that backend.currentActiveSectionIndex equals the section index the sidebar "Applications" button carries (read from its delegate context, not hard-coded), and that the welcome page's host (WorkspaceArea) is no longer visible. Prefers the spec objectName welcomePage.installNow and falls back to the button text until that name lands in WelcomePage.qml. --- tests/ui-tests.mjs | 79 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/tests/ui-tests.mjs b/tests/ui-tests.mjs index b11cf99..6eaabbb 100644 --- a/tests/ui-tests.mjs +++ b/tests/ui-tests.mjs @@ -123,6 +123,85 @@ test("welcome: first launch shows the welcome page", async (app) => { } }); +// --- Welcome page (A2) — must run right after A1: navigating clicks the +// welcome page away --- +test('welcome: "Install now" navigates to Applications', async (app) => { + // Prefer the spec objectName; fall back to the button text until + // "welcomePage.installNow" exists in WelcomePage.qml. + let button = null; + await app.waitFor(async () => { + const byName = await app.findByProperty("objectName", "welcomePage.installNow"); + button = (byName.matches ?? [])[0] || null; + if (!button) { + const byText = await app.findByProperty("text", "Install now"); + button = (byText.matches ?? []).find((m) => (m.type ?? "").includes("Button")) || null; + } + if (!button) throw new Error('"Install now" button not found on the welcome page'); + }, { timeout: 10000, interval: 500, description: '"Install now" button to exist' }); + + // Signal-level click — coordinate hit-testing on offscreen is fragile + // (see installViaPmu); the onClicked handler chain is identical. + const clicked = await app.inspector.send("callMethod", { + objectId: button.id, method: "clicked", + }); + if (clicked.error) throw new Error(`callMethod(clicked) failed: ${clicked.error}`); + + await app.waitFor( + async () => { await app.expectTexts(["Install and manage applications."]); }, + { timeout: 10000, interval: 500, description: "Applications view to render" } + ); + + // The sidebar "Applications" button carries the section index it activates + // (onClicked passes _d.workspaceSections.length + index) — read it from the + // delegate's context instead of hard-coding the sidebar layout. Only + // objects in SidebarPanel's delegate scope can resolve the expression, so + // it also disambiguates the button from same-text headers. + const sidebarHits = await app.findByProperty("text", "Applications"); + let appsButtonId = null; + let applicationsIndex = null; + for (const m of sidebarHits.matches ?? []) { + const res = await app.inspector.send("evaluate", { + objectId: m.id, expression: "_d.workspaceSections.length + index", + }); + if (!res.error && typeof res.result === "number") { + appsButtonId = m.id; + applicationsIndex = res.result; + break; + } + } + if (appsButtonId === null) { + throw new Error('sidebar "Applications" button (with section index in scope) not found'); + } + + await app.waitFor(async () => { + const res = await app.inspector.send("evaluate", { + objectId: appsButtonId, expression: "backend.currentActiveSectionIndex", + }); + if (res.error) { + throw new Error(`evaluate(backend.currentActiveSectionIndex) failed: ${res.error}`); + } + if (res.result !== applicationsIndex) { + throw new Error( + `backend.currentActiveSectionIndex=${res.result} ` + + `(expected Applications index ${applicationsIndex})`); + } + }, { timeout: 10000, interval: 500, description: "active section to become Applications" }); + + // WelcomePage's own QML `visible` stays true inside its offscreen + // QQuickWidget host; what observably hides it is that host — WorkspaceArea + // (objectName "workspace"), the stack page the section switch left. + const wsHits = await app.findByProperty("objectName", "workspace"); + const workspace = (wsHits.matches ?? [])[0]; + if (!workspace) throw new Error("workspace area (welcome page host) not found"); + const props = await app.inspector.send("getProperties", { objectId: workspace.id }); + const visible = props.properties?.find((p) => p.name === "visible")?.value; + if (visible !== false) { + throw new Error( + `welcome page still visible: workspace visible=` + + `${JSON.stringify(visible)} (expected false)`); + } +}); + // --- Package Manager --- // // PMUI is no longer launched from the sidebar app launcher (filtered out