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
7 changes: 7 additions & 0 deletions integration_tests/pages/Header.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
HEADER_AVATAR_MENU_ID,
HEADER_HELP_MENU_ID,
HEADER_HELP_MENU_BUTTON_TEST_ID,
HEADER_LOGOUT_BUTTON_TEST_ID,
} from "~client/components/Header.locators";
import { TAKE_TOUR_BUTTON_TEST_ID } from "~client/components/GuidedTour.locators";
import { TourModal } from "./TourModal";
Expand All @@ -14,6 +15,7 @@ export class Header {
takeTourButton = this.helpMenu.getByTestId(TAKE_TOUR_BUTTON_TEST_ID);
avatarButton = this.page.getByTestId(HEADER_AVATAR_BUTTON_TEST_ID);
avatarMenu = this.page.locator(`#${HEADER_AVATAR_MENU_ID}`);
logoutBtn = this.avatarMenu.getByTestId(HEADER_LOGOUT_BUTTON_TEST_ID);

constructor(public page: Page) {
this.page = page;
Expand All @@ -30,6 +32,11 @@ export class Header {
await this.avatarButton.click();
}

async logout() {
await this.openAvatarMenu();
await this.logoutBtn.click();
}

avatarMenuItem(name: string) {
return this.avatarMenu.getByRole("menuitem", { name });
}
Expand Down
2 changes: 2 additions & 0 deletions integration_tests/pages/LoginPage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,6 @@ export class LoginPage extends BasePage {
"[aria-label='toggle password visibility']",
);
loginForm = this.page.locator(`#${LOCATOR_LOGIN_FORM}`);
ssoBtn = this.page.getByTestId("oidcLoginBtn");
registerLink = this.page.getByText("Create an account");
}
3 changes: 3 additions & 0 deletions integration_tests/pages/UserListPage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ import { Table } from "./components/Table";

export class UserListPage extends BasePage {
table: Table;
deleteSelectedBtn = this.page.locator(
"button:has([data-testid='DeleteIcon'])",
);

constructor(page: Page) {
super(page);
Expand Down
22 changes: 22 additions & 0 deletions integration_tests/pages/components/BuildList.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,28 @@ export class BuildList {
this.page = page;
}

// the menu of one build: stop, edit its CI build id, delete
menu = this.page.getByRole("menu");
ciBuildIdInput = this.page.getByTestId("newCiBuildId");

getBuildItem(number: number) {
// "#1" is also the start of "#10"
return this.buildList
.locator(".MuiListItemButton-root")
.filter({ hasText: new RegExp(String.raw`#${number}(?!\d)`) });
}

// the menu button only shows while the pointer is over its build
async openMenu(number: number) {
const item = this.getBuildItem(number);
await item.hover();
await item.getByTestId("MoreVertIcon").click();
}

menuItem(name: "Stop" | "Edit CI Build" | "Delete") {
return this.menu.getByRole("menuitem", { name, exact: true });
}

getBuildLocator(number: number) {
return this.buildList.getByText(`#${number}`);
}
Expand Down
217 changes: 217 additions & 0 deletions integration_tests/test/buildMenu.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
import { expect } from "@playwright/test";
import { test } from "fixtures";
import {
TEST_BUILD_PASSED,
TEST_BUILD_UNRESOLVED,
TEST_PROJECT,
} from "~client/_test/test.data.helper";
import {
API_URL,
mockApiError,
mockDeleteBuilds,
mockGetBuildDetails,
mockGetBuilds,
mockGetProjects,
mockGetTestRuns,
mockUpdateBuild,
} from "utils/mocks";

const project = TEST_PROJECT;
const runningBuild = { ...TEST_BUILD_UNRESOLVED, isRunning: true };
const finishedBuild = TEST_BUILD_PASSED;

const isPatchOf =
(id: string) => (request: { url(): string; method(): string }) =>
request.url() === `${API_URL}/builds/${id}` && request.method() === "PATCH";

test.beforeEach(async ({ page }) => {
await mockGetProjects(page, [project]);
await mockGetBuilds(page, project.id, [runningBuild, finishedBuild]);
await mockGetBuildDetails(page, runningBuild);
await mockGetBuildDetails(page, finishedBuild);
await mockGetTestRuns(page, runningBuild.id, []);
await mockGetTestRuns(page, finishedBuild.id, []);
});

test("renders the build menu", async ({ openProjectPage, page }) => {
const { buildList } = await openProjectPage(project.id);

await buildList.openMenu(runningBuild.number);
await expect(buildList.menuItem("Stop")).toBeVisible();

await expect(page).toHaveScreenshot("build-menu.png");
});

test("renders the CI build id dialog", async ({ openProjectPage, page }) => {
const { buildList, modal } = await openProjectPage(project.id);

await buildList.openMenu(finishedBuild.number);
await buildList.menuItem("Edit CI Build").click();
await expect(modal.dialog).toBeVisible();

await expect(page).toHaveScreenshot("build-edit-dialog.png");
});

test("stops a running build", async ({ openProjectPage, page }) => {
await mockUpdateBuild(page, runningBuild);
const { buildList } = await openProjectPage(project.id);

await buildList.openMenu(runningBuild.number);
const [request] = await Promise.all([
page.waitForRequest(isPatchOf(runningBuild.id)),
buildList.menuItem("Stop").click(),
]);

expect(request.postDataJSON()).toEqual({ isRunning: false });
await expect(buildList.menu).toBeHidden();
await expect(
page.locator("#notistack-snackbar").filter({
hasText: `${runningBuild.id} finished`,
}),
).toBeVisible();
});

test("offers no stop for a finished build", async ({ openProjectPage }) => {
const { buildList } = await openProjectPage(project.id);

await buildList.openMenu(finishedBuild.number);

await expect(buildList.menuItem("Edit CI Build")).toBeVisible();
await expect(buildList.menuItem("Delete")).toBeVisible();
await expect(buildList.menuItem("Stop")).toHaveCount(0);
});

test("changes the CI build id", async ({ openProjectPage, page }) => {
await mockUpdateBuild(page, finishedBuild);
const projectPage = await openProjectPage(project.id);
const { buildList, modal } = projectPage;

await buildList.openMenu(finishedBuild.number);
await buildList.menuItem("Edit CI Build").click();
await expect(modal.dialog).toContainText(
`Edit the ci build id for build: #${finishedBuild.number}`,
);
await buildList.ciBuildIdInput.fill("release-42");
const [request] = await Promise.all([
page.waitForRequest(isPatchOf(finishedBuild.id)),
modal.confirmBtn.click(),
]);

expect(request.postDataJSON()).toEqual({ ciBuildId: "release-42" });
await expect(modal.dialog).toBeHidden();
});

test("does not save a CI build id that is too short", async ({
openProjectPage,
page,
}) => {
const patches: string[] = [];
page.on("request", (request) => {
if (isPatchOf(finishedBuild.id)(request)) {
patches.push(request.url());
}
});
const { buildList, modal } = await openProjectPage(project.id);

await buildList.openMenu(finishedBuild.number);
await buildList.menuItem("Edit CI Build").click();
await buildList.ciBuildIdInput.fill("r");
await modal.confirmBtn.click();

await expect(
modal.dialog.getByText("Enter at least two characters."),
).toBeVisible();
expect(patches).toEqual([]);
});

test("opens nothing by itself after a failed CI build id change", async ({
openProjectPage,
page,
}) => {
await mockApiError(
page,
(url) => url.pathname === `/builds/${finishedBuild.id}`,
500,
"Could not save",
);
const { buildList, modal, notification } = await openProjectPage(project.id);

await buildList.openMenu(finishedBuild.number);
await buildList.menuItem("Edit CI Build").click();
await buildList.ciBuildIdInput.fill("release-42");
await modal.confirmBtn.click();
await expect(notification.message).toHaveText("Could not save");

// the next menu opens as a menu, not straight into the old edit dialog
await buildList.openMenu(runningBuild.number);
await expect(buildList.menu).toBeVisible();
await expect(modal.dialog).toHaveCount(0);
});

test("deletes a build", async ({ openProjectPage, page }) => {
await mockDeleteBuilds(page);
const { buildList, modal } = await openProjectPage(project.id);

await buildList.openMenu(finishedBuild.number);
await buildList.menuItem("Delete").click();
await expect(modal.dialog).toContainText(
`Are you sure you want to delete build: #${finishedBuild.number}?`,
);
await Promise.all([
page.waitForRequest(
(request) =>
request.url() === `${API_URL}/builds/${finishedBuild.id}` &&
request.method() === "DELETE",
),
modal.confirmBtn.click(),
]);

await expect(
page.locator("#notistack-snackbar").filter({
hasText: `Build #${finishedBuild.number} deleted`,
}),
).toBeVisible();
await expect(modal.dialog).toBeHidden();
});

test("lets go of a build once it is deleted", async ({
openProjectPage,
page,
}) => {
await mockDeleteBuilds(page);
const { buildList, modal } = await openProjectPage(
project.id,
finishedBuild.id,
);

await buildList.openMenu(finishedBuild.number);
await buildList.menuItem("Delete").click();
await modal.confirmBtn.click();

await expect(page).toHaveURL(
(url) =>
url.pathname === `/${project.id}` && !url.searchParams.has("buildId"),
);
});

test("opens nothing by itself after a failed delete", async ({
openProjectPage,
page,
}) => {
await mockApiError(
page,
(url) => url.pathname === `/builds/${finishedBuild.id}`,
500,
"Could not delete",
);
const { buildList, modal, notification } = await openProjectPage(project.id);

await buildList.openMenu(finishedBuild.number);
await buildList.menuItem("Delete").click();
await modal.confirmBtn.click();
await expect(notification.message).toHaveText("Could not delete");

await buildList.openMenu(runningBuild.number);
await expect(buildList.menu).toBeVisible();
await expect(modal.dialog).toHaveCount(0);
});
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
21 changes: 21 additions & 0 deletions integration_tests/test/errors.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,27 @@ test.describe("a failing API", () => {
await expect(projectPage.buildList.emptyMessage).toBeHidden();
});

test("renders the build list that did not load", async ({
page,
openProjectPage,
}) => {
await mockGetProjects(page, [project]);
await mockApiError(
page,
(url) => url.origin === API_URL && url.pathname === "/builds",
500,
"Could not load builds",
);

const projectPage = await openProjectPage(project.id);
await expect(projectPage.buildList.errorMessage).toBeVisible();
// the toast would hide on its own partway through the comparison
await projectPage.notification.dismissBtn.click();
await expect(projectPage.notification.message).toBeHidden();

await expect(page).toHaveScreenshot("project-page-builds-error.png");
});

test("stops loading when the test runs fail", async ({
page,
openProjectPage,
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading