Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions airflow-core/src/airflow/ui/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -49,6 +50,7 @@ export const ExpandCollapseButtons = ({
</IconButton>
<IconButton
aria-label={collapseLabel}
data-testid="collapse-all-button"
disabled={isCollapseDisabled}
onClick={onCollapse}
size="sm"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,7 @@ export const FilterBar = ({
_hover={{ bg: "colorPalette.subtle" }}
bg="gray.muted"
borderRadius="full"
data-testid="add-filter-button"
variant="outline"
>
<MdAdd />
Expand Down
222 changes: 222 additions & 0 deletions airflow-core/src/airflow/ui/tests/e2e/pages/XComsPage.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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());
});
});
104 changes: 104 additions & 0 deletions airflow-core/src/airflow/ui/tests/e2e/specs/xcoms.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading