mirror of
https://github.com/logos-co/logos-basecamp.git
synced 2026-08-27 06:41:14 +00:00
Merge f074cc9243 into 6362789960
This commit is contained in:
@@ -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
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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: {
|
||||
|
||||
@@ -104,6 +104,7 @@ Rectangle {
|
||||
|
||||
LogosSearchBar {
|
||||
id: searchBar
|
||||
objectName: "settings.searchField"
|
||||
visible: d.searchable
|
||||
Layout.alignment: Qt.AlignRight
|
||||
Layout.preferredWidth: 605
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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).
|
||||
|
||||
Vendored
+317
@@ -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}`);
|
||||
}
|
||||
}
|
||||
Vendored
+221
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user