Skip to content

Commit 00a952d

Browse files
committed
test(ui): add A5 test for re-click of an open app not duplicating dock
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.
1 parent 2adb8e6 commit 00a952d

1 file changed

Lines changed: 180 additions & 1 deletion

File tree

tests/ui-tests.mjs

Lines changed: 180 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
import { fileURLToPath } from "node:url";
1515
import { dirname, resolve } from "node:path";
1616
import { writeSync } from "node:fs";
17-
import { findByObjectName, makeTest } from "./fixtures/harness.mjs";
17+
import { findByObjectName, makeTest, sleep } from "./fixtures/harness.mjs";
1818
import { FIXTURE_A } from "./fixtures/lgx.mjs";
1919

2020
const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -383,6 +383,185 @@ test("workspace: closing the last dock brings the welcome page back", async (app
383383
}, { timeout: 5000, interval: 250, description: "currentVisibleApp to clear" });
384384
});
385385

386+
// --- Workspace (A5) — re-clicking an open app does not create a second dock ---
387+
//
388+
// Spec §2.A A5: click sidebar.app.test_qml_only twice, 500 ms apart. The
389+
// first click opens the dock (A4 left the workspace empty); the second must
390+
// activate the existing dock, not spawn another.
391+
//
392+
// "Exactly one instance of fixture A's root item type in the tree" cannot be
393+
// checked literally on this branch: the fixture's root is a plain Rectangle
394+
// (qmlViewFor in tests/fixtures/lgx.mjs), a type the shell instantiates all
395+
// over. Each instantiation of the fixture's root document lives in exactly
396+
// one host QQuickWidget whose source is <installDir>/Main.qml
397+
// (PluginLoader.cpp:367) and renders exactly one Text with the unique
398+
// payload string — so those two counts stand in for the root-type count.
399+
400+
test("workspace: re-clicking an open app does not create a second dock", async (app) => {
401+
// Same stable evaluate anchor as A3/A4 — has `backend` in context and
402+
// survives sidebar delegate churn.
403+
let welcome = null;
404+
await app.waitFor(async () => {
405+
welcome = await findWelcomePage(app);
406+
if (!welcome) throw new Error("no WelcomePage instance in the QML tree");
407+
}, { timeout: 10000, interval: 500, description: "WelcomePage instance to exist" });
408+
409+
const workspace = await findByObjectName(app.inspector, "workspace");
410+
if (!workspace) {
411+
throw new Error('WorkspaceArea (objectName "workspace") not found');
412+
}
413+
414+
// Click #1 — opens the dock.
415+
let tile = null;
416+
try {
417+
await app.waitFor(async () => {
418+
tile = await findByObjectName(app.inspector, `sidebar.app.${FIXTURE_A.name}`);
419+
if (!tile) throw new Error(`sidebar.app.${FIXTURE_A.name} not in the tree`);
420+
}, { timeout: 10000, interval: 500, description: "fixture A sidebar tile to appear" });
421+
} catch (e) {
422+
if (!CI_MODE) {
423+
console.log(
424+
` SKIP: fixture A (${FIXTURE_A.name}) is not installed in this ` +
425+
`app instance (spec §0.A: skip, not fail, outside --ci)`);
426+
return;
427+
}
428+
throw new Error(
429+
`fixture A sidebar tile never appeared — integration-test pre-seeds ` +
430+
`${FIXTURE_A.name} at boot, so this is a real failure: ${e.message}`);
431+
}
432+
const firstClick = await app.inspector.send("callMethod", {
433+
objectId: tile.id, method: "clicked",
434+
});
435+
if (firstClick.error) {
436+
throw new Error(`clicking sidebar.app.${FIXTURE_A.name} failed: ${firstClick.error}`);
437+
}
438+
439+
// Wait until the app is actually open — the spec's 500 ms spacing assumes
440+
// the first click's dock exists before the re-click; on a slow-loading run
441+
// a blind 500 ms click would test click-while-loading instead.
442+
await app.waitFor(async () => {
443+
const count = await app.inspector.send("evaluate", {
444+
objectId: workspace.id, expression: "dockCount",
445+
});
446+
if (count.error) throw new Error(`evaluate(dockCount) failed: ${count.error}`);
447+
if (count.result !== 1) {
448+
throw new Error(`WorkspaceArea.dockCount=${count.result} (expected 1)`);
449+
}
450+
const visibleApp = await app.inspector.send("evaluate", {
451+
objectId: welcome.id, expression: "backend.currentVisibleApp",
452+
});
453+
if (visibleApp.error) {
454+
throw new Error(`evaluate(backend.currentVisibleApp) failed: ${visibleApp.error}`);
455+
}
456+
if (visibleApp.result !== FIXTURE_A.name) {
457+
throw new Error(
458+
`backend.currentVisibleApp=${JSON.stringify(visibleApp.result)} ` +
459+
`(expected "${FIXTURE_A.name}")`);
460+
}
461+
}, { timeout: 10000, interval: 500,
462+
description: "fixture A dock to open after the first click" });
463+
464+
// Click #2, 500 ms later. Loading moved the delegate from the unloaded to
465+
// the loaded Repeater (same objectName, new object), so re-find inside the
466+
// retry loop — a delegate mid-churn just retries, and a duplicate
467+
// activation click is harmless (activation is what A5 exercises).
468+
await sleep(500);
469+
await app.waitFor(async () => {
470+
const loadedTile =
471+
await findByObjectName(app.inspector, `sidebar.app.${FIXTURE_A.name}`);
472+
if (!loadedTile) throw new Error(`sidebar.app.${FIXTURE_A.name} not in the tree`);
473+
const clicked = await app.inspector.send("callMethod", {
474+
objectId: loadedTile.id, method: "clicked",
475+
});
476+
if (clicked.error) {
477+
throw new Error(`re-clicking sidebar.app.${FIXTURE_A.name} failed: ${clicked.error}`);
478+
}
479+
}, { timeout: 10000, interval: 500, description: "second click on fixture A tile" });
480+
481+
// Gate: dock count STAYS 1 — poll across a settle window rather than one
482+
// instant-passing read, so an asynchronously created second dock (the
483+
// load path defers through singleShot timers) cannot slip in unseen.
484+
const settleDeadline = Date.now() + 2000;
485+
for (;;) {
486+
const count = await app.inspector.send("evaluate", {
487+
objectId: workspace.id, expression: "dockCount",
488+
});
489+
if (count.error) throw new Error(`evaluate(dockCount) failed: ${count.error}`);
490+
if (count.result !== 1) {
491+
throw new Error(
492+
`WorkspaceArea.dockCount=${count.result} after re-click ` +
493+
`(expected it to stay 1)`);
494+
}
495+
if (Date.now() >= settleDeadline) break;
496+
await sleep(250);
497+
}
498+
499+
// Gate: fixture A is still the front-most app.
500+
const visibleApp = await app.inspector.send("evaluate", {
501+
objectId: welcome.id, expression: "backend.currentVisibleApp",
502+
});
503+
if (visibleApp.error) {
504+
throw new Error(`evaluate(backend.currentVisibleApp) failed: ${visibleApp.error}`);
505+
}
506+
if (visibleApp.result !== FIXTURE_A.name) {
507+
throw new Error(
508+
`backend.currentVisibleApp=${JSON.stringify(visibleApp.result)} ` +
509+
`(expected "${FIXTURE_A.name}")`);
510+
}
511+
512+
// Gate: exactly one instantiation of fixture A's root document — one host
513+
// QQuickWidget sourced from the fixture's Main.qml…
514+
const byType = await app.inspector.send("findByType", { typeName: "QQuickWidget" });
515+
if (byType.error) throw new Error(`findByType(QQuickWidget) failed: ${byType.error}`);
516+
const fixtureHosts = [];
517+
for (const m of byType.matches ?? []) {
518+
const props = await app.inspector.send("getProperties", { objectId: m.id });
519+
const source = props.properties?.find((p) => p.name === "source")?.value;
520+
if (typeof source === "string"
521+
&& source.includes(`/${FIXTURE_A.name}/`)
522+
&& source.endsWith("Main.qml")) {
523+
fixtureHosts.push(source);
524+
}
525+
}
526+
if (fixtureHosts.length !== 1) {
527+
throw new Error(
528+
`${fixtureHosts.length} QQuickWidget(s) sourced from fixture A's ` +
529+
`Main.qml (expected exactly 1): ${JSON.stringify(fixtureHosts)}`);
530+
}
531+
532+
// …and exactly one render of its unique payload text.
533+
const textHits = await app.inspector.send("findByProperty", {
534+
property: "text", value: FIXTURE_A_TEXT,
535+
});
536+
if (textHits.error) {
537+
throw new Error(`findByProperty(text=payload) failed: ${textHits.error}`);
538+
}
539+
const payloadCount = (textHits.matches ?? []).length;
540+
if (payloadCount !== 1) {
541+
throw new Error(
542+
`${payloadCount} instance(s) of fixture A's payload text in the tree ` +
543+
`(expected exactly 1)`);
544+
}
545+
546+
// Cleanup: close the dock so the rest of the suite starts from the same
547+
// no-docks baseline A4 established (close also unloads the module —
548+
// same evaluate path as A4; callMethod can't marshal the QString arg).
549+
const closed = await app.inspector.send("evaluate", {
550+
objectId: workspace.id,
551+
expression: `closeDock(${JSON.stringify(FIXTURE_A.name)})`,
552+
});
553+
if (closed.error) throw new Error(`evaluate(closeDock) failed: ${closed.error}`);
554+
await app.waitFor(async () => {
555+
const res = await app.inspector.send("evaluate", {
556+
objectId: workspace.id, expression: "dockCount",
557+
});
558+
if (res.error) throw new Error(`evaluate(dockCount) failed: ${res.error}`);
559+
if (res.result !== 0) {
560+
throw new Error(`WorkspaceArea.dockCount=${res.result} (expected 0)`);
561+
}
562+
}, { timeout: 5000, interval: 250, description: "cleanup: fixture A dock to close" });
563+
});
564+
386565
// --- Package Manager ---
387566
//
388567
// PMUI is no longer launched from the sidebar app launcher (filtered out

0 commit comments

Comments
 (0)