From c6ec08049b4cdcdfe66b65e2e11f39cc4b8bcf13 Mon Sep 17 00:00:00 2001 From: Khushboo Mehta Date: Mon, 11 May 2026 21:09:44 +0530 Subject: [PATCH] feat:add integration tests --- .github/workflows/ci.yml | 27 +++ flake.lock | 12 +- src/qml/BackendStore.qml | 2 + src/qml/Panels/TableHeader.qml | 2 +- src/qml/icons/qmldir | 2 +- tests/ui-tests.mjs | 297 ++++++++++++++++++++++++++++++--- 6 files changed, 311 insertions(+), 31 deletions(-) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..1236efe --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 diff --git a/flake.lock b/flake.lock index 393162d..1be7410 100644 --- a/flake.lock +++ b/flake.lock @@ -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": { diff --git a/src/qml/BackendStore.qml b/src/qml/BackendStore.qml index 022e1d0..63d46bc 100644 --- a/src/qml/BackendStore.qml +++ b/src/qml/BackendStore.qml @@ -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) diff --git a/src/qml/Panels/TableHeader.qml b/src/qml/Panels/TableHeader.qml index 8793143..e9972b0 100644 --- a/src/qml/Panels/TableHeader.qml +++ b/src/qml/Panels/TableHeader.qml @@ -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) diff --git a/src/qml/icons/qmldir b/src/qml/icons/qmldir index 893cf6a..d68a3e0 100644 --- a/src/qml/icons/qmldir +++ b/src/qml/icons/qmldir @@ -1,2 +1,2 @@ -module Icons +module icons singleton PackageIcons 1.0 PackageIcons.qml diff --git a/tests/ui-tests.mjs b/tests/ui-tests.mjs index 1730b0c..be72bd1 100644 --- a/tests/ui-tests.mjs +++ b/tests/ui-tests.mjs @@ -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 # CI: launch app, test, kill +// node tests/ui-tests.mjs # against a running app +// node tests/ui-tests.mjs --ci # launch, test, kill +// node tests/ui-tests.mjs # 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 +// _QMLTYPE_). 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();