Files
calendar/apps/web/playwright/teams.e2e.ts
T
RomitGitHubDevin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
7801266dbe fix: remove TeamsCTA from teams loading skeleton (#28323)
* fix: resolve duplicate new-team-btn selector in teams E2E test

Remove TeamsCTA from the loading skeleton to prevent duplicate
data-testid='new-team-btn' elements in the DOM during Next.js
streaming SSR. The skeleton's Suspense fallback and the resolved
page content can briefly coexist, causing Playwright's strict
mode to fail when both contain the same test ID.

Also use .first() in the E2E test as a defensive measure.

Co-Authored-By: romitgabani1 <romitgabani1.work@gmail.com>

* fix: assert single new-team-btn element instead of using .first()

Address Cubic AI review feedback: use toHaveCount(1) assertion
to ensure exactly one new-team-btn exists, rather than .first()
which could mask duplicate-element regressions.

Co-Authored-By: romitgabani1 <romitgabani1.work@gmail.com>

* fix: revert test changes, only remove CTA from skeleton

Per user feedback: only change needed is removing TeamsCTA from
the loading skeleton to prevent users without a team plan from
bypassing the upgrade banner via the skeleton CTA.

Co-Authored-By: romitgabani1 <romitgabani1.work@gmail.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-03-08 13:14:30 +05:30

426 lines
14 KiB
TypeScript

import { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants";
import { prisma } from "@calcom/prisma";
import { SchedulingType } from "@calcom/prisma/enums";
import { expect } from "@playwright/test";
import { test, todo } from "./lib/fixtures";
import {
bookTimeSlot,
confirmReschedule,
fillStripeTestCheckout,
selectFirstAvailableTimeSlotNextMonth,
submitAndWaitForResponse,
testName,
} from "./lib/testUtils";
test.describe.configure({ mode: "parallel" });
test.describe("Teams tests", () => {
test("should render the /teams page", async ({ page, users, context }) => {
const user = await users.create(undefined, { hasTeam: true });
await user.apiLogin();
await page.goto("/teams");
await page.waitForLoadState();
const locator = page.getByRole("heading", { name: "Teams", exact: true });
await expect(locator).toBeVisible();
});
});
test.describe("Teams - NonOrg", () => {
test.afterEach(({ users }) => users.deleteAll());
test("Can create a booking for Collective EventType", async ({ page, users }) => {
const teamMatesObj = [
{ name: "teammate-1" },
{ name: "teammate-2" },
{ name: "teammate-3" },
{ name: "teammate-4" },
];
const owner = await users.create(
{ username: "pro-user", name: "pro-user" },
{
hasTeam: true,
teammates: teamMatesObj,
schedulingType: SchedulingType.COLLECTIVE,
}
);
const { team } = await owner.getFirstTeamMembership();
const { title: teamEventTitle, slug: teamEventSlug } = await owner.getFirstTeamEvent(team.id);
await page.goto(`/team/${team.slug}/${teamEventSlug}`);
await selectFirstAvailableTimeSlotNextMonth(page);
await bookTimeSlot(page);
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
// The title of the booking
const BookingTitle = `${teamEventTitle} between ${team.name} and ${testName}`;
await expect(page.locator("[data-testid=booking-title]")).toHaveText(BookingTitle);
// The booker should be in the attendee list
await expect(page.locator(`[data-testid="attendee-name-${testName}"]`)).toHaveText(testName);
// All the teammates should be in the booking
for (const teammate of teamMatesObj) {
await expect(page.getByText(teammate.name, { exact: true })).toBeVisible();
}
// TODO: Assert whether the user received an email
});
test("Can create a booking for Round Robin EventType", async ({ page, users }) => {
const teamMatesObj = [
{ name: "teammate-1" },
{ name: "teammate-2" },
{ name: "teammate-3" },
{ name: "teammate-4" },
];
const owner = await users.create(
{ username: "pro-user", name: "pro-user" },
{
hasTeam: true,
teammates: teamMatesObj,
schedulingType: SchedulingType.ROUND_ROBIN,
}
);
const { team } = await owner.getFirstTeamMembership();
const { title: teamEventTitle, slug: teamEventSlug } = await owner.getFirstTeamEvent(team.id);
await page.goto(`/team/${team.slug}/${teamEventSlug}`);
await selectFirstAvailableTimeSlotNextMonth(page);
await bookTimeSlot(page);
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
// The person who booked the meeting should be in the attendee list
await expect(page.locator(`[data-testid="attendee-name-${testName}"]`)).toHaveText(testName);
// The title of the booking
const bookingTitle = await page.getByTestId("booking-title").textContent();
expect(
teamMatesObj.concat([{ name: owner.name! }]).some((teamMate) => {
const BookingTitle = `${teamEventTitle} between ${teamMate.name} and ${testName}`;
return BookingTitle === bookingTitle;
})
).toBe(true);
// Since all the users have the same leastRecentlyBooked value
// Anyone of the teammates could be the Host of the booking.
const chosenUser = await page.getByTestId("booking-host-name").textContent();
expect(chosenUser).not.toBeNull();
expect(teamMatesObj.concat([{ name: owner.name! }]).some(({ name }) => name === chosenUser)).toBe(true);
// TODO: Assert whether the user received an email
});
test("Non admin team members cannot create team in org", async ({ page, users }) => {
const teamMateName = "teammate-1";
const owner = await users.create(undefined, {
hasTeam: true,
isOrg: true,
hasSubteam: true,
teammates: [{ name: teamMateName }],
});
const allUsers = await users.get();
const memberUser = allUsers.find((user) => user.name === teamMateName);
// eslint-disable-next-line playwright/no-conditional-in-test
if (memberUser) {
// Add teammate to the sub-team so they have a team membership and see teams list
const org = await owner.getOrgMembership();
const subTeam = await prisma.team.findFirst({
where: { parentId: org.teamId },
select: { id: true },
});
if (!subTeam) throw new Error("Expected a sub-team under the org, but none was found");
await prisma.membership.create({
data: {
teamId: subTeam.id,
userId: memberUser.id,
role: "MEMBER",
accepted: true,
},
});
await memberUser.apiLogin();
await page.goto("/teams");
await expect(page.locator("[data-testid=new-team-btn]")).toBeHidden();
const uniqueName = "test-unique-team-name";
// Go directly to the create team page (new onboarding-v3 style flow)
await page.goto("/settings/teams/new");
await page.waitForLoadState("networkidle");
// Fill team name input
await page.locator('[data-testid="team-name-input"]').fill(uniqueName);
await page.click("[type=submit]");
// cleanup
await prisma.team.delete({ where: { id: org.teamId } });
}
});
test("Can create team with same name as user", async ({ page, users }) => {
const user = await users.create(undefined, { hasTeam: true });
// Name to be used for both user and team
const uniqueName = user.username!;
await user.apiLogin();
await page.goto("/teams");
await test.step("Can create team with same name", async () => {
await page.locator("[data-testid=new-team-btn]").click();
await page.waitForLoadState("networkidle");
// Fill team name input (new onboarding-v3 style flow)
await page.locator('[data-testid="team-name-input"]').fill(uniqueName);
// Click Continue
await page.click("[type=submit]");
// TODO: Figure out a way to make this more reliable
// eslint-disable-next-line playwright/no-conditional-in-test
if (IS_TEAM_BILLING_ENABLED) await fillStripeTestCheckout(page);
// Wait for the invite email page
await expect(page).toHaveURL(/\/settings\/teams\/new\/invite\/email.*$/i);
await page.waitForLoadState("networkidle");
// Skip the invite step
const skipButton = page.locator("[data-testid=skip-invite-button]");
await skipButton.waitFor({ state: "visible", timeout: 10000 });
await skipButton.click();
// Wait for redirect to teams page
await page.waitForURL(/\/teams$/i);
});
await test.step("Can access user and team with same slug", async () => {
// Go to team page and confirm name
const teamUrl = `/team/${uniqueName}`;
await page.goto(teamUrl);
await page.waitForURL(teamUrl);
await expect(page.locator("[data-testid=team-name]")).toHaveText(uniqueName);
// Go to user page and confirm name
const userUrl = `/${uniqueName}`;
await page.goto(userUrl);
await page.waitForURL(userUrl);
await expect(page.locator("[data-testid=name-title]")).toHaveText(uniqueName);
// cleanup team
await prisma.team.deleteMany({ where: { slug: uniqueName } });
});
});
test("Can create a private team", async ({ page, users }) => {
const teamMatesObj = [
{ name: "teammate-1" },
{ name: "teammate-2" },
{ name: "teammate-3" },
{ name: "teammate-4" },
];
const owner = await users.create(
{ username: "pro-user", name: "pro-user" },
{
hasTeam: true,
teammates: teamMatesObj,
schedulingType: SchedulingType.COLLECTIVE,
}
);
await owner.apiLogin();
const { team } = await owner.getFirstTeamMembership();
// Mark team as private
await page.goto(`/settings/teams/${team.id}/settings`);
await Promise.all([
page.click("[data-testid=make-team-private-check]"),
expect(page.locator(`[data-testid=make-team-private-check][data-state="checked"]`)).toBeVisible(),
// according to switch implementation, checked state can be set before mutation is resolved
// so we need to await for req to resolve
page.waitForResponse((res) => res.url().includes("/api/trpc/teams/update")),
]);
// Go to Team's page
await page.goto(`/team/${team.slug}`);
await expect(page.locator('[data-testid="book-a-team-member-btn"]')).toBeHidden();
// Go to members page
await page.goto(`/team/${team.slug}?members=1`);
await expect(page.locator('[data-testid="you-cannot-see-team-members"]')).toBeVisible();
await expect(page.locator('[data-testid="team-members-container"]')).toBeHidden();
});
test("Email Embeds slots are loading for team event types", async ({ page, users }) => {
const teamMatesObj = [
{ name: "teammate-1" },
{ name: "teammate-2" },
{ name: "teammate-3" },
{ name: "teammate-4" },
];
const owner = await users.create(
{ username: "pro-user", name: "pro-user" },
{
hasTeam: true,
teammates: teamMatesObj,
schedulingType: SchedulingType.COLLECTIVE,
}
);
await owner.apiLogin();
const { team } = await owner.getFirstTeamMembership();
const {
title: teamEventTitle,
slug: teamEventSlug,
id: teamEventId,
} = await owner.getFirstTeamEvent(team.id);
await page.goto(`/event-types?teamId=${team.id}`);
await page.getByTestId(`event-type-options-${teamEventId}`).first().click();
await page.getByTestId("embed").click();
await page.getByTestId("email").click();
await page.getByTestId("incrementMonth").click();
await expect(page.getByTestId("no-slots-available")).toBeHidden();
// Check Team Url
const availableTimesUrl = await page.getByTestId("see_all_available_times").getAttribute("href");
await expect(availableTimesUrl).toContain(`/team/${team.slug}/${teamEventSlug}`);
});
todo("Create a Round Robin with different leastRecentlyBooked hosts");
test("Reschedule a Collective EventType booking", async ({ users, page, bookings }) => {
const teamMatesObj = [
{ name: "teammate-1" },
{ name: "teammate-2" },
{ name: "teammate-3" },
{ name: "teammate-4" },
];
const owner = await users.create(
{ username: "pro-user", name: "pro-user" },
{
hasTeam: true,
teammates: teamMatesObj,
schedulingType: SchedulingType.COLLECTIVE,
}
);
const { team } = await owner.getFirstTeamMembership();
const eventType = await owner.getFirstTeamEvent(team.id);
const booking = await bookings.create(owner.id, owner.username, eventType.id);
await page.goto(`/reschedule/${booking.uid}`);
await selectFirstAvailableTimeSlotNextMonth(page);
await confirmReschedule(page);
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
});
todo("Reschedule a Round Robin EventType booking");
});
test.describe("Team Slug Validation", () => {
test.afterEach(({ users, orgs }) => {
users.deleteAll();
orgs.deleteAll();
});
test("Teams in different organizations can have the same slug", async ({ page, users, orgs }) => {
const org1 = await orgs.create({ name: "Organization 1" });
const org2 = await orgs.create({ name: "Organization 2" });
const owner1 = await users.create(
{
organizationId: org1.id,
roleInOrganization: "OWNER",
},
{
hasTeam: true,
teamSlug: "cal",
teamRole: "OWNER",
}
);
const owner2 = await users.create(
{
organizationId: org2.id,
roleInOrganization: "OWNER",
},
{
hasTeam: true,
teamSlug: "calCom",
teamRole: "OWNER",
}
);
const { team: team1 } = await owner1.getFirstTeamMembership();
await owner1.apiLogin();
await page.goto(`/settings/teams/${team1.id}/profile`);
await page.locator('input[name="slug"]').fill("calCom");
await submitAndWaitForResponse(page, "/api/trpc/teams/update?batch=1", {
action: () => page.locator("[data-testid=update-team-profile]").click(),
});
});
test("Teams within same organization cannot have duplicate slugs", async ({ page, users, orgs }) => {
const org = await orgs.create({ name: "Organization 1" });
const owner = await users.create(
{
organizationId: org.id,
roleInOrganization: "OWNER",
},
{
hasTeam: true,
numberOfTeams: 2,
teamRole: "OWNER",
}
);
const teams = await owner.getAllTeamMembership();
await owner.apiLogin();
await page.goto(`/settings/teams/${teams[0].team.id}/profile`);
if (!teams[1].team.slug) throw new Error("Slug not found for team 2");
await page.locator('input[name="slug"]').fill(teams[1].team.slug);
await submitAndWaitForResponse(page, "/api/trpc/teams/update?batch=1", {
action: () => page.locator("[data-testid=update-team-profile]").click(),
expectedStatusCode: 409,
});
});
test("Teams without organization can have same slug as teams in organizations", async ({
page,
users,
orgs,
}) => {
const org = await orgs.create({ name: "Organization 1" });
const orgOwner = await users.create(
{
organizationId: org.id,
roleInOrganization: "OWNER",
},
{
hasTeam: true,
teamSlug: "calCom",
teamRole: "OWNER",
}
);
const teamOwner = await users.create(
{ username: "pro-user", name: "pro-user" },
{
hasTeam: true,
}
);
const { team } = await teamOwner.getFirstTeamMembership();
await teamOwner.apiLogin();
await page.goto(`/settings/teams/${team.id}/profile`);
await page.locator('input[name="slug"]').fill("calCom");
await submitAndWaitForResponse(page, "/api/trpc/teams/update?batch=1", {
action: () => page.locator("[data-testid=update-team-profile]").click(),
});
});
});