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
+ collapsed
+ 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();
+});
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