Skip to content

Commit 2837fec

Browse files
committed
E2E tests for Dag Calendar Tab
1 parent 2ca87da commit 2837fec

File tree

6 files changed

+301
-6
lines changed

6 files changed

+301
-6
lines changed

airflow-core/src/airflow/ui/src/pages/Dag/Calendar/Calendar.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,9 +89,9 @@ export const Calendar = () => {
8989
}
9090

9191
return (
92-
<Box p={6}>
92+
<Box data-testid="dag-calendar-root" p={6}>
9393
<ErrorAlert error={error} />
94-
<HStack justify="space-between" mb={6}>
94+
<HStack data-testid="calendar-header" justify="space-between" mb={6}>
9595
<HStack gap={4} mb={4}>
9696
{granularity === "daily" ? (
9797
<HStack gap={2}>
@@ -107,6 +107,7 @@ export const Calendar = () => {
107107
_hover={selectedDate.year() === currentDate.year() ? {} : { textDecoration: "underline" }}
108108
color={selectedDate.year() === currentDate.year() ? "fg.info" : "inherit"}
109109
cursor={selectedDate.year() === currentDate.year() ? "default" : "pointer"}
110+
data-testid="calendar-current-period"
110111
fontSize="xl"
111112
fontWeight="bold"
112113
minWidth="120px"
@@ -154,6 +155,7 @@ export const Calendar = () => {
154155
? "default"
155156
: "pointer"
156157
}
158+
data-testid="calendar-current-period"
157159
fontSize="xl"
158160
fontWeight="bold"
159161
minWidth="120px"
@@ -207,6 +209,7 @@ export const Calendar = () => {
207209
bg="bg/80"
208210
borderRadius="md"
209211
bottom="0"
212+
data-testid="calendar-loading-overlay"
210213
display="flex"
211214
justifyContent="center"
212215
left="0"
@@ -232,6 +235,7 @@ export const Calendar = () => {
232235
<>
233236
<DailyCalendarView
234237
data={data?.dag_runs ?? []}
238+
data-testid="calendar-daily-view"
235239
scale={scale}
236240
selectedYear={selectedDate.year()}
237241
viewMode={viewMode}

airflow-core/src/airflow/ui/src/pages/Dag/Calendar/CalendarCell.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,9 @@ export const CalendarCell = ({
5959
_hover={hasData ? { transform: "scale(1.1)" } : {}}
6060
borderRadius="2px"
6161
cursor={hasData ? "pointer" : "default"}
62+
data-has-data={hasData ? "true" : "false"}
63+
data-testid="calendar-cell"
64+
data-view-mode={viewMode}
6265
height="14px"
6366
marginRight={computedMarginRight}
6467
overflow="hidden"
@@ -86,6 +89,9 @@ export const CalendarCell = ({
8689
bg={backgroundColor}
8790
borderRadius="2px"
8891
cursor={hasData ? "pointer" : "default"}
92+
data-has-data={hasData ? "true" : "false"}
93+
data-testid="calendar-cell"
94+
data-view-mode={viewMode}
8995
height="14px"
9096
marginRight={computedMarginRight}
9197
width="14px"

airflow-core/src/airflow/ui/src/pages/Dag/Calendar/CalendarTooltip.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,13 +70,13 @@ export const CalendarTooltip = ({ cellData, viewMode = "total" }: Props) => {
7070
}));
7171

7272
return hasRuns ? (
73-
<VStack align="start" gap={2}>
73+
<VStack align="start" data-testid="calendar-tooltip" data-view-mode={viewMode} gap={2}>
7474
<Text fontSize="sm" fontWeight="medium">
7575
{date}
7676
</Text>
7777
<VStack align="start" gap={1.5}>
7878
{states.map(({ color, count, state }) => (
79-
<HStack gap={3} key={state}>
79+
<HStack data-testid={`calendar-tooltip-state-${state.toLowerCase()}`} gap={3} key={state}>
8080
<Box
8181
bg={color}
8282
border="1px solid"

airflow-core/src/airflow/ui/src/pages/Dag/Calendar/HourlyCalendarView.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ export const HourlyCalendarView = ({
6767
const hourlyData = generateHourlyCalendarData(data, selectedYear, selectedMonth);
6868

6969
return (
70-
<Box mb={4}>
70+
<Box data-testid="calendar-hourly-view" mb={4}>
7171
<Box mb={4}>
7272
<Box display="flex" mb={2}>
7373
<Box width="40px" />
@@ -140,7 +140,7 @@ export const HourlyCalendarView = ({
140140
</Box>
141141
</Box>
142142

143-
<Box display="flex" gap={2}>
143+
<Box data-testid="calendar-grid" display="flex" gap={2}>
144144
<Box display="flex" flexDirection="column" gap={0.5}>
145145
{Array.from({ length: 24 }, (_, hour) => (
146146
<Box
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
/*!
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
import { expect, type Locator, type Page } from "@playwright/test";
20+
import { BasePage } from "tests/e2e/pages/BasePage";
21+
22+
export class DagCalendarTab extends BasePage {
23+
public readonly dailyToggle: Locator;
24+
public readonly failedToggle = this.page.getByRole("button", { name: /failed/i });
25+
public readonly hourlyToggle: Locator;
26+
public readonly totalToggle = this.page.getByRole("button", { name: /total/i });
27+
28+
public get activeCells(): Locator {
29+
return this.page.locator('[data-testid="calendar-cell"][data-has-data="true"]');
30+
}
31+
32+
public get calendarCells(): Locator {
33+
return this.page.getByTestId("calendar-cell");
34+
}
35+
36+
public get tooltip(): Locator {
37+
return this.page.getByTestId("calendar-tooltip");
38+
}
39+
40+
public constructor(page: Page) {
41+
super(page);
42+
43+
this.hourlyToggle = page.getByRole("button", { name: /hourly/i });
44+
this.dailyToggle = page.getByRole("button", { name: /daily/i });
45+
}
46+
47+
public async getActiveCellColors(): Promise<Array<string>> {
48+
const count = await this.activeCells.count();
49+
const colors: Array<string> = [];
50+
51+
for (let i = 0; i < count; i++) {
52+
const cell = this.activeCells.nth(i);
53+
const bg = await cell.evaluate((el) => window.getComputedStyle(el).backgroundColor);
54+
55+
colors.push(bg);
56+
}
57+
58+
return colors;
59+
}
60+
61+
public async getActiveCellCount(): Promise<number> {
62+
return this.activeCells.count();
63+
}
64+
65+
public async getManualRunStates(): Promise<Array<string>> {
66+
const count = await this.activeCells.count();
67+
const states: Array<string> = [];
68+
69+
for (let i = 0; i < count; i++) {
70+
const cell = this.activeCells.nth(i);
71+
72+
await cell.hover();
73+
await expect(this.tooltip).toBeVisible({ timeout: 20_000 });
74+
75+
const text = ((await this.tooltip.textContent()) ?? "").toLowerCase();
76+
77+
if (text.includes("success")) states.push("success");
78+
if (text.includes("failed")) states.push("failed");
79+
if (text.includes("running")) states.push("running");
80+
}
81+
82+
return states;
83+
}
84+
85+
public async navigateToCalendar(dagId: string) {
86+
await this.page.goto(`/dags/${dagId}/calendar`);
87+
await this.waitForCalendarReady();
88+
}
89+
90+
public async switchToFailedView() {
91+
await this.failedToggle.click();
92+
}
93+
94+
public async switchToHourly() {
95+
await this.hourlyToggle.click();
96+
97+
await this.page.getByTestId("calendar-hourly-view").waitFor({ state: "visible", timeout: 30_000 });
98+
}
99+
100+
public async switchToTotalView() {
101+
await this.totalToggle.click();
102+
}
103+
104+
public async verifyMonthGridRendered() {
105+
await this.waitForCalendarReady();
106+
}
107+
108+
private async waitForCalendarReady(): Promise<void> {
109+
await this.page.getByTestId("dag-calendar-root").waitFor({ state: "visible", timeout: 120_000 });
110+
111+
await this.page.getByTestId("calendar-current-period").waitFor({ state: "visible", timeout: 120_000 });
112+
113+
const overlay = this.page.getByTestId("calendar-loading-overlay");
114+
115+
if (await overlay.isVisible().catch(() => false)) {
116+
await overlay.waitFor({ state: "hidden", timeout: 120_000 });
117+
}
118+
119+
await this.page.getByTestId("calendar-grid").waitFor({ state: "visible", timeout: 120_000 });
120+
121+
await this.page.waitForFunction(() => {
122+
const cells = document.querySelectorAll('[data-testid="calendar-cell"]');
123+
124+
return cells.length > 0;
125+
});
126+
}
127+
}
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
/*!
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
import { expect, test } from "@playwright/test";
20+
import dayjs from "dayjs";
21+
import { AUTH_FILE, testConfig } from "playwright.config";
22+
23+
import { DagCalendarTab } from "../pages/DagCalendarTab";
24+
25+
test.describe("DAG Calendar Tab", () => {
26+
test.setTimeout(90_000);
27+
const dagId = testConfig.testDag.id;
28+
let calendar: DagCalendarTab;
29+
30+
test.beforeAll(async ({ browser }) => {
31+
test.setTimeout(180_000);
32+
33+
const context = await browser.newContext({ storageState: AUTH_FILE });
34+
const page = await context.newPage();
35+
36+
await page.request.patch(`/api/v2/dags/${dagId}`, {
37+
data: { is_paused: false },
38+
});
39+
40+
const now = dayjs();
41+
42+
const successIso = now.subtract(3, "day").hour(10).toISOString();
43+
const failedIso = now.subtract(2, "day").hour(12).toISOString();
44+
45+
async function createRun(runId: string, iso: string, state: string) {
46+
const response = await page.request.post(`/api/v2/dags/${dagId}/dagRuns`, {
47+
data: {
48+
conf: {},
49+
dag_run_id: runId,
50+
logical_date: iso,
51+
note: "e2e test",
52+
},
53+
});
54+
55+
if (!response.ok()) {
56+
const body = await response.text();
57+
58+
throw new Error(`Run creation failed: ${response.status()} ${body}`);
59+
}
60+
61+
const data = (await response.json()) as { dag_run_id: string };
62+
const dagRunId = data.dag_run_id;
63+
64+
await page.request.patch(`/api/v2/dags/${dagId}/dagRuns/${dagRunId}`, { data: { state } });
65+
}
66+
67+
await createRun(`cal_success_${Date.now()}`, successIso, "success");
68+
await createRun(`cal_failed_${Date.now()}`, failedIso, "failed");
69+
70+
await context.close();
71+
});
72+
73+
test.beforeEach(async ({ page }) => {
74+
test.setTimeout(60_000);
75+
calendar = new DagCalendarTab(page);
76+
await calendar.navigateToCalendar(dagId);
77+
});
78+
79+
test("verify calendar grid renders", async () => {
80+
await calendar.switchToHourly();
81+
await calendar.verifyMonthGridRendered();
82+
});
83+
84+
test("verify active cells appear for DAG runs", async () => {
85+
await calendar.switchToHourly();
86+
87+
const count = await calendar.getActiveCellCount();
88+
89+
expect(count).toBeGreaterThan(0);
90+
});
91+
92+
test("verify manual runs are detected", async () => {
93+
await calendar.switchToHourly();
94+
95+
const states = await calendar.getManualRunStates();
96+
97+
expect(states.length).toBeGreaterThanOrEqual(2);
98+
});
99+
100+
test("verify hover shows correct run states", async () => {
101+
await calendar.switchToHourly();
102+
103+
const states = await calendar.getManualRunStates();
104+
105+
expect(states).toContain("success");
106+
expect(states).toContain("failed");
107+
});
108+
109+
test("failed filter shows only failed runs", async () => {
110+
await calendar.switchToHourly();
111+
112+
const totalStates = await calendar.getManualRunStates();
113+
114+
expect(totalStates).toContain("success");
115+
expect(totalStates).toContain("failed");
116+
117+
await calendar.switchToFailedView();
118+
119+
const failedStates = await calendar.getManualRunStates();
120+
121+
expect(failedStates).toContain("failed");
122+
expect(failedStates).not.toContain("success");
123+
});
124+
125+
test("failed view reduces active cells", async () => {
126+
await calendar.switchToHourly();
127+
128+
const totalCount = await calendar.getActiveCellCount();
129+
130+
await calendar.switchToFailedView();
131+
132+
const failedCount = await calendar.getActiveCellCount();
133+
134+
expect(failedCount).toBeLessThan(totalCount);
135+
});
136+
137+
test("color scale changes between total and failed view", async () => {
138+
await calendar.switchToHourly();
139+
140+
const totalColors = await calendar.getActiveCellColors();
141+
142+
await calendar.switchToFailedView();
143+
144+
const failedColors = await calendar.getActiveCellColors();
145+
146+
// color palette should differ
147+
expect(failedColors).not.toEqual(totalColors);
148+
});
149+
150+
test("cells reflect failed view mode attribute", async () => {
151+
await calendar.switchToHourly();
152+
await calendar.switchToFailedView();
153+
154+
const cell = calendar.activeCells.first();
155+
156+
await expect(cell).toHaveAttribute("data-view-mode", "failed");
157+
});
158+
});

0 commit comments

Comments
 (0)