Skip to content
Open
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
308 changes: 307 additions & 1 deletion tests/ui-tests.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down Expand Up @@ -504,6 +504,312 @@ 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) => {
Comment thread
romanzac marked this conversation as resolved.
// 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
// 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 <installDir>/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
Expand Down