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