feat(tests): add E2E on Executions view (#11556)

This commit is contained in:
yuri
2025-10-16 11:16:16 +02:00
committed by GitHub
parent af9ab4adc6
commit 4909af97fb
12 changed files with 518 additions and 2 deletions

6
ui/.gitignore vendored
View File

@@ -1,2 +1,6 @@
playwright-report
test-results
test-results
tests/.env
tests/data/
tests/e2e/.env
tests/e2e/data/application-secrets.yml

View File

@@ -0,0 +1,13 @@
import {APIRequestContext} from "playwright/test";
import {shared} from "../fixtures/shared";
export class BaseApi {
protected static readonly BASED_PAIR = Buffer.from(`${shared.username}:${shared.password}`).toString("base64");
protected static readonly AUTH = `Basic ${BaseApi.BASED_PAIR}`;
protected readonly apiUrl: string;
constructor(public readonly request: APIRequestContext, protected readonly baseURL: string | undefined) {
this.apiUrl = `${baseURL}/api/v1/main`;
}
}

View File

@@ -0,0 +1,61 @@
import {APIRequestContext} from "playwright/test";
import {shared} from "../fixtures/shared";
import {BaseApi} from "./base.api";
export class ExecutionsApi extends BaseApi {
private readonly executionIds: string[] = [];
constructor(public readonly requests: APIRequestContext, public readonly flowId: string, protected readonly baseURL: string | undefined) {
super(requests, baseURL);
}
async generateExecutionViaApi(labels: [string, string][] = []) {
const formData = new FormData();
formData.append("INPUT_A", "test");
const params = new URLSearchParams();
labels.forEach((tuple) => {
params.append("labels", `${tuple[0]}:${tuple[1]}`);
});
const response = this.request.post(`${this.apiUrl}/executions/${shared.namespace}/${this.flowId}`, {
headers: {
"Accept": "application/json",
"Authorization": ExecutionsApi.AUTH
},
params,
multipart: formData
});
const status = (await response).status();
if (status !== 200) {
throw new Error(`Execution creation failed with HTTP ${status}: ${await (await response).text()}`);
}
const responseJson = await (await response).json();
this.executionIds.push(responseJson["id"]);
}
async removeExecutionsViaApi() {
for (const executionId of this.executionIds) {
const params = new URLSearchParams();
params.append("deleteLogs", "true");
params.append("deleteMetric", "true");
params.append("deleteStorage", "true");
const status = (await this.request.delete(`${this.apiUrl}/executions/${executionId}`, {
headers: {
"Authorization": ExecutionsApi.AUTH
},
params
})).status();
if (status !== 204) {
throw new Error(`Deletion of execution ${executionId} failed with HTTP ${status}`);
}
};
}
}

View File

@@ -0,0 +1,65 @@
import {APIRequestContext} from "playwright/test";
import {BaseApi} from "./base.api";
import {shared} from "../fixtures/shared";
import {v4 as uuid} from "uuid";
import {fileURLToPath} from "url";
import {dirname} from "path";
import fs from "fs";
import path from "path";
export class FlowsApi extends BaseApi {
private readonly flowIds: string[] = [];
constructor(public readonly requests: APIRequestContext, protected readonly baseURL: string | undefined) {
super(requests, baseURL);
}
async generateFlowViaApi(fileName: string, fileFlowId: string) {
const flowId = `test-flow-${uuid()}`;
// Create flow via API
const response = this.request.post(`${this.apiUrl}/flows`, {
headers: {
"Content-Type": "application/x-yaml",
"Accept": "application/json",
"Authorization": FlowsApi.AUTH
},
data: this.getFlowYaml(fileName, fileFlowId, flowId)
});
const status = (await response).status();
if (status !== 200) {
throw new Error(`Flow creation failed with HTTP ${status}`);
}
this.flowIds.push(flowId);
return flowId;
}
async removeFlowsViaApi() {
for(const flowId of this.flowIds) {
const status = (await this.request.delete(`${this.apiUrl}/flows/${shared.namespace}/${flowId}`, {
headers: {
"Authorization": FlowsApi.AUTH
}
})).status();
if (status !== 204) {
throw new Error(`Deletion of flow ${flowId} failed with HTTP ${status}`);
}
};
}
protected getFlowYaml(fileName: string, fileFlowId: string, desiredFlowId: string): string {
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const flowYaml = fs.readFileSync(
path.resolve(__dirname, `../fixtures/flows/${fileName}`),
"utf-8"
);
return flowYaml.replace(fileFlowId, desiredFlowId);
}
}

View File

@@ -0,0 +1,135 @@
import {test, expect} from "../fixtures/executions.fixture";
import {ExecutionState, Pagination} from "../pages/base.page";
test.describe("Executions' view Bulk Actions", () => {
// Use specific flow to create executions
test.use({flow: {fileName: "hello.yaml", flowId: "my-hello-flow-1"}});
test("Labels changed only on a filtered set of executions when using Select All", async ({executionsPage, executionsApi, page}) => {
test.slow(); // creating many executions
expect(page.getByRole("heading", {name: "Executions"})).toBeVisible();
await test.step("Generate 26 executions with the 'foo:bar' label and a single 'a:b' one", async () => {
for (let i = 0; i < 26; i++) {
await executionsApi.generateExecutionViaApi([["foo", "bar"]]);
}
await executionsApi.generateExecutionViaApi([["a", "b"]]);
});
await test.step("Filter just the executions featuring the 'foo:bar' label", async () => {
await executionsPage.setPaginationTo(Pagination.ITEMS_25);
await executionsPage.setFilterByFlowId(executionsApi.flowId);
await executionsPage.setFilterByLabel("foo", "bar");
expect(await executionsPage.getCountOfDisplayedExecutions()).toEqual(25);
expect(await executionsPage.getTotalExecutionsCount()).toEqual(26);
});
await test.step("Set label to 'foo:baz' using Select All on filtered 'foo:bar' executions", async () => {
await page.waitForTimeout(1500); // somehow the execution selection de-selects itself due a data load
await executionsPage.selectExecutionRowByNumber();
await executionsPage.clickOnSelectAll();
await executionsPage.clickOnSetLabels();
await executionsPage.setLabelOnSelectedExecutions();
expect(await executionsPage.getCountOfDisplayedExecutions()).toEqual(0);
});
await test.step("Switch filter to label 'a:b' which should not be affected by the label change", async () => {
await executionsPage.removeFilterByLabelKey("foo");
await executionsPage.setFilterByLabel("a", "b");
expect(await executionsPage.getCountOfDisplayedExecutions()).toEqual(1);
});
});
test.use({flow: {fileName: "failure-then-success.yaml", flowId: "failure-then-success"}});
test("Restart only on a filtered set of executions when using Select All", async ({executionsPage, executionsApi, page}) => {
test.slow(); // creating and resuming many executions
expect(page.getByRole("heading", {name: "Executions"})).toBeVisible();
await test.step("Generate 26 executions with the 'foo:bar' label and a single 'a:b' one", async () => {
for (let i = 0; i < 26; i++) {
await executionsApi.generateExecutionViaApi([["foo", "bar"]]);
}
await executionsApi.generateExecutionViaApi([["a", "b"]]);
});
await test.step("Filter just 'FAILED' executions featuring the 'foo:bar' label", async () => {
await executionsPage.setPaginationTo(Pagination.ITEMS_25);
await executionsPage.setFilterByFlowId(executionsApi.flowId);
await executionsPage.setFilterByLabel("foo", "bar");
await executionsPage.setFilterByState(ExecutionState.FAILED);
expect(await executionsPage.getCountOfDisplayedExecutions()).toEqual(25);
expect(await executionsPage.getTotalExecutionsCount()).toEqual(26);
});
await test.step("Call Restart using Select All on filtered 'FAILED' & 'foo:bar' executions", async () => {
await page.waitForTimeout(1500); // somehow the execution selection de-selects itself due a data load
await executionsPage.selectExecutionRowByNumber();
await executionsPage.clickOnSelectAll();
await executionsPage.clickOnRestart();
});
await test.step("Show all 26 now successfully finished 'foo:bar' executions on a single page", async () => {
await page.waitForTimeout(2000); // ensure restarted executions finished
await executionsPage.setFilterByState(ExecutionState.SUCCESS);
await executionsPage.setPaginationTo(Pagination.ITEMS_50);
expect(await executionsPage.getCountOfDisplayedExecutions()).toEqual(26);
});
await test.step("Switch filter to label 'a:b' which should not be affected by the Restart action", async () => {
await executionsPage.removeFilterByLabelKey("foo");
await executionsPage.setFilterByLabel("a", "b");
await executionsPage.setFilterByState(ExecutionState.FAILED);
expect(await executionsPage.getCountOfDisplayedExecutions()).toEqual(1);
});
});
test.use({flow: {fileName: "failure-then-success.yaml", flowId: "failure-then-success"}});
test("Replay only on a filtered set of executions when using Select All", async ({executionsPage, executionsApi, page}) => {
test.slow(); // creating and resuming many executions
expect(page.getByRole("heading", {name: "Executions"})).toBeVisible();
await test.step("Generate 26 executions with the 'foo:bar' label and a single 'a:b' one", async () => {
for (let i = 0; i < 26; i++) {
await executionsApi.generateExecutionViaApi([["foo", "bar"]]);
}
await executionsApi.generateExecutionViaApi([["a", "b"]]);
});
await test.step("Filter just 'FAILED' executions featuring the 'foo:bar' label", async () => {
await executionsPage.setPaginationTo(Pagination.ITEMS_25);
await executionsPage.setFilterByFlowId(executionsApi.flowId);
await executionsPage.setFilterByLabel("foo", "bar");
await executionsPage.setFilterByState(ExecutionState.FAILED);
expect(await executionsPage.getCountOfDisplayedExecutions()).toEqual(25);
expect(await executionsPage.getTotalExecutionsCount()).toEqual(26);
});
await test.step("Call Replay using Select All on filtered 'FAILED' & 'foo:bar' executions", async () => {
await page.waitForTimeout(1500); // somehow the execution selection de-selects itself due a data load
await executionsPage.selectExecutionRowByNumber();
await executionsPage.clickOnSelectAll();
await executionsPage.clickOnReplay();
});
await test.step("Show 26 original and 26 replayed 'foo:bar' executions on a single page", async () => {
await page.waitForTimeout(2000); // ensure replayed executions finished
await executionsPage.setPaginationTo(Pagination.ITEMS_100);
expect(await executionsPage.getCountOfDisplayedExecutions()).toEqual(26 * 2);
});
await test.step("Switch filter to label 'a:b' which should not be affected by the Restart action", async () => {
await executionsPage.removeFilterByLabelKey("foo");
await executionsPage.setFilterByLabel("a", "b");
await executionsPage.setFilterByState(ExecutionState.FAILED);
expect(await executionsPage.getCountOfDisplayedExecutions()).toEqual(1);
});
});
});

View File

@@ -0,0 +1,38 @@
import {test as base} from "@playwright/test";
import {ExecutionsPage} from "../pages/executions.page";
import {ExecutionsApi} from "../api/executions.api";
import {FlowsApi} from "../api/flows.api";
type ExecutionsFixtures = {
executionsApi: ExecutionsApi,
executionsPage: ExecutionsPage,
flow: {fileName: string, flowId: string}
};
export const test = base.extend<ExecutionsFixtures>({
// define the default `flow` option
flow: [{fileName: "hello.yaml", flowId: "my-hello-flow-1"}, {option: true}],
executionsApi: async ({page, request, baseURL, flow}, use) => {
// Prepare data
const flowsApi = new FlowsApi(request, baseURL);
const executionsPage = new ExecutionsPage(page);
const executionsApi = new ExecutionsApi(request, await flowsApi.generateFlowViaApi(flow.fileName, flow.flowId), baseURL);
await executionsApi.generateExecutionViaApi();
// Navigate
await executionsPage.goto();
// Do the work
await use(executionsApi);
// Clean up
await executionsApi.removeExecutionsViaApi();
},
executionsPage: async ({page}, use) => {
const executionsPage = new ExecutionsPage(page);
await use(executionsPage);
}
});
export {expect} from "@playwright/test";

View File

@@ -0,0 +1,7 @@
id: failure-then-success
namespace: company.team
tasks:
- id: fail-and-recover
type: io.kestra.plugin.core.execution.Fail
condition: "{{ taskrun.attemptsCount < 1}}"

View File

@@ -0,0 +1,8 @@
/**
* TODO: use ENV instead
*/
export const shared = {
namespace: "company.team",
username: "user@kestra.io",
password: "DemoDemo1"
};

View File

@@ -0,0 +1,50 @@
import type {Page} from "@playwright/test";
import {expect} from "@playwright/test";
import {shared} from "../fixtures/shared";
export class BasePage {
constructor(public readonly page: Page) { }
async login() {
await this.page.goto("/ui");
await this.page.getByRole("textbox", {name: "Email"}).fill(shared.username);
await this.page.getByRole("textbox", {name: "Password"}).fill(shared.password);
await this.page.getByRole("button", {name: "Login"}).click();
await expect(this.page.getByRole("heading", {name: "Overview"})).toBeVisible();
}
async addQueryParam(page: Page, key: string, value: string) {
// Get the current URL
const url = new URL(page.url());
// Change query params
url.searchParams.set(key, value);
// Navigate to the new URL
await page.goto(url.toString());
}
async removeQueryParam(page: Page, key: string) {
// Get the current URL
const url = new URL(page.url());
// Change query params
url.searchParams.delete(key);
// Navigate to the new URL
await page.goto(url.toString());
}
}
export enum ExecutionState {
FAILED = "FAILED",
SUCCESS = "SUCCESS"
}
export enum Pagination {
ITEMS_10 = 10,
ITEMS_25 = 25,
ITEMS_50 = 50,
ITEMS_100 = 100
}

View File

@@ -0,0 +1,118 @@
import type {Page} from "@playwright/test";
import {expect} from "@playwright/test";
import {BasePage, ExecutionState, Pagination} from "./base.page";
export class ExecutionsPage extends BasePage {
constructor(public readonly page: Page) {
super(page);
}
async goto() {
await this.login();
await this.page.goto("/ui/main/executions");
await expect(this.page.getByRole("heading", {name: "Executions"})).toBeVisible();
}
async setFilterByFlowId(flowId: string) {
const param = "filters[flowId][EQUALS]";
await this.removeQueryParam(this.page, param);
await this.addQueryParam(this.page, param, flowId);
}
async setFilterByLabel(key: string, value: string) {
const param = `filters[labels][EQUALS][${key}]`;
await this.removeQueryParam(this.page, param);
await this.addQueryParam(this.page, param, value);
}
async setFilterByState(state: ExecutionState) {
const param = "filters[state][EQUALS]";
await this.removeQueryParam(this.page, param);
await this.addQueryParam(this.page, param, state);
}
async removeFilterByLabelKey(key: string) {
await this.removeQueryParam(this.page, `filters[labels][EQUALS][${key}]`);
}
async getCountOfDisplayedExecutions() {
const rows = this.page.getByRole("row");
return await rows.count() - 1;
}
async getTotalExecutionsCount() {
const content = await this.page.getByText(/Total:/).first().textContent();
if (!content) {
throw new Error("Totals not found");
}
return Number.parseInt(content.split(":")[1].trim());
}
async selectExecutionRowByNumber(rowNumber: number = 1) {
if (rowNumber < 0) {
throw new Error("Negative row number is not allowed");
}
const checkbox = this.page.getByRole("row").nth(rowNumber).locator("label.el-checkbox");
await checkbox.waitFor({state: "visible"});
await checkbox.click();
await expect(checkbox).toContainClass("is-checked");
}
async clickOnSelectAll() {
await this.page.getByRole("button", {name: "Select All"}).click();
}
async clickOnSetLabels() {
await this.page.locator(".bulk-select").locator(".el-button-group").locator(".el-dropdown").click();
await this.page.getByRole("menuitem", {name: "Set labels"}).click();
}
async clickOnResume() {
await this.page.locator(".bulk-select").locator(".el-button-group").locator(".el-dropdown").click();
await this.page.getByRole("menuitem", {name: "Resume"}).click();
// Confirm
await this.page.getByRole("button", {name: "OK"}).click();
}
async clickOnRestart() {
await this.page.getByRole("button", {name: "Restart"}).click();
// Confirm
await this.page.getByRole("button", {name: "OK"}).click();
}
async clickOnReplay() {
await this.page.getByRole("button", {name: "Replay"}).click();
// Confirm
await this.page.getByRole("button", {name: "OK"}).click();
}
async setLabelOnSelectedExecutions() {
await this.page.getByRole("textbox", {name: "Key"}).fill("foo");
await this.page.getByRole("textbox", {name: "Value"}).fill("baz");
await this.page.getByRole("button", {name: "OK"}).click();
// Confirm
await this.page.getByRole("button", {name: "OK"}).click();
}
async setPaginationTo(size: Pagination) {
// The Element-Plus dropdown is not a `select` - click on text
await this.page.locator(".pagination .el-select").click();
// Wait for the select dropdown to show
const dropdowns = this.page.locator(".el-select-dropdown");
const visibleDropdown = dropdowns.filter({has: this.page.locator(":visible")}).last();
// Wait for the visible dropdown to actually appear
await visibleDropdown.waitFor({state: "visible", timeout: 500});
// Find and click the matching option
const option = visibleDropdown.locator(".el-select-dropdown__item", {hasText: `${size} per page`});
await option.waitFor({state: "visible", timeout: 500});
await option.click();
}
}

View File

@@ -0,0 +1,17 @@
import type {Page} from "@playwright/test";
import {expect} from "@playwright/test";
import {BasePage} from "./base.page";
export class FlowsPage extends BasePage {
constructor(public readonly page: Page) {
super(page);
}
async goto() {
await this.login();
await this.page.goto("/ui/main/flows");
await expect(this.page.getByRole("heading", {name: "Flows"})).toBeVisible();
}
}

View File

@@ -41,7 +41,7 @@ const config: PlaywrightTestConfig = {
/* Shared settings for all the projects below. */
use: {
/* Base URL to use in actions like `await page.goto("/")`. */
baseURL: "http://localhost:9011/ui",
baseURL: "http://localhost:9011",
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: "retain-on-failure",