|
14 | 14 | import { fileURLToPath } from "node:url"; |
15 | 15 | import { dirname, resolve } from "node:path"; |
16 | 16 | import { writeSync } from "node:fs"; |
17 | | -import { findByObjectName, makeTest } from "./fixtures/harness.mjs"; |
| 17 | +import { findByObjectName, makeTest, sleep } from "./fixtures/harness.mjs"; |
18 | 18 | import { FIXTURE_A } from "./fixtures/lgx.mjs"; |
19 | 19 |
|
20 | 20 | const __dirname = dirname(fileURLToPath(import.meta.url)); |
@@ -383,6 +383,185 @@ test("workspace: closing the last dock brings the welcome page back", async (app |
383 | 383 | }, { timeout: 5000, interval: 250, description: "currentVisibleApp to clear" }); |
384 | 384 | }); |
385 | 385 |
|
| 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 | + |
386 | 565 | // --- Package Manager --- |
387 | 566 | // |
388 | 567 | // PMUI is no longer launched from the sidebar app launcher (filtered out |
|
0 commit comments