Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -89,9 +89,9 @@ export const Calendar = () => {
}

return (
<Box p={6}>
<Box data-testid="dag-calendar-root" p={6}>
<ErrorAlert error={error} />
<HStack justify="space-between" mb={6}>
<HStack data-testid="calendar-header" justify="space-between" mb={6}>
<HStack gap={4} mb={4}>
{granularity === "daily" ? (
<HStack gap={2}>
Expand All @@ -107,6 +107,7 @@ export const Calendar = () => {
_hover={selectedDate.year() === currentDate.year() ? {} : { textDecoration: "underline" }}
color={selectedDate.year() === currentDate.year() ? "fg.info" : "inherit"}
cursor={selectedDate.year() === currentDate.year() ? "default" : "pointer"}
data-testid="calendar-current-period"
fontSize="xl"
fontWeight="bold"
minWidth="120px"
Expand Down Expand Up @@ -154,6 +155,7 @@ export const Calendar = () => {
? "default"
: "pointer"
}
data-testid="calendar-current-period"
fontSize="xl"
fontWeight="bold"
minWidth="120px"
Expand Down Expand Up @@ -207,6 +209,7 @@ export const Calendar = () => {
bg="bg/80"
borderRadius="md"
bottom="0"
data-testid="calendar-loading-overlay"
display="flex"
justifyContent="center"
left="0"
Expand All @@ -232,6 +235,7 @@ export const Calendar = () => {
<>
<DailyCalendarView
data={data?.dag_runs ?? []}
data-testid="calendar-daily-view"
scale={scale}
selectedYear={selectedDate.year()}
viewMode={viewMode}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,9 @@ export const CalendarCell = ({
_hover={hasData ? { transform: "scale(1.1)" } : {}}
borderRadius="2px"
cursor={hasData ? "pointer" : "default"}
data-has-data={hasData ? "true" : "false"}
data-testid="calendar-cell"
data-view-mode={viewMode}
height="14px"
marginRight={computedMarginRight}
overflow="hidden"
Expand Down Expand Up @@ -86,6 +89,9 @@ export const CalendarCell = ({
bg={backgroundColor}
borderRadius="2px"
cursor={hasData ? "pointer" : "default"}
data-has-data={hasData ? "true" : "false"}
data-testid="calendar-cell"
data-view-mode={viewMode}
height="14px"
marginRight={computedMarginRight}
width="14px"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,13 +70,13 @@ export const CalendarTooltip = ({ cellData, viewMode = "total" }: Props) => {
}));

return hasRuns ? (
<VStack align="start" gap={2}>
<VStack align="start" data-testid="calendar-tooltip" data-view-mode={viewMode} gap={2}>
<Text fontSize="sm" fontWeight="medium">
{date}
</Text>
<VStack align="start" gap={1.5}>
{states.map(({ color, count, state }) => (
<HStack gap={3} key={state}>
<HStack data-testid={`calendar-tooltip-state-${state.toLowerCase()}`} gap={3} key={state}>
<Box
bg={color}
border="1px solid"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ export const HourlyCalendarView = ({
const hourlyData = generateHourlyCalendarData(data, selectedYear, selectedMonth);

return (
<Box mb={4}>
<Box data-testid="calendar-hourly-view" mb={4}>
<Box mb={4}>
<Box display="flex" mb={2}>
<Box width="40px" />
Expand Down Expand Up @@ -140,7 +140,7 @@ export const HourlyCalendarView = ({
</Box>
</Box>

<Box display="flex" gap={2}>
<Box data-testid="calendar-grid" display="flex" gap={2}>
<Box display="flex" flexDirection="column" gap={0.5}>
{Array.from({ length: 24 }, (_, hour) => (
<Box
Expand Down
127 changes: 127 additions & 0 deletions airflow-core/src/airflow/ui/tests/e2e/pages/DagCalendarTab.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
/*!
* 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 DagCalendarTab extends BasePage {
public readonly dailyToggle: Locator;
public readonly failedToggle = this.page.getByRole("button", { name: /failed/i });
public readonly hourlyToggle: Locator;
public readonly totalToggle = this.page.getByRole("button", { name: /total/i });

public get activeCells(): Locator {
return this.page.locator('[data-testid="calendar-cell"][data-has-data="true"]');
}

public get calendarCells(): Locator {
return this.page.getByTestId("calendar-cell");
}

public get tooltip(): Locator {
return this.page.getByTestId("calendar-tooltip");
}

public constructor(page: Page) {
super(page);

this.hourlyToggle = page.getByRole("button", { name: /hourly/i });
this.dailyToggle = page.getByRole("button", { name: /daily/i });
}

public async getActiveCellColors(): Promise<Array<string>> {
const count = await this.activeCells.count();
const colors: Array<string> = [];

for (let i = 0; i < count; i++) {
const cell = this.activeCells.nth(i);
const bg = await cell.evaluate((el) => window.getComputedStyle(el).backgroundColor);

colors.push(bg);
}

return colors;
}

public async getActiveCellCount(): Promise<number> {
return this.activeCells.count();
}

public async getManualRunStates(): Promise<Array<string>> {
const count = await this.activeCells.count();
const states: Array<string> = [];

for (let i = 0; i < count; i++) {
const cell = this.activeCells.nth(i);

await cell.hover();
await expect(this.tooltip).toBeVisible({ timeout: 20_000 });

const text = ((await this.tooltip.textContent()) ?? "").toLowerCase();

if (text.includes("success")) states.push("success");
if (text.includes("failed")) states.push("failed");
if (text.includes("running")) states.push("running");
}

return states;
}

public async navigateToCalendar(dagId: string) {
await this.page.goto(`/dags/${dagId}/calendar`);
await this.waitForCalendarReady();
}

public async switchToFailedView() {
await this.failedToggle.click();
}

public async switchToHourly() {
await this.hourlyToggle.click();

await this.page.getByTestId("calendar-hourly-view").waitFor({ state: "visible", timeout: 30_000 });
}

public async switchToTotalView() {
await this.totalToggle.click();
}

public async verifyMonthGridRendered() {
await this.waitForCalendarReady();
}

private async waitForCalendarReady(): Promise<void> {
await this.page.getByTestId("dag-calendar-root").waitFor({ state: "visible", timeout: 120_000 });

await this.page.getByTestId("calendar-current-period").waitFor({ state: "visible", timeout: 120_000 });

const overlay = this.page.getByTestId("calendar-loading-overlay");

if (await overlay.isVisible().catch(() => false)) {
await overlay.waitFor({ state: "hidden", timeout: 120_000 });
}

await this.page.getByTestId("calendar-grid").waitFor({ state: "visible", timeout: 120_000 });

await this.page.waitForFunction(() => {
const cells = document.querySelectorAll('[data-testid="calendar-cell"]');

return cells.length > 0;
});
}
}
158 changes: 158 additions & 0 deletions airflow-core/src/airflow/ui/tests/e2e/specs/dag-calendar-tab.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
/*!
* 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 dayjs from "dayjs";
import { AUTH_FILE, testConfig } from "playwright.config";

import { DagCalendarTab } from "../pages/DagCalendarTab";

test.describe("DAG Calendar Tab", () => {
test.setTimeout(90_000);
const dagId = testConfig.testDag.id;
let calendar: DagCalendarTab;

test.beforeAll(async ({ browser }) => {
test.setTimeout(180_000);

const context = await browser.newContext({ storageState: AUTH_FILE });
const page = await context.newPage();

await page.request.patch(`/api/v2/dags/${dagId}`, {
data: { is_paused: false },
});

const now = dayjs();

const successIso = now.subtract(3, "day").hour(10).toISOString();
const failedIso = now.subtract(2, "day").hour(12).toISOString();

async function createRun(runId: string, iso: string, state: string) {
const response = await page.request.post(`/api/v2/dags/${dagId}/dagRuns`, {
data: {
conf: {},
dag_run_id: runId,
logical_date: iso,
note: "e2e test",
},
});

if (!response.ok()) {
const body = await response.text();

throw new Error(`Run creation failed: ${response.status()} ${body}`);
}

const data = (await response.json()) as { dag_run_id: string };
const dagRunId = data.dag_run_id;

await page.request.patch(`/api/v2/dags/${dagId}/dagRuns/${dagRunId}`, { data: { state } });
}

await createRun(`cal_success_${Date.now()}`, successIso, "success");
await createRun(`cal_failed_${Date.now()}`, failedIso, "failed");

await context.close();
});

test.beforeEach(async ({ page }) => {
test.setTimeout(60_000);
calendar = new DagCalendarTab(page);
await calendar.navigateToCalendar(dagId);
});

test("verify calendar grid renders", async () => {
await calendar.switchToHourly();
await calendar.verifyMonthGridRendered();
});

test("verify active cells appear for DAG runs", async () => {
await calendar.switchToHourly();

const count = await calendar.getActiveCellCount();

expect(count).toBeGreaterThan(0);
});

test("verify manual runs are detected", async () => {
await calendar.switchToHourly();

const states = await calendar.getManualRunStates();

expect(states.length).toBeGreaterThanOrEqual(2);
});

test("verify hover shows correct run states", async () => {
await calendar.switchToHourly();

const states = await calendar.getManualRunStates();

expect(states).toContain("success");
expect(states).toContain("failed");
});

test("failed filter shows only failed runs", async () => {
await calendar.switchToHourly();

const totalStates = await calendar.getManualRunStates();

expect(totalStates).toContain("success");
expect(totalStates).toContain("failed");

await calendar.switchToFailedView();

const failedStates = await calendar.getManualRunStates();

expect(failedStates).toContain("failed");
expect(failedStates).not.toContain("success");
});

test("failed view reduces active cells", async () => {
await calendar.switchToHourly();

const totalCount = await calendar.getActiveCellCount();

await calendar.switchToFailedView();

const failedCount = await calendar.getActiveCellCount();

expect(failedCount).toBeLessThan(totalCount);
});

test("color scale changes between total and failed view", async () => {
await calendar.switchToHourly();

const totalColors = await calendar.getActiveCellColors();

await calendar.switchToFailedView();

const failedColors = await calendar.getActiveCellColors();

// color palette should differ
expect(failedColors).not.toEqual(totalColors);
});

test("cells reflect failed view mode attribute", async () => {
await calendar.switchToHourly();
await calendar.switchToFailedView();

const cell = calendar.activeCells.first();

await expect(cell).toHaveAttribute("data-view-mode", "failed");
});
});
Loading