From 68be649bdaa8aec17489acd550a0cf5cc8da4a30 Mon Sep 17 00:00:00 2001 From: Dave Shoup Date: Fri, 21 Aug 2026 11:33:28 -0400 Subject: [PATCH 1/2] add toBeExpanded() E2E matcher for aria-expanded checks Co-Authored-By: Claude Opus 4.8 --- tests/README.md | 2 +- tests/e2e/baseTest.ts | 6 +- tests/e2e/matchers.ts | 58 +++++++++++++++++++ tests/e2e/objects/views/FlinkDatabaseView.ts | 2 +- tests/e2e/objects/views/ResourcesView.ts | 2 +- tests/e2e/objects/views/SchemasView.ts | 2 +- tests/e2e/objects/views/TopicsView.ts | 2 +- tests/e2e/objects/views/View.ts | 2 +- .../specs/directConnectionLifecycle.spec.ts | 2 +- tests/e2e/specs/flinkArtifact.spec.ts | 2 +- tests/e2e/specs/flinkStatement.spec.ts | 2 +- tests/e2e/specs/produceMessage.spec.ts | 2 +- tests/e2e/specs/scaffold.spec.ts | 4 +- tests/e2e/specs/schemas.spec.ts | 2 +- tests/e2e/utils/connections.ts | 8 +-- tests/e2e/utils/workspace.ts | 2 +- 16 files changed, 80 insertions(+), 20 deletions(-) create mode 100644 tests/e2e/matchers.ts diff --git a/tests/README.md b/tests/README.md index 0e5f8d4c2..5d996518b 100644 --- a/tests/README.md +++ b/tests/README.md @@ -153,7 +153,7 @@ To create a new E2E test: test.beforeEach(async ({ connectionItem }) => { // The connectionItem fixture ensures the connection is set up and expanded in the Resources // view, and handles teardown automatically after the test completes. - await expect(connectionItem.locator).toHaveAttribute("aria-expanded", "true"); + await expect(connectionItem.locator).toBeExpanded(); }); test("should open message viewer from a topic", async ({ page }) => { diff --git a/tests/e2e/baseTest.ts b/tests/e2e/baseTest.ts index df4b12221..b31ee99b9 100644 --- a/tests/e2e/baseTest.ts +++ b/tests/e2e/baseTest.ts @@ -7,6 +7,8 @@ import { tmpdir } from "os"; import path from "path"; import { fileURLToPath } from "url"; import { DEBUG_LOGGING_ENABLED } from "./constants"; +// registers the `toBeExpanded` custom matcher on Playwright's shared `expect` +import "./matchers"; import { NotificationArea } from "./objects/notifications/NotificationArea"; import { Quickpick } from "./objects/quickInputs/Quickpick"; import { FlinkDatabaseView, SelectFlinkDatabase } from "./objects/views/FlinkDatabaseView"; @@ -305,7 +307,7 @@ export const test = testBase.extend({ } // ensure connection has resources available to work with - await expect(connectionItem.locator).toHaveAttribute("aria-expanded", "true"); + await expect(connectionItem.locator).toBeExpanded(); const topicName = e2eResourceName(topicConfig.name); @@ -361,7 +363,7 @@ export const test = testBase.extend({ throw new Error("artifactConfig must be set, like `test.use({ artifactConfig: {} })`"); } - await expect(connectionItem.locator).toHaveAttribute("aria-expanded", "true"); + await expect(connectionItem.locator).toBeExpanded(); const entrypoint = artifactConfig.entrypoint ?? SelectFlinkDatabase.FromDatabaseViewButton; const jarPath = diff --git a/tests/e2e/matchers.ts b/tests/e2e/matchers.ts new file mode 100644 index 000000000..c7739da3c --- /dev/null +++ b/tests/e2e/matchers.ts @@ -0,0 +1,58 @@ +import type { ExpectMatcherState, Locator, MatcherReturnType } from "@playwright/test"; +import { expect } from "@playwright/test"; + +/** Options accepted by {@linkcode toBeExpanded}, mirroring built-in locator assertions. */ +interface ToBeExpandedOptions { + timeout?: number; +} + +/** + * Assert that a tree item or view header is expanded, i.e. carries `aria-expanded="true"`. + * + * Delegates to the auto-retrying `toHaveAttribute` assertion, so it preserves web-first + * auto-waiting and works with `.not`. + */ +async function toBeExpanded( + this: ExpectMatcherState, + locator: Locator, + options?: ToBeExpandedOptions, +): Promise { + const assertionName = "toBeExpanded"; + let pass: boolean; + let matcherResult: { actual?: unknown } | undefined; + try { + // delegate through `.not` when negated so auto-waiting polls in the expected direction + const assertion = this.isNot ? expect(locator).not : expect(locator); + await assertion.toHaveAttribute("aria-expanded", "true", options); + pass = true; + } catch (error: unknown) { + matcherResult = (error as { matcherResult?: { actual?: unknown } }).matcherResult; + pass = false; + } + // `.not` was already applied above; flip back so the returned `pass` reflects the base assertion. + if (this.isNot) { + pass = !pass; + } + + const message = () => { + const hint = this.utils.matcherHint(assertionName, undefined, "", { isNot: this.isNot }); + return ( + `${hint}\n\n` + + `Expected: ${this.isNot ? "not " : ""}aria-expanded="true"\n` + + `Received: aria-expanded=${this.utils.printReceived(matcherResult?.actual)}` + ); + }; + + return { message, pass, name: assertionName, expected: "true", actual: matcherResult?.actual }; +} + +expect.extend({ toBeExpanded }); + +declare global { + namespace PlaywrightTest { + interface Matchers { + /** Assert the locator carries `aria-expanded="true"` (i.e. the tree item/view is expanded). */ + toBeExpanded(options?: ToBeExpandedOptions): R; + } + } +} diff --git a/tests/e2e/objects/views/FlinkDatabaseView.ts b/tests/e2e/objects/views/FlinkDatabaseView.ts index 40acd52ad..c885fdf8c 100644 --- a/tests/e2e/objects/views/FlinkDatabaseView.ts +++ b/tests/e2e/objects/views/FlinkDatabaseView.ts @@ -204,7 +204,7 @@ export class FlinkDatabaseView extends SearchableView { // containers are always Collapsed by default, so we don't need to check for null here if (isExpanded === "false") { await container.click(); - await expect(container).toHaveAttribute("aria-expanded", "true"); + await expect(container).toBeExpanded(); } } diff --git a/tests/e2e/objects/views/ResourcesView.ts b/tests/e2e/objects/views/ResourcesView.ts index d8ccf07e7..36948c10a 100644 --- a/tests/e2e/objects/views/ResourcesView.ts +++ b/tests/e2e/objects/views/ResourcesView.ts @@ -328,7 +328,7 @@ export class ResourcesView extends SearchableView { if ((await environment.getAttribute("aria-expanded")) === "false") { await environment.click(); } - await expect(environment).toHaveAttribute("aria-expanded", "true"); + await expect(environment).toBeExpanded(); } /** diff --git a/tests/e2e/objects/views/SchemasView.ts b/tests/e2e/objects/views/SchemasView.ts index 6152910b6..ead689cc6 100644 --- a/tests/e2e/objects/views/SchemasView.ts +++ b/tests/e2e/objects/views/SchemasView.ts @@ -127,7 +127,7 @@ export class SchemasView extends SearchableView { default: throw new Error(`Unsupported entrypoint: ${entrypoint}`); } - await expect(this.header).toHaveAttribute("aria-expanded", "true"); + await expect(this.header).toBeExpanded(); await expect(this.body).toBeVisible(); } diff --git a/tests/e2e/objects/views/TopicsView.ts b/tests/e2e/objects/views/TopicsView.ts index 431d3d2da..2f7c3c6a9 100644 --- a/tests/e2e/objects/views/TopicsView.ts +++ b/tests/e2e/objects/views/TopicsView.ts @@ -142,7 +142,7 @@ export class TopicsView extends SearchableView { default: throw new Error(`Unsupported entrypoint: ${entrypoint}`); } - await expect(this.header).toHaveAttribute("aria-expanded", "true"); + await expect(this.header).toBeExpanded(); await expect(this.body).toBeVisible(); await expect(this.progressIndicator).toBeHidden(); await this.waitForContainerLoaded(this.topicsContainer); diff --git a/tests/e2e/objects/views/View.ts b/tests/e2e/objects/views/View.ts index 6d2f6f1bc..bb43c89be 100644 --- a/tests/e2e/objects/views/View.ts +++ b/tests/e2e/objects/views/View.ts @@ -87,7 +87,7 @@ export class View { if (isExpanded !== "true") { await this.header.click(); } - await expect(this.header).toHaveAttribute("aria-expanded", "true"); + await expect(this.header).toBeExpanded(); } /** diff --git a/tests/e2e/specs/directConnectionLifecycle.spec.ts b/tests/e2e/specs/directConnectionLifecycle.spec.ts index 3f0d7b4c6..ab1f69570 100644 --- a/tests/e2e/specs/directConnectionLifecycle.spec.ts +++ b/tests/e2e/specs/directConnectionLifecycle.spec.ts @@ -248,7 +248,7 @@ test.describe("Direct Connection CRUD Lifecycle", { tag: [Tag.DirectConnectionCR test.beforeEach(async ({ electronApp, page, connectionItem }) => { // make sure the local item is expanded before we try to copy local resources properties - await expect(connectionItem.locator).toHaveAttribute("aria-expanded", "true"); + await expect(connectionItem.locator).toBeExpanded(); // grant clipboard access to read the copied bootstrap servers and schema registry URL // from the local items' context menu actions diff --git a/tests/e2e/specs/flinkArtifact.spec.ts b/tests/e2e/specs/flinkArtifact.spec.ts index 29c851441..06c337a8d 100644 --- a/tests/e2e/specs/flinkArtifact.spec.ts +++ b/tests/e2e/specs/flinkArtifact.spec.ts @@ -22,7 +22,7 @@ const __dirname = path.dirname(__filename); test.describe("Flink Artifacts", { tag: [Tag.CCloud, Tag.FlinkArtifacts] }, () => { test.use({ connectionType: ConnectionType.Ccloud }); test.beforeEach(async ({ connectionItem }) => { - await expect(connectionItem.locator).toHaveAttribute("aria-expanded", "true"); + await expect(connectionItem.locator).toBeExpanded(); }); test.afterEach(async ({ page }) => { diff --git a/tests/e2e/specs/flinkStatement.spec.ts b/tests/e2e/specs/flinkStatement.spec.ts index 23842f04e..6ecef58e6 100644 --- a/tests/e2e/specs/flinkStatement.spec.ts +++ b/tests/e2e/specs/flinkStatement.spec.ts @@ -21,7 +21,7 @@ test.describe("Flink Statements", { tag: [Tag.CCloud, Tag.FlinkStatements] }, () test.beforeEach(async ({ connectionItem }) => { // ensure connection tree item has resources available to work with - await expect(connectionItem.locator).toHaveAttribute("aria-expanded", "true"); + await expect(connectionItem.locator).toBeExpanded(); webview = undefined; statementName = undefined; }); diff --git a/tests/e2e/specs/produceMessage.spec.ts b/tests/e2e/specs/produceMessage.spec.ts index 3d3047324..b3dcedac2 100644 --- a/tests/e2e/specs/produceMessage.spec.ts +++ b/tests/e2e/specs/produceMessage.spec.ts @@ -72,7 +72,7 @@ test.describe("Produce Message(s) to Topic", { tag: [Tag.ProduceMessageToTopic] test.beforeEach(async ({ page, connectionItem, topic: topicName }) => { // ensure connection tree item has resources available to work with - await expect(connectionItem.locator).toHaveAttribute("aria-expanded", "true"); + await expect(connectionItem.locator).toBeExpanded(); const topicsView = new TopicsView(page); // click a Kafka cluster from the Resources view to open and populate the Topics view diff --git a/tests/e2e/specs/scaffold.spec.ts b/tests/e2e/specs/scaffold.spec.ts index 9baeffbf4..2686c513b 100644 --- a/tests/e2e/specs/scaffold.spec.ts +++ b/tests/e2e/specs/scaffold.spec.ts @@ -62,7 +62,7 @@ test.describe("Project Scaffolding", { tag: [Tag.ProjectScaffolding] }, () => { test.beforeEach(async ({ connectionItem }) => { // ensure connection tree item has resources available to work with - await expect(connectionItem.locator).toHaveAttribute("aria-expanded", "true"); + await expect(connectionItem.locator).toBeExpanded(); }); test(`should apply Flink Table API In Java For Confluent Cloud template from Flink compute pool`, async ({ @@ -163,7 +163,7 @@ test.describe("Project Scaffolding", { tag: [Tag.ProjectScaffolding] }, () => { test.beforeEach(async ({ connectionItem }) => { // ensure connection tree item has resources available to work with - await expect(connectionItem.locator).toHaveAttribute("aria-expanded", "true"); + await expect(connectionItem.locator).toBeExpanded(); }); test( diff --git a/tests/e2e/specs/schemas.spec.ts b/tests/e2e/specs/schemas.spec.ts index 8f9639e6c..b4bd01cef 100644 --- a/tests/e2e/specs/schemas.spec.ts +++ b/tests/e2e/specs/schemas.spec.ts @@ -62,7 +62,7 @@ test.describe("Schema Management", { tag: [Tag.EvolveSchema] }, () => { test.beforeEach(async ({ page, connectionItem }) => { // ensure connection tree item has resources available to work with - await expect(connectionItem.locator).toHaveAttribute("aria-expanded", "true"); + await expect(connectionItem.locator).toBeExpanded(); const schemasView = new SchemasView(page); await schemasView.loadSchemaSubjects( diff --git a/tests/e2e/utils/connections.ts b/tests/e2e/utils/connections.ts index d1a65169f..2f896ca53 100644 --- a/tests/e2e/utils/connections.ts +++ b/tests/e2e/utils/connections.ts @@ -160,7 +160,7 @@ export async function setupCCloudConnection( } await expect(ccloudItem.locator).not.toContainText(NOT_CONNECTED_TEXT); - await expect(ccloudItem.locator).toHaveAttribute("aria-expanded", "true"); + await expect(ccloudItem.locator).toBeExpanded(); return ccloudItem; } @@ -229,7 +229,7 @@ export async function setupDirectConnection( if ((await connectionItem.locator.getAttribute("aria-expanded")) === "false") { await connectionItem.locator.click(); } - await expect(connectionItem.locator).toHaveAttribute("aria-expanded", "true"); + await expect(connectionItem.locator).toBeExpanded(); } return connectionItem; @@ -299,7 +299,7 @@ export async function setupLocalKafka(page: Page) { // broker input did not appear within 5s — continue without confirming } - await expect(localItem.locator).toHaveAttribute("aria-expanded", "true"); + await expect(localItem.locator).toBeExpanded(); await expect(resourcesView.localKafkaClusters).not.toHaveCount(0); return localItem; } @@ -317,7 +317,7 @@ export async function setupLocalSchemaRegistry(page: Page) { await containerQuickpick.selectItemByText("Schema Registry"); await containerQuickpick.confirm(); - await expect(localItem.locator).toHaveAttribute("aria-expanded", "true"); + await expect(localItem.locator).toBeExpanded(); // local SR requires local Kafka, so we should always see local Kafka appear await expect(resourcesView.localKafkaClusters).not.toHaveCount(0); await expect(resourcesView.localSchemaRegistries).not.toHaveCount(0, { timeout: 60_000 }); diff --git a/tests/e2e/utils/workspace.ts b/tests/e2e/utils/workspace.ts index 5a83f30c1..b43a2fe24 100644 --- a/tests/e2e/utils/workspace.ts +++ b/tests/e2e/utils/workspace.ts @@ -71,7 +71,7 @@ export async function openConfluentSidebar(page: Page): Promise { const resourcesView = new ResourcesView(page); // the Resources should be visible and expanded by default - await expect(resourcesView.header).toHaveAttribute("aria-expanded", "true"); + await expect(resourcesView.header).toBeExpanded(); // and should show the "Confluent Cloud" placeholder item (not "No resources found") await expect(resourcesView.confluentCloudItem).toBeVisible(); // we don't check for the "Local" item here in the event the Confluent Cloud item has children From 19fc013feccf363fca8581aa2d3de520dcf7baad Mon Sep 17 00:00:00 2001 From: Dave Shoup Date: Fri, 21 Aug 2026 15:48:20 -0400 Subject: [PATCH 2/2] add functional test covering toBeExpanded() matcher Covers both toBeExpanded() and .not.toBeExpanded() across expanded, collapsed, and absent aria-expanded states via the rollwright harness, exercising the negated branch that no converted call site hits. Co-Authored-By: Claude Opus 4.8 --- src/webview/aria-expanded-matcher.spec.ts | 32 +++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 src/webview/aria-expanded-matcher.spec.ts diff --git a/src/webview/aria-expanded-matcher.spec.ts b/src/webview/aria-expanded-matcher.spec.ts new file mode 100644 index 000000000..a855a4d6b --- /dev/null +++ b/src/webview/aria-expanded-matcher.spec.ts @@ -0,0 +1,32 @@ +// Coverage for the shared `toBeExpanded()` matcher defined in tests/e2e/matchers.ts. It lives here, +// not in Electron E2E, because the rollwright functional harness is the only non-Electron way to run +// real-DOM Playwright assertions, keeping the `.not` and absent-attribute edge cases fast in CI. +import { expect } from "@playwright/test"; +// registers the `toBeExpanded` matcher on Playwright's shared `expect` +import "../../tests/e2e/matchers"; +import { test } from "./baseTest"; + +// minimal tree items covering the three aria-expanded states the matcher distinguishes +const TREE_ITEMS = ` +
expanded
+ +
leaf
+`; + +test.use({ coverage: false }); + +test.beforeEach(async ({ page }) => { + await page.setContent(TREE_ITEMS); +}); + +test('toBeExpanded() passes for aria-expanded="true"', async ({ page }) => { + await expect(page.getByTestId("expanded")).toBeExpanded(); +}); + +test('not.toBeExpanded() passes for aria-expanded="false"', async ({ page }) => { + await expect(page.getByTestId("collapsed")).not.toBeExpanded(); +}); + +test("not.toBeExpanded() passes when aria-expanded is absent", async ({ page }) => { + await expect(page.getByTestId("leaf")).not.toBeExpanded(); +});