From cd0b04051806da2999fbab6accdc88c5d658b454 Mon Sep 17 00:00:00 2001 From: romanzac Date: Fri, 21 Aug 2026 08:46:10 +0000 Subject: [PATCH 1/3] test(ui): add A4 test for closing the last dock restoring welcome MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spec MCP-TEST-PLAN v2 §2.A A4: from the A3 state, close fixture A's dock and gate on dockCount reaching 0 within 5s, the welcome page becoming visible again with the "Welcome back" greeting (fixture A stays installed, only unloaded), and backend.currentVisibleApp returning to "". The dock is closed via the inspector's evaluate path (closeDock("test_qml_only") on the WorkspaceArea object) rather than callMethod, which does not marshal the QString argument correctly. The test establishes its own precondition — if no dock is open it re-opens fixture A first, and skips (per spec §0.A) when the fixture is absent outside --ci mode. --- tests/ui-tests.mjs | 127 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 127 insertions(+) diff --git a/tests/ui-tests.mjs b/tests/ui-tests.mjs index 46ad888a..ec180ef2 100644 --- a/tests/ui-tests.mjs +++ b/tests/ui-tests.mjs @@ -504,6 +504,133 @@ test("workspace: closing the last dock brings the welcome page back", async (app }, { timeout: 5000, interval: 250, description: "currentVisibleApp to clear" }); }); +// --- Workspace (A4) — closing the last dock brings the welcome page back --- +// +// Spec §2.A A4: from A3 state, close fixture A's dock. Closing the last dock +// also unloads the module by design (WorkspaceArea::pluginClosed → +// unloadUiModule), so the gates double as a regression guard for the +// currentVisibleApp clear on unload of the visible app. +// +// closeDock is invoked through the inspector's evaluate, NOT callMethod: +// callMethod does not marshal the QString argument correctly (logos-qt-mcp +// limitation), while the evaluate path's JS engine converts it fine. + +test("workspace: closing the last dock brings the welcome page back", async (app) => { + // Same stable evaluate anchor as A3 — has `backend` in context and + // survives the dock teardown. + 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 workspace = await findByObjectName(app.inspector, "workspace"); + if (!workspace) { + throw new Error('WorkspaceArea (objectName "workspace") not found'); + } + + // Establish the A3 end state without assuming A3 left it: fixture A's + // dock must be open before we can close it. + const preCount = await app.inspector.send("evaluate", { + objectId: workspace.id, expression: "dockCount", + }); + if (preCount.error) throw new Error(`evaluate(dockCount) failed: ${preCount.error}`); + if (preCount.result !== 1) { + let tile = null; + try { + await app.waitFor(async () => { + tile = await findByObjectName(app.inspector, `sidebar.app.${FIXTURE_A.name}`); + if (!tile) throw new Error(`sidebar.app.${FIXTURE_A.name} not in the tree`); + }, { timeout: 10000, interval: 500, description: "fixture A sidebar tile to appear" }); + } catch (e) { + if (!CI_MODE) { + console.log( + ` SKIP: fixture A (${FIXTURE_A.name}) is not installed in this ` + + `app instance (spec §0.A: skip, not fail, outside --ci)`); + return; + } + throw new Error( + `no dock open and fixture A sidebar tile never appeared — ` + + `integration-test pre-seeds ${FIXTURE_A.name} at boot, so this is ` + + `a real failure: ${e.message}`); + } + const clicked = await app.inspector.send("callMethod", { + objectId: tile.id, method: "clicked", + }); + if (clicked.error) { + throw new Error(`clicking sidebar.app.${FIXTURE_A.name} failed: ${clicked.error}`); + } + } + await app.waitFor(async () => { + const count = await app.inspector.send("evaluate", { + objectId: workspace.id, expression: "dockCount", + }); + if (count.error) throw new Error(`evaluate(dockCount) failed: ${count.error}`); + if (count.result !== 1) { + throw new Error(`WorkspaceArea.dockCount=${count.result} (expected 1)`); + } + const visibleApp = await app.inspector.send("evaluate", { + objectId: welcome.id, expression: "backend.currentVisibleApp", + }); + if (visibleApp.error) { + throw new Error(`evaluate(backend.currentVisibleApp) failed: ${visibleApp.error}`); + } + if (visibleApp.result !== FIXTURE_A.name) { + throw new Error( + `backend.currentVisibleApp=${JSON.stringify(visibleApp.result)} ` + + `(expected "${FIXTURE_A.name}")`); + } + }, { timeout: 10000, interval: 500, + description: `fixture A dock to be open and front-most` }); + + // Close the dock. + const closed = await app.inspector.send("evaluate", { + objectId: workspace.id, + expression: `closeDock(${JSON.stringify(FIXTURE_A.name)})`, + }); + if (closed.error) throw new Error(`evaluate(closeDock) failed: ${closed.error}`); + + // Gate: dock count reaches 0 within 5 s. + await app.waitFor(async () => { + const res = await app.inspector.send("evaluate", { + objectId: workspace.id, expression: "dockCount", + }); + if (res.error) throw new Error(`evaluate(dockCount) failed: ${res.error}`); + if (res.result !== 0) { + throw new Error(`WorkspaceArea.dockCount=${res.result} (expected 0)`); + } + }, { timeout: 5000, interval: 250, description: "workspace dockCount to reach 0" }); + + // Gate: the welcome page is visible again… + await app.waitFor(async () => { + if ((await welcomePageHidden(app, welcome.id)) !== false) { + throw new Error("welcome page is still hidden after closing the last dock"); + } + }, { timeout: 5000, interval: 250, description: "welcome page to reappear" }); + + // …with the installed-apps greeting — closing unloads fixture A but does + // not uninstall it, so launcherApps stays non-empty and the greeting is + // "Welcome back", not the first-launch text. + await app.waitFor( + async () => { await app.expectTexts(["Welcome back"]); }, + { timeout: 5000, interval: 250, description: '"Welcome back" greeting to render' } + ); + + // Gate: the backend no longer reports a front-most app. + await app.waitFor(async () => { + const res = await app.inspector.send("evaluate", { + objectId: welcome.id, expression: "backend.currentVisibleApp", + }); + if (res.error) { + throw new Error(`evaluate(backend.currentVisibleApp) failed: ${res.error}`); + } + if (res.result !== "") { + throw new Error( + `backend.currentVisibleApp=${JSON.stringify(res.result)} (expected "")`); + } + }, { timeout: 5000, interval: 250, description: "currentVisibleApp to clear" }); +}); + // --- Package Manager --- // // PMUI is no longer launched from the sidebar app launcher (filtered out From 7ec1b6c7d4f46c0beea6820d42639966bbc958a1 Mon Sep 17 00:00:00 2001 From: romanzac Date: Fri, 21 Aug 2026 08:54:44 +0000 Subject: [PATCH 2/3] test(ui): add A5 test for re-click of an open app not duplicating dock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spec MCP-TEST-PLAN v2 §2.A A5: click sidebar.app.test_qml_only twice, 500 ms apart, starting from the empty workspace A4 leaves behind. Gates: WorkspaceArea.dockCount stays 1 across a 2 s settle window after the re-click, backend.currentVisibleApp still reports test_qml_only, and exactly one instantiation of fixture A's root document exists — counted as one QQuickWidget sourced from the fixture's Main.qml plus one render of its unique payload text, since the literal root type is a plain Rectangle the shell instantiates everywhere. The test closes the dock at the end to restore the no-docks baseline for the rest of the suite. --- tests/ui-tests.mjs | 181 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 180 insertions(+), 1 deletion(-) diff --git a/tests/ui-tests.mjs b/tests/ui-tests.mjs index ec180ef2..6837ae3d 100644 --- a/tests/ui-tests.mjs +++ b/tests/ui-tests.mjs @@ -14,7 +14,7 @@ import { fileURLToPath } from "node:url"; import { dirname, resolve } from "node:path"; import { writeSync } from "node:fs"; -import { findByObjectName, makeTest } from "./fixtures/harness.mjs"; +import { findByObjectName, makeTest, sleep } from "./fixtures/harness.mjs"; import { FIXTURE_A } from "./fixtures/lgx.mjs"; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -631,6 +631,185 @@ test("workspace: closing the last dock brings the welcome page back", async (app }, { timeout: 5000, interval: 250, description: "currentVisibleApp to clear" }); }); +// --- Workspace (A5) — re-clicking an open app does not create a second dock --- +// +// Spec §2.A A5: click sidebar.app.test_qml_only twice, 500 ms apart. The +// first click opens the dock (A4 left the workspace empty); the second must +// activate the existing dock, not spawn another. +// +// "Exactly one instance of fixture A's root item type in the tree" cannot be +// checked literally on this branch: the fixture's root is a plain Rectangle +// (qmlViewFor in tests/fixtures/lgx.mjs), a type the shell instantiates all +// over. Each instantiation of the fixture's root document lives in exactly +// one host QQuickWidget whose source is /Main.qml +// (PluginLoader.cpp:367) and renders exactly one Text with the unique +// payload string — so those two counts stand in for the root-type count. + +test("workspace: re-clicking an open app does not create a second dock", async (app) => { + // Same stable evaluate anchor as A3/A4 — has `backend` in context and + // survives sidebar delegate churn. + 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 workspace = await findByObjectName(app.inspector, "workspace"); + if (!workspace) { + throw new Error('WorkspaceArea (objectName "workspace") not found'); + } + + // Click #1 — opens the dock. + let tile = null; + try { + await app.waitFor(async () => { + tile = await findByObjectName(app.inspector, `sidebar.app.${FIXTURE_A.name}`); + if (!tile) throw new Error(`sidebar.app.${FIXTURE_A.name} not in the tree`); + }, { timeout: 10000, interval: 500, description: "fixture A sidebar tile to appear" }); + } catch (e) { + if (!CI_MODE) { + console.log( + ` SKIP: fixture A (${FIXTURE_A.name}) is not installed in this ` + + `app instance (spec §0.A: skip, not fail, outside --ci)`); + return; + } + throw new Error( + `fixture A sidebar tile never appeared — integration-test pre-seeds ` + + `${FIXTURE_A.name} at boot, so this is a real failure: ${e.message}`); + } + const firstClick = await app.inspector.send("callMethod", { + objectId: tile.id, method: "clicked", + }); + if (firstClick.error) { + throw new Error(`clicking sidebar.app.${FIXTURE_A.name} failed: ${firstClick.error}`); + } + + // Wait until the app is actually open — the spec's 500 ms spacing assumes + // the first click's dock exists before the re-click; on a slow-loading run + // a blind 500 ms click would test click-while-loading instead. + await app.waitFor(async () => { + const count = await app.inspector.send("evaluate", { + objectId: workspace.id, expression: "dockCount", + }); + if (count.error) throw new Error(`evaluate(dockCount) failed: ${count.error}`); + if (count.result !== 1) { + throw new Error(`WorkspaceArea.dockCount=${count.result} (expected 1)`); + } + const visibleApp = await app.inspector.send("evaluate", { + objectId: welcome.id, expression: "backend.currentVisibleApp", + }); + if (visibleApp.error) { + throw new Error(`evaluate(backend.currentVisibleApp) failed: ${visibleApp.error}`); + } + if (visibleApp.result !== FIXTURE_A.name) { + throw new Error( + `backend.currentVisibleApp=${JSON.stringify(visibleApp.result)} ` + + `(expected "${FIXTURE_A.name}")`); + } + }, { timeout: 10000, interval: 500, + description: "fixture A dock to open after the first click" }); + + // Click #2, 500 ms later. Loading moved the delegate from the unloaded to + // the loaded Repeater (same objectName, new object), so re-find inside the + // retry loop — a delegate mid-churn just retries, and a duplicate + // activation click is harmless (activation is what A5 exercises). + await sleep(500); + await app.waitFor(async () => { + const loadedTile = + await findByObjectName(app.inspector, `sidebar.app.${FIXTURE_A.name}`); + if (!loadedTile) throw new Error(`sidebar.app.${FIXTURE_A.name} not in the tree`); + const clicked = await app.inspector.send("callMethod", { + objectId: loadedTile.id, method: "clicked", + }); + if (clicked.error) { + throw new Error(`re-clicking sidebar.app.${FIXTURE_A.name} failed: ${clicked.error}`); + } + }, { timeout: 10000, interval: 500, description: "second click on fixture A tile" }); + + // Gate: dock count STAYS 1 — poll across a settle window rather than one + // instant-passing read, so an asynchronously created second dock (the + // load path defers through singleShot timers) cannot slip in unseen. + const settleDeadline = Date.now() + 2000; + for (;;) { + const count = await app.inspector.send("evaluate", { + objectId: workspace.id, expression: "dockCount", + }); + if (count.error) throw new Error(`evaluate(dockCount) failed: ${count.error}`); + if (count.result !== 1) { + throw new Error( + `WorkspaceArea.dockCount=${count.result} after re-click ` + + `(expected it to stay 1)`); + } + if (Date.now() >= settleDeadline) break; + await sleep(250); + } + + // Gate: fixture A is still the front-most app. + const visibleApp = await app.inspector.send("evaluate", { + objectId: welcome.id, expression: "backend.currentVisibleApp", + }); + if (visibleApp.error) { + throw new Error(`evaluate(backend.currentVisibleApp) failed: ${visibleApp.error}`); + } + if (visibleApp.result !== FIXTURE_A.name) { + throw new Error( + `backend.currentVisibleApp=${JSON.stringify(visibleApp.result)} ` + + `(expected "${FIXTURE_A.name}")`); + } + + // Gate: exactly one instantiation of fixture A's root document — one host + // QQuickWidget sourced from the fixture's Main.qml… + const byType = await app.inspector.send("findByType", { typeName: "QQuickWidget" }); + if (byType.error) throw new Error(`findByType(QQuickWidget) failed: ${byType.error}`); + const fixtureHosts = []; + for (const m of byType.matches ?? []) { + const props = await app.inspector.send("getProperties", { objectId: m.id }); + const source = props.properties?.find((p) => p.name === "source")?.value; + if (typeof source === "string" + && source.includes(`/${FIXTURE_A.name}/`) + && source.endsWith("Main.qml")) { + fixtureHosts.push(source); + } + } + if (fixtureHosts.length !== 1) { + throw new Error( + `${fixtureHosts.length} QQuickWidget(s) sourced from fixture A's ` + + `Main.qml (expected exactly 1): ${JSON.stringify(fixtureHosts)}`); + } + + // …and exactly one render of its unique payload text. + const textHits = await app.inspector.send("findByProperty", { + property: "text", value: FIXTURE_A_TEXT, + }); + if (textHits.error) { + throw new Error(`findByProperty(text=payload) failed: ${textHits.error}`); + } + const payloadCount = (textHits.matches ?? []).length; + if (payloadCount !== 1) { + throw new Error( + `${payloadCount} instance(s) of fixture A's payload text in the tree ` + + `(expected exactly 1)`); + } + + // Cleanup: close the dock so the rest of the suite starts from the same + // no-docks baseline A4 established (close also unloads the module — + // same evaluate path as A4; callMethod can't marshal the QString arg). + const closed = await app.inspector.send("evaluate", { + objectId: workspace.id, + expression: `closeDock(${JSON.stringify(FIXTURE_A.name)})`, + }); + if (closed.error) throw new Error(`evaluate(closeDock) failed: ${closed.error}`); + await app.waitFor(async () => { + const res = await app.inspector.send("evaluate", { + objectId: workspace.id, expression: "dockCount", + }); + if (res.error) throw new Error(`evaluate(dockCount) failed: ${res.error}`); + if (res.result !== 0) { + throw new Error(`WorkspaceArea.dockCount=${res.result} (expected 0)`); + } + }, { timeout: 5000, interval: 250, description: "cleanup: fixture A dock to close" }); +}); + // --- Package Manager --- // // PMUI is no longer launched from the sidebar app launcher (filtered out From cc58f75a22fc6b2adc5633556ab9dbbb364e9093 Mon Sep 17 00:00:00 2001 From: Roman Date: Thu, 27 Aug 2026 14:20:25 +0800 Subject: [PATCH 3/3] fix: remove duplicated test --- tests/ui-tests.mjs | 127 --------------------------------------------- 1 file changed, 127 deletions(-) diff --git a/tests/ui-tests.mjs b/tests/ui-tests.mjs index 6837ae3d..3bb4f066 100644 --- a/tests/ui-tests.mjs +++ b/tests/ui-tests.mjs @@ -504,133 +504,6 @@ test("workspace: closing the last dock brings the welcome page back", async (app }, { timeout: 5000, interval: 250, description: "currentVisibleApp to clear" }); }); -// --- Workspace (A4) — closing the last dock brings the welcome page back --- -// -// Spec §2.A A4: from A3 state, close fixture A's dock. Closing the last dock -// also unloads the module by design (WorkspaceArea::pluginClosed → -// unloadUiModule), so the gates double as a regression guard for the -// currentVisibleApp clear on unload of the visible app. -// -// closeDock is invoked through the inspector's evaluate, NOT callMethod: -// callMethod does not marshal the QString argument correctly (logos-qt-mcp -// limitation), while the evaluate path's JS engine converts it fine. - -test("workspace: closing the last dock brings the welcome page back", async (app) => { - // Same stable evaluate anchor as A3 — has `backend` in context and - // survives the dock teardown. - 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 workspace = await findByObjectName(app.inspector, "workspace"); - if (!workspace) { - throw new Error('WorkspaceArea (objectName "workspace") not found'); - } - - // Establish the A3 end state without assuming A3 left it: fixture A's - // dock must be open before we can close it. - const preCount = await app.inspector.send("evaluate", { - objectId: workspace.id, expression: "dockCount", - }); - if (preCount.error) throw new Error(`evaluate(dockCount) failed: ${preCount.error}`); - if (preCount.result !== 1) { - let tile = null; - try { - await app.waitFor(async () => { - tile = await findByObjectName(app.inspector, `sidebar.app.${FIXTURE_A.name}`); - if (!tile) throw new Error(`sidebar.app.${FIXTURE_A.name} not in the tree`); - }, { timeout: 10000, interval: 500, description: "fixture A sidebar tile to appear" }); - } catch (e) { - if (!CI_MODE) { - console.log( - ` SKIP: fixture A (${FIXTURE_A.name}) is not installed in this ` + - `app instance (spec §0.A: skip, not fail, outside --ci)`); - return; - } - throw new Error( - `no dock open and fixture A sidebar tile never appeared — ` + - `integration-test pre-seeds ${FIXTURE_A.name} at boot, so this is ` + - `a real failure: ${e.message}`); - } - const clicked = await app.inspector.send("callMethod", { - objectId: tile.id, method: "clicked", - }); - if (clicked.error) { - throw new Error(`clicking sidebar.app.${FIXTURE_A.name} failed: ${clicked.error}`); - } - } - await app.waitFor(async () => { - const count = await app.inspector.send("evaluate", { - objectId: workspace.id, expression: "dockCount", - }); - if (count.error) throw new Error(`evaluate(dockCount) failed: ${count.error}`); - if (count.result !== 1) { - throw new Error(`WorkspaceArea.dockCount=${count.result} (expected 1)`); - } - const visibleApp = await app.inspector.send("evaluate", { - objectId: welcome.id, expression: "backend.currentVisibleApp", - }); - if (visibleApp.error) { - throw new Error(`evaluate(backend.currentVisibleApp) failed: ${visibleApp.error}`); - } - if (visibleApp.result !== FIXTURE_A.name) { - throw new Error( - `backend.currentVisibleApp=${JSON.stringify(visibleApp.result)} ` + - `(expected "${FIXTURE_A.name}")`); - } - }, { timeout: 10000, interval: 500, - description: `fixture A dock to be open and front-most` }); - - // Close the dock. - const closed = await app.inspector.send("evaluate", { - objectId: workspace.id, - expression: `closeDock(${JSON.stringify(FIXTURE_A.name)})`, - }); - if (closed.error) throw new Error(`evaluate(closeDock) failed: ${closed.error}`); - - // Gate: dock count reaches 0 within 5 s. - await app.waitFor(async () => { - const res = await app.inspector.send("evaluate", { - objectId: workspace.id, expression: "dockCount", - }); - if (res.error) throw new Error(`evaluate(dockCount) failed: ${res.error}`); - if (res.result !== 0) { - throw new Error(`WorkspaceArea.dockCount=${res.result} (expected 0)`); - } - }, { timeout: 5000, interval: 250, description: "workspace dockCount to reach 0" }); - - // Gate: the welcome page is visible again… - await app.waitFor(async () => { - if ((await welcomePageHidden(app, welcome.id)) !== false) { - throw new Error("welcome page is still hidden after closing the last dock"); - } - }, { timeout: 5000, interval: 250, description: "welcome page to reappear" }); - - // …with the installed-apps greeting — closing unloads fixture A but does - // not uninstall it, so launcherApps stays non-empty and the greeting is - // "Welcome back", not the first-launch text. - await app.waitFor( - async () => { await app.expectTexts(["Welcome back"]); }, - { timeout: 5000, interval: 250, description: '"Welcome back" greeting to render' } - ); - - // Gate: the backend no longer reports a front-most app. - await app.waitFor(async () => { - const res = await app.inspector.send("evaluate", { - objectId: welcome.id, expression: "backend.currentVisibleApp", - }); - if (res.error) { - throw new Error(`evaluate(backend.currentVisibleApp) failed: ${res.error}`); - } - if (res.result !== "") { - throw new Error( - `backend.currentVisibleApp=${JSON.stringify(res.result)} (expected "")`); - } - }, { timeout: 5000, interval: 250, description: "currentVisibleApp to clear" }); -}); - // --- Workspace (A5) — re-clicking an open app does not create a second dock --- // // Spec §2.A A5: click sidebar.app.test_qml_only twice, 500 ms apart. The