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 8f3ae04f89e feat: Add E2E tests for Task Instances page (#60514)
8f3ae04f89e is described below

commit 8f3ae04f89e4679c02b4a2500557cf46863af854
Author: junis <[email protected]>
AuthorDate: Thu Feb 5 17:24:15 2026 +0800

    feat: Add E2E tests for Task Instances page (#60514)
    
    * feat: Add E2E tests for Task Instances page
    
    Co-authored-by: Rahul Vats <[email protected]>
---
 .../ui/tests/e2e/pages/TaskInstancesPage.ts        | 200 +++++++++++++++++++++
 .../ui/tests/e2e/specs/task-instances.spec.ts      | 163 +++++++++++++++++
 2 files changed, 363 insertions(+)

diff --git a/airflow-core/src/airflow/ui/tests/e2e/pages/TaskInstancesPage.ts 
b/airflow-core/src/airflow/ui/tests/e2e/pages/TaskInstancesPage.ts
new file mode 100644
index 00000000000..9c560888ec1
--- /dev/null
+++ b/airflow-core/src/airflow/ui/tests/e2e/pages/TaskInstancesPage.ts
@@ -0,0 +1,200 @@
+/*!
+ * 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 TaskInstancesPage extends BasePage {
+  public static get taskInstancesUrl(): string {
+    return "/task_instances";
+  }
+
+  public readonly paginationNextButton: Locator;
+
+  public readonly paginationPrevButton: Locator;
+
+  public readonly taskInstancesTable: Locator;
+
+  public constructor(page: Page) {
+    super(page);
+    this.paginationNextButton = page.locator('[data-testid="next"]');
+    this.paginationPrevButton = page.locator('[data-testid="prev"]');
+    this.taskInstancesTable = page.locator('table, div[role="table"]');
+  }
+
+  /**
+   * Click next page button
+   */
+  public async clickNextPage(): Promise<void> {
+    const initialTaskInstanceIds = await this.getTaskInstanceIds();
+
+    await this.paginationNextButton.click();
+
+    await expect
+      .poll(() => this.getTaskInstanceIds(), { timeout: 10_000 })
+      .not.toEqual(initialTaskInstanceIds);
+
+    await this.waitForTaskInstanceList();
+  }
+
+  /**
+   * Click previous page button
+   */
+  public async clickPrevPage(): Promise<void> {
+    const initialTaskInstanceIds = await this.getTaskInstanceIds();
+
+    await this.paginationPrevButton.click();
+
+    await expect
+      .poll(() => this.getTaskInstanceIds(), { timeout: 10_000 })
+      .not.toEqual(initialTaskInstanceIds);
+    await this.waitForTaskInstanceList();
+  }
+
+  /**
+   * Get all task instance identifiers from the current page
+   */
+  public async getTaskInstanceIds(): Promise<Array<string>> {
+    await this.waitForTaskInstanceList();
+    const taskLinks = this.taskInstancesTable.locator("a[href*='/dags/']");
+    const texts = await taskLinks.allTextContents();
+
+    return texts.map((text) => text.trim()).filter((text) => text !== "");
+  }
+
+  /**
+   * Navigate to Task Instances page and wait for data to load
+   */
+  public async navigate(): Promise<void> {
+    await this.navigateTo(TaskInstancesPage.taskInstancesUrl);
+    await this.page.waitForURL(/.*task_instances/, { timeout: 15_000 });
+    await this.taskInstancesTable.waitFor({ state: "visible", timeout: 10_000 
});
+
+    const dataLink = 
this.taskInstancesTable.locator("a[href*='/dags/']").first();
+    const noDataMessage = this.page.locator('text="No Task Instances found"');
+
+    await expect(dataLink.or(noDataMessage)).toBeVisible({ timeout: 30_000 });
+  }
+
+  /**
+   * Verify state filtering via URL parameters
+   */
+  public async verifyStateFiltering(expectedState: string): Promise<void> {
+    await 
this.navigateTo(`${TaskInstancesPage.taskInstancesUrl}?task_state=${expectedState.toLowerCase()}`);
+    await this.page.waitForURL(/.*task_state=.*/, { timeout: 15_000 });
+    await this.page.waitForLoadState("networkidle");
+
+    const dataLink = 
this.taskInstancesTable.locator("a[href*='/dags/']").first();
+
+    await expect(dataLink).toBeVisible({ timeout: 30_000 });
+    await expect(this.taskInstancesTable).toBeVisible();
+
+    const rowsAfterFilter = this.taskInstancesTable.locator(
+      'tbody tr:not(.no-data), div[role="row"]:not(:first-child)',
+    );
+    const noDataMessage = this.page.locator("text=/No.*found/i, 
text=/No.*results/i, text=/Empty/i");
+    const stateBadges = this.taskInstancesTable.locator('[class*="badge"], 
[class*="Badge"]');
+
+    await expect(stateBadges.first().or(noDataMessage.first())).toBeVisible({ 
timeout: 30_000 });
+
+    const countAfter = await rowsAfterFilter.count();
+
+    expect(
+      countAfter,
+      `Expected task instances with state "${expectedState}" but found none`,
+    ).toBeGreaterThan(0);
+
+    const badgeCount = await stateBadges.count();
+
+    expect(badgeCount).toBeGreaterThan(0);
+
+    for (let i = 0; i < Math.min(badgeCount, 20); i++) {
+      const badge = stateBadges.nth(i);
+      const badgeText = (await badge.textContent())?.trim().toLowerCase();
+
+      expect(badgeText).toContain(expectedState.toLowerCase());
+    }
+  }
+
+  /**
+   * Verify that task instance details are displayed correctly
+   */
+  public async verifyTaskDetailsDisplay(): Promise<void> {
+    const firstRow = this.taskInstancesTable.locator("tbody tr, 
div[role='row']:not(:first-child)").first();
+
+    const dagIdLink = firstRow.locator("a[href*='/dags/']").first();
+
+    if ((await dagIdLink.count()) > 0) {
+      await expect(dagIdLink).toBeVisible();
+      expect((await dagIdLink.textContent())?.trim()).toBeTruthy();
+    }
+
+    const allCells = firstRow.locator('td, div[role="cell"]');
+    const cellCount = await allCells.count();
+
+    expect(cellCount).toBeGreaterThan(1);
+
+    const runIdLink = firstRow.locator("a[href*='/runs/']").first();
+
+    if ((await runIdLink.count()) > 0) {
+      await expect(runIdLink).toBeVisible();
+      expect((await runIdLink.textContent())?.trim()).toBeTruthy();
+    }
+
+    const stateBadge = firstRow.locator('[class*="badge"], [class*="Badge"], 
[class*="status"]');
+
+    const hasStateBadge = (await stateBadge.count()) > 0;
+
+    if (hasStateBadge) {
+      await expect(stateBadge.first()).toBeVisible();
+    } else {
+      const allCellsForState = firstRow.locator('td, div[role="cell"]');
+
+      expect(await allCellsForState.count()).toBeGreaterThan(2);
+    }
+
+    const timeElements = firstRow.locator("time");
+
+    if ((await timeElements.count()) > 0) {
+      await expect(timeElements.first()).toBeVisible();
+    } else {
+      const dateCells = firstRow.locator('td, div[role="cell"]');
+      const dateCellCount = await dateCells.count();
+
+      expect(dateCellCount).toBeGreaterThan(3);
+    }
+  }
+
+  /**
+   * Verify that task instances exist in the table
+   */
+  public async verifyTaskInstancesExist(): Promise<void> {
+    const rows = this.taskInstancesTable.locator('tbody tr:not(.no-data), 
div[role="row"]:not(:first-child)');
+
+    expect(await rows.count()).toBeGreaterThan(0);
+  }
+
+  /**
+   * Wait for task instance list to be rendered
+   */
+  private async waitForTaskInstanceList(): Promise<void> {
+    const dataLink = 
this.taskInstancesTable.locator("a[href*='/dags/']").first();
+
+    await expect(dataLink).toBeVisible({ timeout: 10_000 });
+  }
+}
diff --git a/airflow-core/src/airflow/ui/tests/e2e/specs/task-instances.spec.ts 
b/airflow-core/src/airflow/ui/tests/e2e/specs/task-instances.spec.ts
new file mode 100644
index 00000000000..12c05aba057
--- /dev/null
+++ b/airflow-core/src/airflow/ui/tests/e2e/specs/task-instances.spec.ts
@@ -0,0 +1,163 @@
+/*!
+ * 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 { test, expect } from "@playwright/test";
+import { AUTH_FILE, testConfig } from "playwright.config";
+import { TaskInstancesPage } from "tests/e2e/pages/TaskInstancesPage";
+
+test.describe("Task Instances Page", () => {
+  test.setTimeout(60_000);
+
+  let taskInstancesPage: TaskInstancesPage;
+  const testDagId = testConfig.testDag.id;
+
+  test.beforeAll(async ({ browser }) => {
+    const context = await browser.newContext({ storageState: AUTH_FILE });
+    const page = await context.newPage();
+    const baseUrl = process.env.AIRFLOW_UI_BASE_URL ?? "http://localhost:8080";;
+    const timestamp = Date.now();
+
+    // Create first DAG run for success state
+    const runId1 = `test_ti_success_${timestamp}`;
+    const logicalDate1 = new Date(timestamp).toISOString();
+    const triggerResponse1 = await 
page.request.post(`${baseUrl}/api/v2/dags/${testDagId}/dagRuns`, {
+      data: JSON.stringify({
+        dag_run_id: runId1,
+        logical_date: logicalDate1,
+      }),
+      headers: {
+        "Content-Type": "application/json",
+      },
+    });
+
+    expect(triggerResponse1.ok()).toBeTruthy();
+
+    // Get all task instances for the first run
+    const tasksResponse1 = await page.request.get(
+      `${baseUrl}/api/v2/dags/${testDagId}/dagRuns/${runId1}/taskInstances`,
+    );
+
+    expect(tasksResponse1.ok()).toBeTruthy();
+
+    const tasksData1 = (await tasksResponse1.json()) as {
+      task_instances: Array<{ task_id: string }>;
+    };
+
+    // Mark all tasks as success
+    for (const task of tasksData1.task_instances) {
+      const patchResponse = await page.request.patch(
+        
`${baseUrl}/api/v2/dags/${testDagId}/dagRuns/${runId1}/taskInstances/${task.task_id}`,
+        {
+          data: JSON.stringify({ new_state: "success" }),
+          headers: { "Content-Type": "application/json" },
+        },
+      );
+
+      expect(patchResponse.ok()).toBeTruthy();
+    }
+
+    // Create second DAG run for failed state
+    const runId2 = `test_ti_failed_${timestamp}`;
+    const logicalDate2 = new Date(timestamp + 60_000).toISOString();
+    const triggerResponse2 = await 
page.request.post(`${baseUrl}/api/v2/dags/${testDagId}/dagRuns`, {
+      data: JSON.stringify({
+        dag_run_id: runId2,
+        logical_date: logicalDate2,
+      }),
+      headers: {
+        "Content-Type": "application/json",
+      },
+    });
+
+    expect(triggerResponse2.ok()).toBeTruthy();
+
+    // Get all task instances for the second run
+    const tasksResponse2 = await page.request.get(
+      `${baseUrl}/api/v2/dags/${testDagId}/dagRuns/${runId2}/taskInstances`,
+    );
+
+    expect(tasksResponse2.ok()).toBeTruthy();
+
+    const tasksData2 = (await tasksResponse2.json()) as {
+      task_instances: Array<{ task_id: string }>;
+    };
+
+    // Mark all tasks as failed
+    for (const task of tasksData2.task_instances) {
+      const patchResponse = await page.request.patch(
+        
`${baseUrl}/api/v2/dags/${testDagId}/dagRuns/${runId2}/taskInstances/${task.task_id}`,
+        {
+          data: JSON.stringify({ new_state: "failed" }),
+          headers: { "Content-Type": "application/json" },
+        },
+      );
+
+      expect(patchResponse.ok()).toBeTruthy();
+    }
+
+    await context.close();
+  });
+
+  test.beforeEach(({ page }) => {
+    taskInstancesPage = new TaskInstancesPage(page);
+  });
+
+  test("verify task instances table displays data", async () => {
+    await taskInstancesPage.navigate();
+    await taskInstancesPage.verifyTaskInstancesExist();
+  });
+
+  test("verify task details display correctly", async () => {
+    await taskInstancesPage.navigate();
+    await taskInstancesPage.verifyTaskDetailsDisplay();
+  });
+
+  test("verify filtering by failed state", async () => {
+    await taskInstancesPage.navigate();
+    await taskInstancesPage.verifyStateFiltering("Failed");
+  });
+
+  test("verify filtering by success state", async () => {
+    await taskInstancesPage.navigate();
+    await taskInstancesPage.verifyStateFiltering("Success");
+  });
+
+  test("verify pagination with offset and limit", async () => {
+    await taskInstancesPage.navigate();
+
+    await expect(taskInstancesPage.paginationNextButton).toBeVisible();
+    await expect(taskInstancesPage.paginationPrevButton).toBeVisible();
+
+    const initialTaskInstanceIds = await 
taskInstancesPage.getTaskInstanceIds();
+
+    expect(initialTaskInstanceIds.length).toBeGreaterThan(0);
+
+    await taskInstancesPage.clickNextPage();
+
+    const taskInstanceIdsAfterNext = await 
taskInstancesPage.getTaskInstanceIds();
+
+    expect(taskInstanceIdsAfterNext.length).toBeGreaterThan(0);
+    expect(taskInstanceIdsAfterNext).not.toEqual(initialTaskInstanceIds);
+
+    await taskInstancesPage.clickPrevPage();
+
+    const taskInstanceIdsAfterPrev = await 
taskInstancesPage.getTaskInstanceIds();
+
+    expect(taskInstanceIdsAfterPrev).toEqual(initialTaskInstanceIds);
+  });
+});

Reply via email to