feat:add integration tests

This commit is contained in:
Khushboo Mehta
2026-05-11 21:20:32 +05:30
parent e7a4d0ab50
commit c6ec08049b
6 changed files with 311 additions and 31 deletions
+27
View File
@@ -0,0 +1,27 @@
name: CI
on:
push:
branches: [master]
pull_request:
jobs:
integration-test-linux:
name: Agentic UI tests (ubuntu-latest)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: cachix/install-nix-action@v27
with:
extra_nix_config: |
experimental-features = nix-command flakes
accept-flake-config = true
- run: nix build .#integration-test -L
integration-test-macos:
name: Agentic UI tests (macos-latest)
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- uses: DeterminateSystems/nix-installer-action@main
- run: nix build .#integration-test -L
Generated
+6 -6
View File
@@ -1176,11 +1176,11 @@
]
},
"locked": {
"lastModified": 1778263343,
"narHash": "sha256-MhJYbaw2ppjjirKGnZQT7uIpPIvxM8+OaEHAlFtOxYA=",
"lastModified": 1778513719,
"narHash": "sha256-xx/GAOH/tn5adLn43ZBLDOvS5w2othnD0UODWgFiDtU=",
"owner": "logos-co",
"repo": "logos-module-builder",
"rev": "5e196e2769605f201b481fa71b8c2e731ad3dcc6",
"rev": "b15a3724772e171e33d590fec57ede9208b8a478",
"type": "github"
},
"original": {
@@ -7486,11 +7486,11 @@
]
},
"locked": {
"lastModified": 1778263283,
"narHash": "sha256-+eQlkpdUKvaZdI31crmC987/VfYtF8km/BlLXU/XA4Q=",
"lastModified": 1778512973,
"narHash": "sha256-TPGogCxwYpLrn4xwwlT8a02+mVinVi6ll8DlCprc4Wo=",
"owner": "logos-co",
"repo": "logos-standalone-app",
"rev": "3cf72811babecbc4446712297f8e251a44378497",
"rev": "f99087157e9efb71cc6c29ffe6c7586e49a325cb",
"type": "github"
},
"original": {
+2
View File
@@ -6,6 +6,8 @@ import Logos.PackageManagerUi 1.0
QtObject {
id: store
objectName: "pmui.BackendStore"
// ─── Properties: inputs (overridable for tests) ───
property string moduleName: "package_manager_ui"
property var backend: logos.module(moduleName)
+1 -1
View File
@@ -4,7 +4,7 @@ import QtQuick.Layouts
import Logos.Theme
import Logos.Controls
import Icons
import icons
// Table header per Figma: title "Packages" + install-state tabs on the
// left, release picker + bulk-action buttons (Reload / Uninstall / Install)
+1 -1
View File
@@ -1,2 +1,2 @@
module Icons
module icons
singleton PackageIcons 1.0 PackageIcons.qml
+274 -23
View File
@@ -1,46 +1,297 @@
#!/usr/bin/env node
// package_manager_ui integration tests
//
// Usage:
// node tests/ui-tests.mjs # run against a running app
// node tests/ui-tests.mjs --ci <binary> # CI: launch app, test, kill
// node tests/ui-tests.mjs # against a running app
// node tests/ui-tests.mjs --ci <binary> # launch, test, kill
// node tests/ui-tests.mjs <substring> # filter tests by name
//
// Requires result-mcp/: nix build .#test-framework -o result-mcp
// Requires: nix build .#test-framework -o result-mcp
import { resolve } from "node:path";
const root = process.env.LOGOS_QT_MCP || new URL("../result-mcp", import.meta.url).pathname;
const { test, run } = await import(resolve(root, "test-framework/framework.mjs"));
test("package_manager_ui: loads and shows title", async (app) => {
// Reload is the most stable mount signal — always present, always rendered.
async function waitForPmuiLoaded(app, timeout = 15000) {
await app.waitFor(
async () => { await app.expectTexts(["Package Manager"]); },
{ timeout: 15000, interval: 500, description: "Package Manager UI to load" }
async () => { await app.expectTexts(["Reload"]); },
{ timeout, interval: 500, description: "Package Manager UI to load" }
);
}
// findByType doesn't match QML-declared types (Qt mangles them as
// <Type>_QMLTYPE_<n>). Anchor lookups on the QObject objectName instead —
// BackendStore.qml sets objectName: "pmui.BackendStore".
async function storeProperty(app, propName) {
const res = await app.findByProperty("objectName", "pmui.BackendStore");
if (res.error || !res.matches || res.matches.length === 0) {
throw new Error('No object found with objectName "pmui.BackendStore"');
}
return propertyOf(app, res.matches[0].id, propName);
}
async function propertyOf(app, objectId, propName) {
const res = await app.getProperties(objectId);
if (res.error) throw new Error(`getProperties failed: ${res.error}`);
const prop = res.properties.find((p) => p.name === propName);
if (!prop) throw new Error(`property "${propName}" not found`);
return prop.value;
}
test("smoke: PMUI loads and shows title", async (app) => {
await waitForPmuiLoaded(app);
await app.expectTexts(["Package Manager"]);
});
test("package_manager_ui: shows subtitle", async (app) => {
await app.expectTexts(["Manage plugins and packages"]);
test("smoke: subtitle renders", async (app) => {
await waitForPmuiLoaded(app);
await app.expectTexts(["Manage your plugins and packages."]);
});
test("package_manager_ui: reload button visible", async (app) => {
await app.expectTexts(["Reload"]);
test("smoke: top-bar action labels render", async (app) => {
await waitForPmuiLoaded(app);
// Uninstall is gated on selection, may not always be visible.
await app.expectTexts(["Reload", "Install"]);
});
test("package_manager_ui: install button visible", async (app) => {
await app.expectTexts(["Install"]);
});
test("package_manager_ui: shows release selector", async (app) => {
await app.expectTexts(["Release:"]);
});
test("package_manager_ui: shows table headers", async (app) => {
test("structure: table headers render", async (app) => {
await waitForPmuiLoaded(app);
await app.expectTexts(["Package", "Type", "Description", "Status"]);
});
test("package_manager_ui: shows default details text", async (app) => {
await app.expectTexts(["Select a package to view its details."]);
test("store: exposes the documented properties with sane defaults", async (app) => {
await waitForPmuiLoaded(app);
// Values depend on fixture; only types and existence are pinned here.
const isLoading = await storeProperty(app, "isLoading");
const isInstalling = await storeProperty(app, "isInstalling");
const pageSize = await storeProperty(app, "pageSize");
const currentPage = await storeProperty(app, "currentPage");
if (typeof isLoading !== "boolean") throw new Error(`isLoading not boolean: ${isLoading}`);
if (typeof isInstalling !== "boolean") throw new Error(`isInstalling not boolean: ${isInstalling}`);
if (typeof pageSize !== "number") throw new Error(`pageSize not number: ${pageSize}`);
if (typeof currentPage !== "number") throw new Error(`currentPage not number: ${currentPage}`);
if (currentPage < 1) throw new Error(`currentPage must be 1-indexed, got ${currentPage}`);
});
test("store: idle state — not installing once catalog has loaded", async (app) => {
await waitForPmuiLoaded(app);
await app.waitFor(
async () => {
const loading = await storeProperty(app, "isLoading");
if (loading) throw new Error("still loading");
},
{ timeout: 10000, interval: 500, description: "catalog to finish loading" }
);
const isInstalling = await storeProperty(app, "isInstalling");
if (isInstalling) throw new Error("isInstalling should be false at idle");
});
test("search: typing into the search bar updates store.searchText", async (app) => {
await waitForPmuiLoaded(app);
const search = await app.findByProperty("placeholderText", "Search packages…");
if (!search.matches || search.matches.length === 0) {
throw new Error("Search bar not found");
}
const searchId = search.matches[0].id;
// Drive via setProperty rather than typing — typing depends on the focus chain.
await app.inspector.send("setProperty", {
objectId: searchId, property: "text", value: "waku",
});
await app.waitFor(
async () => {
const t = await storeProperty(app, "searchText");
if (t !== "waku") throw new Error(`searchText="${t}" (expected "waku")`);
},
{ timeout: 5000, interval: 250, description: "store.searchText to mirror search bar" }
);
}, { skip: ["offscreen"] });
test("search: clearing search resets totalCount to its pre-filter value", async (app) => {
await waitForPmuiLoaded(app);
const totalBefore = await storeProperty(app, "totalCount");
const search = await app.findByProperty("placeholderText", "Search packages…");
const searchId = search.matches[0].id;
await app.inspector.send("setProperty", { objectId: searchId, property: "text", value: "nothing-matches-this-zzzzz" });
await app.waitFor(
async () => {
const t = await storeProperty(app, "totalCount");
// A fixture happening to contain this row is a fixture issue, not a test bug.
if (t !== 0) throw new Error(`expected 0 results, got ${t}`);
},
{ timeout: 5000, interval: 250, description: "filter to apply" }
);
await app.inspector.send("setProperty", { objectId: searchId, property: "text", value: "" });
await app.waitFor(
async () => {
const t = await storeProperty(app, "totalCount");
if (t !== totalBefore) throw new Error(`expected ${totalBefore} after clear, got ${t}`);
},
{ timeout: 5000, interval: 250, description: "totalCount to recover" }
);
}, { skip: ["offscreen"] });
test("filter tabs: All/Installed/Not Installed labels render", async (app) => {
await waitForPmuiLoaded(app);
await app.expectTexts(["All", "Installed", "Not Installed"]);
});
test("filter tabs: clicking 'Not Installed' updates store.installStateFilter", async (app) => {
await waitForPmuiLoaded(app);
// Use exact matching: substring would let "Installed" match the "Not Installed"
// tab, and the bare "All" matches the sidebar's Types entry before the tab.
await app.click("Not Installed", { exact: true });
await app.waitFor(
async () => {
const v = await storeProperty(app, "installStateFilter");
if (v !== 2) throw new Error(`installStateFilter=${v} (expected 2)`);
},
{ timeout: 5000, interval: 250, description: "filter state to switch" }
);
await app.click("Installed", { exact: true });
await app.waitFor(
async () => {
const v = await storeProperty(app, "installStateFilter");
if (v !== 1) throw new Error(`installStateFilter=${v} (expected 1)`);
},
{ timeout: 5000, interval: 250, description: "filter to switch to Installed" }
);
});
test("paginator: currentPage starts at 1 and is bounded by totalCount", async (app) => {
await waitForPmuiLoaded(app);
const total = await storeProperty(app, "totalCount");
const pageSize = await storeProperty(app, "pageSize");
const currentPage = await storeProperty(app, "currentPage");
if (currentPage < 1) throw new Error(`currentPage must be 1-indexed, got ${currentPage}`);
// Paginator is only visible when totalCount > 0; otherwise the bound check is moot.
if (total > 0) {
const maxPage = Math.max(1, Math.ceil(total / pageSize));
if (currentPage > maxPage) {
throw new Error(`currentPage=${currentPage} exceeds maxPage=${maxPage}`);
}
}
});
test("paginator: applying a filter resets currentPage to 1", async (app) => {
await waitForPmuiLoaded(app);
// PagingProxy resets currentPage on source-model reset, which a filter change triggers.
await app.click("All");
await app.click("Installed");
await app.waitFor(
async () => {
const p = await storeProperty(app, "currentPage");
if (p !== 1) throw new Error(`currentPage=${p} (expected 1 after filter change)`);
},
{ timeout: 5000, interval: 250, description: "currentPage to reset to 1" }
);
});
test("sort: clicking a column header updates store.sortRole", async (app) => {
await waitForPmuiLoaded(app);
await app.click("Type");
await app.waitFor(
async () => {
const role = await storeProperty(app, "sortRole");
if (role !== "type") throw new Error(`sortRole="${role}" (expected "type")`);
},
{ timeout: 5000, interval: 250, description: "sortRole to update" }
);
}, { skip: ["offscreen"] });
test("details panel: hidden by default (no selection)", async (app) => {
await waitForPmuiLoaded(app);
// DetailsPanel may still be in the tree but invisible; check `visible` per match
// rather than asserting absence from the tree.
const res = await app.findByProperty("text", "Details");
if (res.matches && res.matches.length > 0) {
let anyVisible = false;
for (const m of res.matches) {
try {
const visible = await propertyOf(app, m.id, "visible");
if (visible) { anyVisible = true; break; }
} catch { /* property missing — skip */ }
}
if (anyVisible) {
throw new Error("DetailsPanel header visible without a selected row");
}
}
});
test("bulk install: button is initially disabled (no selection)", async (app) => {
await waitForPmuiLoaded(app);
const has = await storeProperty(app, "hasInstallableSelection");
if (has !== false) {
throw new Error(`hasInstallableSelection=${has} on initial load (expected false)`);
}
});
test("bulk uninstall: button is initially disabled (no selection)", async (app) => {
await waitForPmuiLoaded(app);
const has = await storeProperty(app, "hasUninstallableSelection");
if (has !== false) {
throw new Error(`hasUninstallableSelection=${has} on initial load (expected false)`);
}
});
test("reload: clicking the reload button triggers a refresh cycle", async (app) => {
await waitForPmuiLoaded(app);
// reloadCatalog is a no-op stub for empty fixtures; we only assert the click
// doesn't error and the app settles back to not-loading.
await app.click("Reload");
await app.waitFor(
async () => {
const loading = await storeProperty(app, "isLoading");
if (loading) throw new Error("still loading");
},
{ timeout: 15000, interval: 500, description: "refresh cycle to settle" }
);
});
test("regression: store.totalCount and pageSize are integers, not strings", async (app) => {
// Old QtRO bug serialised int props as strings on macOS, silently breaking ceil arithmetic.
await waitForPmuiLoaded(app);
const total = await storeProperty(app, "totalCount");
const pageSize = await storeProperty(app, "pageSize");
if (typeof total !== "number") throw new Error(`totalCount type=${typeof total}`);
if (typeof pageSize !== "number") throw new Error(`pageSize type=${typeof pageSize}`);
});
test("regression: availableTypes always includes 'All' as the first entry", async (app) => {
// The type sidebar must show 'All' even before packages load.
await waitForPmuiLoaded(app);
const types = await storeProperty(app, "availableTypes");
if (!Array.isArray(types) || types.length === 0) {
throw new Error(`availableTypes is empty: ${JSON.stringify(types)}`);
}
if (types[0] !== "All") {
throw new Error(`availableTypes[0]="${types[0]}" (expected "All")`);
}
});
test("regression: sortOrder is one of Qt.AscendingOrder/DescendingOrder", async (app) => {
await waitForPmuiLoaded(app);
const order = await storeProperty(app, "sortOrder");
if (order !== 0 && order !== 1) {
throw new Error(`sortOrder=${order} (expected 0 or 1)`);
}
});
run();