This is an automated email from the ASF dual-hosted git repository.

rahulvats pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new 728079af9b0 Add E2E test to verify XComs page displays XCom entries 
correctly #59358 (#60620)
728079af9b0 is described below

commit 728079af9b0e0fe60b44b27e34debfce10e249c6
Author: Haseeb Malik <[email protected]>
AuthorDate: Tue Jan 20 00:59:40 2026 -0500

    Add E2E test to verify XComs page displays XCom entries correctly #59358 
(#60620)
---
 airflow-core/src/airflow/ui/playwright.config.ts   |   3 +
 .../ui/src/components/ExpandCollapseButtons.tsx    |   2 +
 .../ui/src/components/FilterBar/FilterBar.tsx      |   1 +
 .../src/airflow/ui/tests/e2e/pages/XComsPage.ts    | 222 +++++++++++++++++++++
 .../ui/tests/e2e/specs/dag-audit-log.spec.ts       |  14 +-
 .../src/airflow/ui/tests/e2e/specs/xcoms.spec.ts   | 104 ++++++++++
 6 files changed, 344 insertions(+), 2 deletions(-)

diff --git a/airflow-core/src/airflow/ui/playwright.config.ts 
b/airflow-core/src/airflow/ui/playwright.config.ts
index bb103648b45..757b30a5b48 100644
--- a/airflow-core/src/airflow/ui/playwright.config.ts
+++ b/airflow-core/src/airflow/ui/playwright.config.ts
@@ -28,6 +28,9 @@ export const testConfig = {
   testDag: {
     id: process.env.TEST_DAG_ID ?? "example_bash_operator",
   },
+  xcomDag: {
+    id: process.env.TEST_XCOM_DAG_ID ?? "example_xcom",
+  },
 };
 
 const currentFilename = fileURLToPath(import.meta.url);
diff --git 
a/airflow-core/src/airflow/ui/src/components/ExpandCollapseButtons.tsx 
b/airflow-core/src/airflow/ui/src/components/ExpandCollapseButtons.tsx
index fac305a7624..4d281b6e429 100644
--- a/airflow-core/src/airflow/ui/src/components/ExpandCollapseButtons.tsx
+++ b/airflow-core/src/airflow/ui/src/components/ExpandCollapseButtons.tsx
@@ -40,6 +40,7 @@ export const ExpandCollapseButtons = ({
   <ButtonGroup attached size="sm" variant="surface" {...rest}>
     <IconButton
       aria-label={expandLabel}
+      data-testid="expand-all-button"
       disabled={isExpandDisabled}
       onClick={onExpand}
       size="sm"
@@ -49,6 +50,7 @@ export const ExpandCollapseButtons = ({
     </IconButton>
     <IconButton
       aria-label={collapseLabel}
+      data-testid="collapse-all-button"
       disabled={isCollapseDisabled}
       onClick={onCollapse}
       size="sm"
diff --git a/airflow-core/src/airflow/ui/src/components/FilterBar/FilterBar.tsx 
b/airflow-core/src/airflow/ui/src/components/FilterBar/FilterBar.tsx
index 2c830e72f51..5423d5ae270 100644
--- a/airflow-core/src/airflow/ui/src/components/FilterBar/FilterBar.tsx
+++ b/airflow-core/src/airflow/ui/src/components/FilterBar/FilterBar.tsx
@@ -152,6 +152,7 @@ export const FilterBar = ({
               _hover={{ bg: "colorPalette.subtle" }}
               bg="gray.muted"
               borderRadius="full"
+              data-testid="add-filter-button"
               variant="outline"
             >
               <MdAdd />
diff --git a/airflow-core/src/airflow/ui/tests/e2e/pages/XComsPage.ts 
b/airflow-core/src/airflow/ui/tests/e2e/pages/XComsPage.ts
new file mode 100644
index 00000000000..ac1cf3e44e9
--- /dev/null
+++ b/airflow-core/src/airflow/ui/tests/e2e/pages/XComsPage.ts
@@ -0,0 +1,222 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { expect, type Locator, type Page } from "@playwright/test";
+import { BasePage } from "tests/e2e/pages/BasePage";
+
+export class XComsPage extends BasePage {
+  public static get xcomsUrl(): string {
+    return "/xcoms";
+  }
+
+  public readonly addFilterButton: Locator;
+  public readonly collapseAllButton: Locator;
+  public readonly expandAllButton: Locator;
+  public readonly paginationNextButton: Locator;
+  public readonly paginationPrevButton: Locator;
+  public readonly xcomsTable: Locator;
+
+  public constructor(page: Page) {
+    super(page);
+    this.addFilterButton = page.locator('[data-testid="add-filter-button"]');
+    this.collapseAllButton = 
page.locator('[data-testid="collapse-all-button"]');
+    this.expandAllButton = page.locator('[data-testid="expand-all-button"]');
+    this.paginationNextButton = page.locator('[data-testid="next"]');
+    this.paginationPrevButton = page.locator('[data-testid="prev"]');
+    this.xcomsTable = page.locator('[data-testid="table-list"]');
+  }
+
+  public async applyFilter(filterName: string, value: string): Promise<void> {
+    await this.addFilterButton.click();
+
+    const filterMenu = this.page.locator('[role="menu"]');
+
+    await filterMenu.waitFor({ state: "visible", timeout: 5000 });
+
+    const filterOption = filterMenu.locator('[role="menuitem"]').filter({ 
hasText: filterName });
+
+    await filterOption.click();
+
+    await expect(filterMenu).toHaveAttribute("data-state", "closed", { 
timeout: 10_000 });
+
+    const filterPill = this.page
+      .locator("div")
+      .filter({ hasText: `${filterName}:` })
+      .first();
+    const filterInput = filterPill.locator("input");
+
+    await filterInput.waitFor({ state: "visible", timeout: 5000 });
+    await filterInput.fill(value);
+    await filterInput.press("Enter");
+    await this.page.waitForLoadState("networkidle");
+  }
+
+  public async navigate(): Promise<void> {
+    await this.navigateTo(XComsPage.xcomsUrl);
+    await this.page.waitForURL(/.*xcoms/, { timeout: 15_000 });
+    await this.xcomsTable.waitFor({ state: "visible", timeout: 10_000 });
+    await this.page.waitForLoadState("networkidle");
+  }
+
+  public async verifyDagDisplayNameFiltering(dagDisplayNamePattern: string): 
Promise<void> {
+    await this.navigate();
+    await this.applyFilter("DAG ID", dagDisplayNamePattern);
+
+    await expect(async () => {
+      const firstLink = this.xcomsTable.locator("tbody 
tr").first().locator("a[href*='/dags/']").first();
+
+      await expect(firstLink).toContainText(dagDisplayNamePattern, { 
ignoreCase: true });
+    }).toPass({ timeout: 30_000 });
+
+    const rows = this.xcomsTable.locator("tbody tr");
+    const rowCount = await rows.count();
+
+    expect(rowCount).toBeGreaterThan(0);
+
+    for (let i = 0; i < Math.min(rowCount, 3); i++) {
+      const dagIdLink = rows.nth(i).locator("a[href*='/dags/']").first();
+
+      await expect(dagIdLink).toContainText(dagDisplayNamePattern, { 
ignoreCase: true });
+    }
+  }
+
+  public async verifyExpandCollapse(): Promise<void> {
+    await this.navigate();
+
+    await expect(this.expandAllButton.first()).toBeVisible({ timeout: 5000 });
+    await this.expandAllButton.first().click();
+    await this.page.waitForLoadState("networkidle");
+
+    await expect(this.collapseAllButton.first()).toBeVisible({ timeout: 5000 
});
+    await this.collapseAllButton.first().click();
+    await this.page.waitForLoadState("networkidle");
+  }
+
+  public async verifyKeyPatternFiltering(keyPattern: string): Promise<void> {
+    await this.navigate();
+    await this.applyFilter("Key", keyPattern);
+
+    await expect(async () => {
+      const firstKeyCell = this.xcomsTable.locator("tbody 
tr").first().locator("td").first();
+
+      await expect(firstKeyCell).toContainText(keyPattern, { ignoreCase: true 
});
+    }).toPass({ timeout: 30_000 });
+
+    const rows = this.xcomsTable.locator("tbody tr");
+    const rowCount = await rows.count();
+
+    expect(rowCount).toBeGreaterThan(0);
+
+    for (let i = 0; i < Math.min(rowCount, 3); i++) {
+      const keyCell = rows.nth(i).locator("td").first();
+
+      await expect(keyCell).toContainText(keyPattern, { ignoreCase: true });
+    }
+  }
+
+  public async verifyPagination(limit: number): Promise<void> {
+    await this.navigateTo(`${XComsPage.xcomsUrl}?offset=0&limit=${limit}`);
+    await this.page.waitForURL(/.*offset=0.*limit=/, { timeout: 10_000 });
+    await this.page.waitForLoadState("networkidle");
+    await this.xcomsTable.waitFor({ state: "visible", timeout: 10_000 });
+
+    const rows = this.xcomsTable.locator("tbody tr");
+
+    expect(await rows.count()).toBeGreaterThan(0);
+
+    await expect(this.paginationNextButton).toBeVisible({ timeout: 10_000 });
+    await this.paginationNextButton.click();
+    await this.page.waitForLoadState("networkidle");
+    await this.xcomsTable.waitFor({ state: "visible", timeout: 10_000 });
+
+    const urlPage2 = this.page.url();
+
+    expect(urlPage2).toContain(`limit=${limit}`);
+    expect(urlPage2).not.toContain("offset=0&");
+
+    const rows2 = this.xcomsTable.locator("tbody tr");
+
+    expect(await rows2.count()).toBeGreaterThan(0);
+
+    await expect(this.paginationPrevButton).toBeVisible({ timeout: 5000 });
+    await this.paginationPrevButton.click();
+    await this.page.waitForLoadState("networkidle");
+    await this.xcomsTable.waitFor({ state: "visible", timeout: 10_000 });
+
+    const urlBackToPage1 = this.page.url();
+
+    expect(urlBackToPage1).toContain(`limit=${limit}`);
+    const isPage1 = urlBackToPage1.includes("offset=0") || 
!urlBackToPage1.includes("offset=");
+
+    expect(isPage1).toBeTruthy();
+  }
+  public async verifyXComDetailsDisplay(): Promise<void> {
+    const firstRow = this.xcomsTable.locator("tbody tr").first();
+
+    await expect(firstRow).toBeVisible({ timeout: 10_000 });
+
+    const keyCell = firstRow.locator("td").first();
+
+    await expect(async () => {
+      await expect(keyCell).toBeVisible();
+      const text = await keyCell.textContent();
+
+      expect(text?.trim()).toBeTruthy();
+    }).toPass({ timeout: 10_000 });
+
+    const dagIdLink = firstRow.locator("a[href*='/dags/']").first();
+
+    await expect(dagIdLink).toBeVisible();
+    await expect(dagIdLink).not.toBeEmpty();
+
+    const runIdLink = firstRow.locator("a[href*='/runs/']").first();
+
+    await expect(runIdLink).toBeVisible();
+    await expect(runIdLink).not.toBeEmpty();
+
+    const taskIdLink = firstRow.locator("a[href*='/tasks/']").first();
+
+    await expect(taskIdLink).toBeVisible();
+    await expect(taskIdLink).not.toBeEmpty();
+  }
+
+  public async verifyXComsExist(): Promise<void> {
+    const dataLinks = this.xcomsTable.locator("a[href*='/dags/']");
+
+    await expect(dataLinks.first()).toBeVisible({ timeout: 30_000 });
+    expect(await dataLinks.count()).toBeGreaterThan(0);
+  }
+
+  public async verifyXComValuesDisplayed(): Promise<void> {
+    const firstRow = this.xcomsTable.locator("tbody tr").first();
+
+    await expect(firstRow).toBeVisible({ timeout: 10_000 });
+
+    const valueCell = firstRow.locator("td").last();
+
+    await expect(valueCell).toBeVisible();
+
+    await expect(async () => {
+      const textContent = await valueCell.textContent();
+      const hasTextContent = (textContent?.trim().length ?? 0) > 0;
+      const hasWidgetContent = (await valueCell.locator("button, pre, 
code").count()) > 0;
+
+      expect(hasTextContent || hasWidgetContent).toBeTruthy();
+    }).toPass({ timeout: 10_000 });
+  }
+}
diff --git a/airflow-core/src/airflow/ui/tests/e2e/specs/dag-audit-log.spec.ts 
b/airflow-core/src/airflow/ui/tests/e2e/specs/dag-audit-log.spec.ts
index 4fa6e009341..6ef1a05fae5 100644
--- a/airflow-core/src/airflow/ui/tests/e2e/specs/dag-audit-log.spec.ts
+++ b/airflow-core/src/airflow/ui/tests/e2e/specs/dag-audit-log.spec.ts
@@ -154,8 +154,18 @@ test.describe("DAG Audit Log", () => {
 
     const sortedEvents = await eventsPage.getEventTypes(true);
 
-    const expectedSorted = [...initialEvents].sort();
+    const isSorted = sortedEvents.every((event, index) => {
+      if (index === 0) {
+        return true;
+      }
+      const previousEvent = sortedEvents[index - 1];
 
-    expect(sortedEvents).toEqual(expectedSorted);
+      return previousEvent !== undefined && event >= previousEvent;
+    });
+
+    expect(isSorted).toBe(true);
+
+    expect(sortedEvents.length).toBe(initialEvents.length);
+    expect(sortedEvents.sort()).toEqual(initialEvents.sort());
   });
 });
diff --git a/airflow-core/src/airflow/ui/tests/e2e/specs/xcoms.spec.ts 
b/airflow-core/src/airflow/ui/tests/e2e/specs/xcoms.spec.ts
new file mode 100644
index 00000000000..dcbe8a43835
--- /dev/null
+++ b/airflow-core/src/airflow/ui/tests/e2e/specs/xcoms.spec.ts
@@ -0,0 +1,104 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { expect, test } from "@playwright/test";
+import { AUTH_FILE, testConfig } from "playwright.config";
+import { DagsPage } from "tests/e2e/pages/DagsPage";
+import { XComsPage } from "tests/e2e/pages/XComsPage";
+
+test.describe("XComs Page", () => {
+  test.setTimeout(60_000);
+
+  let xcomsPage: XComsPage;
+  const testDagId = testConfig.xcomDag.id;
+  const testXComKey = "return_value";
+  const paginationLimit = 3;
+  const triggerCount = 2;
+
+  test.beforeAll(async ({ browser }) => {
+    test.setTimeout(3 * 60 * 1000);
+    const context = await browser.newContext({ storageState: AUTH_FILE });
+    const page = await context.newPage();
+    const setupDagsPage = new DagsPage(page);
+    const setupXComsPage = new XComsPage(page);
+
+    for (let i = 0; i < triggerCount; i++) {
+      const dagRunId = await setupDagsPage.triggerDag(testDagId);
+
+      await setupDagsPage.verifyDagRunStatus(testDagId, dagRunId);
+    }
+
+    await setupXComsPage.navigate();
+    await page.waitForFunction(
+      (minCount) => {
+        const table = document.querySelector('[data-testid="table-list"]');
+
+        if (!table) {
+          return false;
+        }
+        const rows = table.querySelectorAll("tbody tr");
+
+        return rows.length >= minCount;
+      },
+      triggerCount,
+      { timeout: 120_000 },
+    );
+
+    await context.close();
+  });
+
+  test.beforeEach(({ page }) => {
+    xcomsPage = new XComsPage(page);
+  });
+
+  test("verify XComs table renders", async () => {
+    await xcomsPage.navigate();
+    await expect(xcomsPage.xcomsTable).toBeVisible();
+  });
+
+  test("verify XComs table displays data", async () => {
+    await xcomsPage.navigate();
+    await xcomsPage.verifyXComsExist();
+  });
+
+  test("verify XCom details display correctly", async () => {
+    await xcomsPage.navigate();
+    await xcomsPage.verifyXComDetailsDisplay();
+  });
+
+  test("verify XCom values can be viewed", async () => {
+    await xcomsPage.navigate();
+    await xcomsPage.verifyXComValuesDisplayed();
+  });
+
+  test("verify expand/collapse functionality", async () => {
+    await xcomsPage.verifyExpandCollapse();
+  });
+
+  test("verify filtering by key pattern", async () => {
+    await xcomsPage.verifyKeyPatternFiltering(testXComKey);
+  });
+
+  test("verify filtering by DAG display name", async () => {
+    await xcomsPage.verifyDagDisplayNameFiltering(testDagId);
+  });
+
+  test("verify pagination works", async () => {
+    await xcomsPage.verifyPagination(paginationLimit);
+  });
+});

Reply via email to