Files
calendar/packages/app-store/routing-forms/playwright/tests/basic.e2e.ts
T
Keith WilliamsGitHubkeith@cal.com <keithwillcode@gmail.com>Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
0e3696f20c chore: dedupe yarn.lock to remove duplicate package versions (#26068)
* chore: dedupe yarn.lock to remove duplicate package versions

- Consolidated 762 duplicate package versions
- Reduced yarn.lock from 51,211 lines (1.7MB) to 44,471 lines (1.5MB)
- Removed 7,071 lines (~200KB) of redundant dependency entries
- Major packages deduplicated include: resolve, semver, type-fest, commander, glob, lru-cache, dotenv, @babel/* packages, typescript, tslib, postcss, and many more

Co-Authored-By: keith@cal.com <keithwillcode@gmail.com>

* fix: resolve type errors in managed event types and react-select components

- Add Zod-compatible versions of allManagedEventTypeProps and unlockedManagedEventTypeProps
  that only include scalar fields (excludes Prisma relation fields)
- Update handleChildrenEventTypes.ts and queries.ts to use the new Zod-compatible props
- Fix react-select CSS type errors in Select.tsx files by using Object.assign
  instead of spread operator to avoid TypeScript type inference issues
- Fix lint warning in updateNewTeamMemberEventTypes by using if statement

Co-Authored-By: keith@cal.com <keithwillcode@gmail.com>

* fix: derive eventType parameter type from actual query result

Use Awaited<ReturnType<typeof getEventTypesToAddNewMembers>>[number] to ensure
type safety at call sites, avoiding Prisma type namespace mismatches.

Co-Authored-By: keith@cal.com <keithwillcode@gmail.com>

* fix: disable turbo cache for @calcom/trpc#build to prevent stale type errors

Co-Authored-By: keith@cal.com <keithwillcode@gmail.com>

* fix: correct field names in API v1 validation schemas

- Remove timeZone from booking schema (field doesn't exist on Booking model)
- Remove bookingId from destination-calendar schema (field doesn't exist, only booking relation)
- Change avatar to avatarUrl in user schema (correct field name)

Co-Authored-By: keith@cal.com <keithwillcode@gmail.com>

* fix: use Object.assign for react-select styles to fix TypeScript spread type errors

The spread operator causes TypeScript to compute an incompatible type with
CSSObjectWithLabel due to the accentColor property. Using Object.assign
preserves the correct type inference.

Fixed files:
- FormEdit.tsx
- DestinationCalendarSelector.tsx (features and platform)
- TimezoneSelect.tsx
- ApiKeyDialogForm.tsx
- Select.tsx (features/form)
- WebhookForm.tsx

Also fixed pre-existing lint warnings:
- Constant truthiness in label assignment
- Unused variant parameter

Co-Authored-By: keith@cal.com <keithwillcode@gmail.com>

* fix: remove extra fields from allManagedEventTypePropsForZod to match original behavior

The Zod-compatible version should only include scalar fields that were in the
original allManagedEventTypeProps. Removed instantMeetingScheduleId, profileId,
rrSegmentQueryValue, and assignRRMembersUsingSegment which were incorrectly
added and caused test failures by including extra fields in Prisma update payloads.

Co-Authored-By: keith@cal.com <keithwillcode@gmail.com>

* fix: remove access to fields not in Zod schema

- Remove unnecessary destructuring of profileId and instantMeetingScheduleId
  from managedEventTypeValues in handleChildrenEventTypes.ts (these fields
  are already sourced from other variables)
- Set rrSegmentQueryValue to undefined directly in queries.ts instead of
  accessing it from managedEventTypeValues (not applicable for managed children)

Co-Authored-By: keith@cal.com <keithwillcode@gmail.com>

* fix: add name property to mock credentials and revert turbo cache change

- Add name property to MockCredential type and factory function in
  InstallAppButtonChild.test.tsx to match expected credentials type
- Revert turbo cache disable for @calcom/trpc#build (per review comment)

Co-Authored-By: keith@cal.com <keithwillcode@gmail.com>

* fix: revert API v1 validation changes and restore eslint comment

- Revert API v1 validation changes (booking.ts, destination-calendar.ts,
  user.ts) since API v1 is deprecated and these could be breaking changes
- Restore eslint-disable comment for @typescript-eslint/no-empty-function
  in Select.tsx that was accidentally removed

Co-Authored-By: keith@cal.com <keithwillcode@gmail.com>

* fix: add inputs to @calcom/trpc#build to properly invalidate cache

Co-Authored-By: keith@cal.com <keithwillcode@gmail.com>

* fix: properly fix API v1 validation schemas for stricter zod 3.25.76

The yarn dedupe upgraded zod from 3.22.4 to 3.25.76, which has stricter
.pick() typing that now properly rejects picking non-existent fields.

Changes:
- user.ts: Pick avatarUrl (actual Prisma field) and extend with avatar
  for API v1 backward compatibility
- booking.ts: Remove timeZone from pick (not a field on Booking model,
  only exists in nested attendees/user objects)
- destination-calendar.ts: Remove bookingId from pick (not a field on
  DestinationCalendar model)

Co-Authored-By: keith@cal.com <keithwillcode@gmail.com>

* fix: remove turbo.json inputs for @calcom/trpc#build to use cached artifacts

Co-Authored-By: keith@cal.com <keithwillcode@gmail.com>

* fix: use robust selector for react-select option in routing forms E2E test

The previous selector used .nth(1) which assumed the email text appeared
exactly twice in the DOM in a specific order. This broke when react-select
was upgraded from 5.7.2 to 5.8.0 via yarn dedupe.

The new approach waits for the react-select listbox to appear and clicks
the option within it, which is more robust against DOM structure changes.

Co-Authored-By: keith@cal.com <keithwillcode@gmail.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-12-20 23:30:03 -03:00

1167 lines
42 KiB
TypeScript

import type { Locator, Page } from "@playwright/test";
import { expect } from "@playwright/test";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { prisma } from "@calcom/prisma";
import { AttributeType, MembershipRole, SchedulingType } from "@calcom/prisma/enums";
import type { Fixtures } from "@calcom/web/playwright/lib/fixtures";
import { test } from "@calcom/web/playwright/lib/fixtures";
import { selectInteractions } from "@calcom/web/playwright/lib/pageObject";
import {
confirmBooking,
getEmailsReceivedByUser,
gotoRoutingLink,
selectFirstAvailableTimeSlotNextMonth,
testEmail,
testName,
} from "@calcom/web/playwright/lib/testUtils";
import {
addForm,
saveCurrentForm,
verifySelectOptions,
addOneFieldAndDescriptionAndSaveForm,
} from "./testUtils";
function todo(title: string) {
// eslint-disable-next-line playwright/no-skipped-test
test.skip(title, () => {});
}
const Identifiers = {
multi: "multi",
multiNewFormat: "multi-new-format",
select: "test-select",
selectNewFormat: "test-select-new-format",
};
async function enableContactOwnerOverride(page: Page) {
await page.click("text=Contact owner will be the Round Robin host if available");
}
async function selectFirstAttributeOption({ fromLocator }: { fromLocator: Locator }) {
await selectOptionUsingLocator({
locator: fromLocator,
option: 1,
});
}
async function selectFirstValueForAttributeValue({
fromLocator,
option,
}: {
fromLocator: Locator;
option: number;
}) {
await selectOptionUsingLocator({
locator: fromLocator,
option,
});
}
async function selectOperatorOption({ fromLocator, option }: { fromLocator: Locator; option: number }) {
await selectOptionUsingLocator({
locator: fromLocator,
option,
});
}
async function addAttributeRoutingRule(page: Page) {
// TODO: Use a better selector maybe?
await page.locator('text="Add rule"').nth(1).click();
const attributeQueryBuilder = page.locator(".group-container").nth(1);
const attributeSelectorFirstRule = attributeQueryBuilder.locator(".rule--field").nth(0);
await selectFirstAttributeOption({
fromLocator: attributeSelectorFirstRule,
});
const attributeValueSelector = attributeQueryBuilder.locator(".rule--value").nth(0);
await selectFirstValueForAttributeValue({
fromLocator: attributeValueSelector,
// Select 'Value of Field Short Text' option
option: 1,
});
}
async function addAttributeRoutingRuleWithOperator(page: Page, valueFrom: string, valueTo: string) {
await page.locator('text="Add rule"').nth(1).click();
const attributeQueryBuilder = page.locator(".group-container").nth(1);
const attributeSelectorFirstRule = attributeQueryBuilder.locator(".rule--field").nth(0);
await selectFirstAttributeOption({
fromLocator: attributeSelectorFirstRule,
});
const attributeOperatorSelector = attributeQueryBuilder.locator(".rule--operator").nth(0);
await selectOperatorOption({
fromLocator: attributeOperatorSelector,
option: 7,
});
const attributeValueSelector = attributeQueryBuilder.locator(".rule--value").nth(0);
await attributeValueSelector.locator("input").nth(0).fill(valueFrom);
await attributeValueSelector.locator("input").nth(1).fill(valueTo);
}
async function selectFirstEventRedirectOption(page: Page) {
await selectOption({
selector: {
selector: ".data-testid-eventTypeRedirectUrl-select",
nth: 0,
},
option: 2,
page,
});
}
test.describe("Routing Forms", () => {
test.describe("Zero State Routing Forms", () => {
test("should be able to add a new form and view it", async ({ page }) => {
const formId = await addForm(page);
await page.click('[data-testid="back-button"]');
await page.waitForSelector('[data-testid="routing-forms-list"]');
// Ensure that it's visible in forms list
await expect(page.locator('[data-testid="routing-forms-list"] > div')).toHaveCount(1);
await gotoRoutingLink({ page, formId });
await expect(page.locator("text=Test Form Name")).toBeVisible();
await page.goto(`apps/routing-forms/route-builder/${formId}`);
await page.waitForSelector('[data-testid="add-route-button"]');
await expect(page.locator('[data-testid="add-route-button"]')).toBeVisible();
});
test.skip("should be able to disable form", async ({ page }) => {
const formId = await addForm(page);
await page.click('[data-testid="back-button"]');
await disableForm(page);
await gotoRoutingLink({ page, formId });
await expect(page.locator('[data-testid="404-page"]')).toBeVisible();
});
test("recently added form appears first in the list", async ({ page }) => {
await addForm(page, { name: "Test Form 1" });
await page.goto(`apps/routing-forms/forms`);
await page.waitForSelector('[data-testid="routing-forms-list"]');
await expect(page.locator('[data-testid="routing-forms-list"] > div h1')).toHaveCount(1);
await addForm(page, { name: "Test Form 2" });
await page.goto(`apps/routing-forms/forms`);
await page.waitForSelector('[data-testid="routing-forms-list"]');
await expect(page.locator('[data-testid="routing-forms-list"] > div h1')).toHaveCount(2);
const firstForm = page.locator('[data-testid="routing-forms-list"] > div h1').first();
await expect(firstForm).toHaveText("Test Form 2");
});
test("should be able to edit a newly created form", async ({ page }) => {
const formId = await addForm(page);
const description = "Test Description";
const label = "Test Label";
const createdFields: Record<number, { label: string; typeIndex: number }> = {};
const { fieldTypesList: types, fields } = await addAllTypesOfFieldsAndSaveForm(formId, page, {
description,
label,
});
await page.click('[data-testid="settings-button"]');
await expect(page.locator('[data-testid="description"]')).toHaveValue(description);
await expect(page.locator('[data-testid="field"]')).toHaveCount(types.length);
await page.click('[data-testid="settings-slider-over-cancel"]');
fields.forEach((item, index) => {
createdFields[index] = { label: item.label, typeIndex: index };
});
await expectCurrentFormToHaveFields(page, createdFields, types);
await page.locator('[data-testid="toggle-group-item-route-builder"]').nth(1).click();
await page.waitForURL("/routing/route-builder/**");
await page.click('[data-testid="add-route-button"]');
await page.click('[data-testid="add-rule"]');
const options = Object.values(createdFields).map((item) => item.label);
await verifyFieldOptionsInRule(options, page);
});
// This feature is disable till it is fully supported and tested with Routing Form with Attributes.
test.describe.skip("F1<-F2 Relationship", () => {
test("Create relationship by adding F1 as route.Editing F1 should update F2", async ({ page }) => {
const form1Id = await addForm(page, { name: "F1" });
await page.goto(`/routing-forms/forms`);
const form2Id = await addForm(page, { name: "F2" });
await addOneFieldAndDescriptionAndSaveForm(form1Id, page, {
name: "F1",
description: "Form 1 Description",
field: {
label: "F1 Field1",
typeIndex: 1,
},
});
const { types } = await addOneFieldAndDescriptionAndSaveForm(form2Id, page, {
name: "F2",
description: "Form 2 Description",
field: {
label: "F2 Field1",
//TODO: Maybe choose some other type and choose type by it's name and not index
typeIndex: 1,
},
});
// Add F1 as Router to F2
await page.goto(`/routing-forms/route-builder/${form2Id}`);
// await addNewRoute(page, {
// // It should be F1. TODO: Verify that it's F1
// routeSelectNumber: 2,
// });
await addNewRoute(page);
await saveCurrentForm(page);
// Expect F1 fields to be available in F2
await page.goto(`/routing-forms/form-edit/${form2Id}`);
//FIXME: Figure out why this delay is required. Without it field count comes out to be 1 only
await new Promise((resolve) => setTimeout(resolve, 1000));
await expect(page.locator('[data-testid="field"]')).toHaveCount(2);
await expectCurrentFormToHaveFields(page, { 1: { label: "F1 Field1", typeIndex: 1 } }, types);
// Add 1 more field in F1
await addOneFieldAndDescriptionAndSaveForm(form1Id, page, {
name: "F1",
field: {
label: "F1 Field2",
typeIndex: 1,
},
});
await page.goto(`/routing-forms/form-edit/${form2Id}`);
//FIXME: Figure out why this delay is required. Without it field count comes out to be 1 only
await new Promise((resolve) => setTimeout(resolve, 1000));
await expect(page.locator('[data-testid="field"]')).toHaveCount(3);
await expectCurrentFormToHaveFields(page, { 2: { label: "F1 Field2", typeIndex: 1 } }, types);
});
todo("Create relationship by using duplicate with live connect");
});
test("should be able to submit a prefilled form with all types of fields", async ({ page }) => {
const formId = await addForm(page);
// Click desktop toggle group item
await page.locator('[data-testid="toggle-group-item-route-builder"]').nth(1).click();
await page.waitForURL("/routing/route-builder/**");
await addNewRoute(page);
await selectOption({
selector: {
selector: ".data-testid-select-routing-action",
nth: 0,
},
option: 2,
page,
});
await page.fill("[name=externalRedirectUrl]", `${WEBAPP_URL}/pro`);
await saveCurrentForm(page);
const { fields } = await addAllTypesOfFieldsAndSaveForm(formId, page, {
description: "Description",
label: "Test Field",
});
const queryString =
"firstField=456&Test-Field-Number=456&Test-Field-Single-Selection=456&Test-Field-Multiple-Selection=456&Test-Field-Multiple-Selection=789&Test-Field-Phone=456&Test-Field-Email=456@example.com";
await gotoRoutingLink({ page, queryString });
await page.fill('[data-testid="form-field-Test-Field-Long-Text"]', "manual-fill");
await expect(page.locator('[data-testid="form-field-firstField"]')).toHaveValue("456");
await expect(page.locator('[data-testid="form-field-Test-Field-Number"]')).toHaveValue("456");
// TODO: Verify select and multiselect has prefilled values.
// expect(await page.locator(`[data-testid="form-field-Test-Field-Select"]`).inputValue()).toBe("456");
// expect(await page.locator(`[data-testid="form-field-Test-Field-MultiSelect"]`).inputValue()).toBe("456");
await expect(page.locator('[data-testid="form-field-Test-Field-Phone"]')).toHaveValue("456");
await expect(page.locator('[data-testid="form-field-Test-Field-Email"]')).toHaveValue(
"456@example.com"
);
await page.click('button[type="submit"]');
await page.waitForURL((url) => {
return url.pathname.endsWith("/pro");
});
const url = new URL(page.url());
// Coming from the response filled by booker
expect(url.searchParams.get("firstField")).toBe("456");
// All other params come from prefill URL
expect(url.searchParams.get("Test-Field-Number")).toBe("456");
expect(url.searchParams.get("Test-Field-Long-Text")).toBe("manual-fill");
expect(url.searchParams.get("Test-Field-Multiple-Selection")).toBe("456");
expect(url.searchParams.getAll("Test-Field-Multiple-Selection")).toMatchObject(["456", "789"]);
expect(url.searchParams.get("Test-Field-Phone")).toBe("456");
expect(url.searchParams.get("Test-Field-Email")).toBe("456@example.com");
});
// TODO: How to install the app just once?
test.beforeEach(async ({ page, users }) => {
const user = await users.create(
{ username: "routing-forms" },
{
hasTeam: true,
}
);
await user.apiLogin();
});
test.afterEach(async ({ users }) => {
// This also delete forms on cascade
await users.deleteAll();
});
});
todo("should be able to duplicate form");
test.describe("Routing Form to Event Booking", () => {
test("should redirect to event page and successfully complete booking", async ({ page, users }) => {
const user = await users.create(
{ username: "routing-to-booking" },
{
hasTeam: true,
}
);
await user.apiLogin();
// Create a routing form
const formId = await addForm(page, { name: "Event Redirect Form" });
await addShortTextFieldAndSaveForm({ page, formId });
await page.locator('[data-testid="toggle-group-item-route-builder"]').nth(1).click();
await page.waitForURL("/routing/route-builder/**");
await addNewRoute(page);
await selectFirstEventRedirectOption(page);
await saveCurrentForm(page);
await gotoRoutingLink({ page, formId });
await page.fill('[data-testid="form-field-short-text"]', "Test Input");
await page.click('button[type="submit"]');
// Wait for redirect and verify presence of valid routingFormResponseId in URL
await page.waitForURL((url) => {
const routingFormResponseId = url.searchParams.get("cal.routingFormResponseId");
const parsedId = Number(routingFormResponseId);
return !!routingFormResponseId && !isNaN(parsedId);
});
await selectFirstAvailableTimeSlotNextMonth(page);
// Fill booking form
await page.fill('[name="name"]', testName);
await page.fill('[name="email"]', testEmail);
await page.fill('[name="notes"]', "Booked via routing form");
await confirmBooking(page);
// Verify booking was successful
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
await expect(page.locator(`[data-testid="attendee-name-${testName}"]`)).toBeVisible();
await expect(page.locator(`[data-testid="attendee-email-${testEmail}"]`)).toBeVisible();
});
test.afterEach(async ({ users }) => {
await users.deleteAll();
});
});
test.describe("Seeded Routing Form ", () => {
test.beforeEach(async ({ page }) => {
await page.goto(`/routing-forms/forms`);
});
test.afterEach(async ({ users }) => {
// This also delete forms on cascade
await users.deleteAll();
});
const createUserAndLogin = async function ({ users, page }: { users: Fixtures["users"]; page: Page }) {
const user = await users.create(
{ username: "routing-forms" },
{ seedRoutingForms: true, hasTeam: true }
);
await user.apiLogin();
return user;
};
test("Routing Link - Reporting and CSV Download ", async ({ page, users }) => {
const user = await createUserAndLogin({ users, page });
const routingForm = user.routingForms[0];
// Fill form when you are logged out
await users.logout();
await fillSeededForm(page, routingForm.id);
// Log back in to view form responses.
await user.apiLogin();
await page.goto(`apps/routing-forms/route-builder/${routingForm.id}`);
const downloadPromise = page.waitForEvent("download");
await page.locator('[data-testid="form-dropdown"]').nth(1).click();
await page.locator('[data-testid="download-responses"]').click();
const download = await downloadPromise;
const downloadStream = await download.createReadStream();
expect(download.suggestedFilename()).toEqual(`${routingForm.name}-${routingForm.id}.csv`);
const csv: string = await new Promise((resolve) => {
let body = "";
downloadStream?.on("data", (chunk) => {
body += chunk;
});
downloadStream?.on("end", () => {
resolve(body);
});
});
const csvRows = csv.trim().split("\n");
const csvHeaderRow = csvRows[0];
expect(csvHeaderRow).toEqual(
"Test field,Multi Select(with Legacy `selectText`),Multi Select,Legacy Select,Select,Submission Time"
);
const firstResponseCells = csvRows[1].split(",");
const secondResponseCells = csvRows[2].split(",");
const thirdResponseCells = csvRows[3].split(",");
expect(firstResponseCells.slice(0, -1).join(",")).toEqual(
"event-routing,Option-2,Option-2,Option-2,Option-2"
);
expect(new Date(firstResponseCells.at(-1) as string).getDay()).toEqual(new Date().getDay());
expect(secondResponseCells.slice(0, -1).join(",")).toEqual(
"external-redirect,Option-2,Option-2,Option-2,Option-2"
);
expect(new Date(secondResponseCells.at(-1) as string).getDay()).toEqual(new Date().getDay());
expect(thirdResponseCells.slice(0, -1).join(",")).toEqual(
"custom-page,Option-2,Option-2,Option-2,Option-2"
);
expect(new Date(thirdResponseCells.at(-1) as string).getDay()).toEqual(new Date().getDay());
});
test("Router URL should work", async ({ page, users }) => {
const user = await createUserAndLogin({ users, page });
const routingForm = user.routingForms[0];
// Router should be publicly accessible
await users.logout();
await page.goto(`/router?form=${routingForm.id}&Test field=event-routing`);
await page.waitForURL((url) => {
return url.pathname.endsWith("/pro/30min") && url.searchParams.get("Test field") === "event-routing";
});
await page.goto(`/router?form=${routingForm.id}&Test field=external-redirect`);
await page.waitForURL((url) => {
return url.pathname.endsWith("/pro") && url.searchParams.get("Test field") === "external-redirect";
});
await page.goto(`/router?form=${routingForm.id}&Test field=custom-page`);
await expect(page.locator("text=Custom Page Result")).toBeVisible();
await page.goto(`/router?form=${routingForm.id}&Test field=doesntmatter&${Identifiers.multi}=Option-2`);
await expect(page.locator("text=Multiselect(Legacy) chosen")).toBeVisible({ timeout: 10000 });
await page.goto(
`/router?form=${routingForm.id}&Test field=doesntmatter&${Identifiers.multiNewFormat}=d1302635-9f12-17b1-9153-c3a854649182`
);
await expect(page.locator("text=Multiselect chosen")).toBeVisible({ timeout: 10000 });
// Negative test - Ensures that the "No logic" situation where a Route is considered to be always passing isn't hitting
await page.goto(`/router?form=${routingForm.id}&Test field=kuchbhi`);
await page.waitForURL((url) => {
return url.searchParams.get("Test field") === "kuchbhi";
});
expect(page.url()).not.toContain("/pro/30min");
});
test("Routing Link should validate fields", async ({ page, users }) => {
const user = await createUserAndLogin({ users, page });
const routingForm = user.routingForms[0];
await gotoRoutingLink({ page, formId: routingForm.id });
await page.click('button[type="submit"]');
const firstInputMissingValue = await page.evaluate(() => {
return document.querySelectorAll("input")[0].validity.valueMissing;
});
expect(firstInputMissingValue).toBe(true);
await expect(page.locator('button[type="submit"][disabled]')).toHaveCount(0);
});
test("Test preview should return correct route", async ({ page, users }) => {
const user = await createUserAndLogin({ users, page });
const routingForm = user.routingForms[0];
await page.goto(`apps/routing-forms/form-edit/${routingForm.id}`);
//event redirect
await page.click('[data-testid="preview-button"]');
await page.fill('[data-testid="form-field-Test field"]', "event-routing");
await page.click('[data-testid="submit-button"]');
let routingType = await page.locator('[data-testid="chosen-route-title"]').innerText();
let route = await page.locator('[data-testid="test-routing-result"]').innerText();
expect(routingType).toBe("Event Redirect");
expect(route).toBe("pro/30min");
await page.click('[data-testid="close-results-button"]');
//custom page
await page.click('[data-testid="preview-button"]');
await page.fill('[data-testid="form-field-Test field"]', "custom-page");
await page.click('[data-testid="submit-button"]');
routingType = await page.locator('[data-testid="chosen-route-title"]').innerText();
route = await page.locator('[data-testid="test-routing-result"]').innerText();
expect(routingType).toBe("Custom Page");
expect(route).toBe("Custom Page Result");
await page.click('[data-testid="close-results-button"]');
//external redirect
await page.click('[data-testid="preview-button"]');
await page.fill('[data-testid="form-field-Test field"]', "external-redirect");
await page.click('[data-testid="submit-button"]');
routingType = await page.locator('[data-testid="chosen-route-title"]').innerText();
route = await page.locator('[data-testid="test-routing-result"]').innerText();
expect(routingType).toBe("External Redirect");
expect(route).toBe(`${WEBAPP_URL}/pro`);
await page.click('[data-testid="close-results-button"]');
// Multiselect(Legacy)
await page.click('[data-testid="preview-button"]');
await page.fill('[data-testid="form-field-Test field"]', "doesntmatter");
await page.click(`[data-testid="form-field-${Identifiers.multi}"]`); // Open dropdown
await page.click("text=Option-2"); // Select option
await page.click('[data-testid="submit-button"]');
routingType = await page.locator('[data-testid="chosen-route-title"]').innerText();
route = await page.locator('[data-testid="test-routing-result"]').innerText();
expect(routingType).toBe("Custom Page");
expect(route).toBe("Multiselect(Legacy) chosen");
await page.click('[data-testid="close-results-button"]');
// Multiselect
await page.click('[data-testid="preview-button"]');
await page.fill('[data-testid="form-field-Test field"]', "doesntmatter");
await page.click(`[data-testid="form-field-${Identifiers.multiNewFormat}"]`); // Open dropdown
await page.click("text=Option-2"); // Select option
await page.click('[data-testid="submit-button"]');
routingType = await page.locator('[data-testid="chosen-route-title"]').innerText();
route = await page.locator('[data-testid="test-routing-result"]').innerText();
expect(routingType).toBe("Custom Page");
expect(route).toBe("Multiselect chosen");
await page.click('[data-testid="close-results-button"]');
//fallback route
await page.click('[data-testid="preview-button"]');
await page.fill('[data-testid="form-field-Test field"]', "fallback");
await page.click('[data-testid="submit-button"]');
routingType = await page.locator('[data-testid="chosen-route-title"]').innerText();
route = await page.locator('[data-testid="test-routing-result"]').innerText();
expect(routingType).toBe("Custom Page");
expect(route).toBe("Fallback Message");
await page.click('[data-testid="close-results-button"]');
});
});
test.describe("Form with Attribute Routing - Team Form", () => {
test.beforeEach(async ({ page, users }) => {
const userFixture = await users.create(
{ username: "routing-forms" },
{
hasTeam: true,
isOrg: true,
hasSubteam: true,
schedulingType: SchedulingType.ROUND_ROBIN,
}
);
const orgMembership = await userFixture.getOrgMembership();
const createdAttribute = await prisma.attribute.create({
data: {
teamId: orgMembership.teamId,
type: AttributeType.SINGLE_SELECT,
name: "Company Size",
slug: `company-size-orgId-${orgMembership.teamId}`,
options: {
create: [
{
slug: "large",
value: "large",
},
{
slug: "medium",
value: "medium",
},
{
slug: "small",
value: "small",
},
],
},
},
include: {
options: true,
},
});
await prisma.attributeToUser.create({
data: {
member: {
connect: {
userId_teamId: {
userId: userFixture.id,
teamId: orgMembership.teamId,
},
},
},
attributeOption: {
connect: {
id: createdAttribute.options[0].id,
},
},
},
});
await userFixture.apiLogin();
});
test.afterEach(async ({ users }) => {
// This also delete forms on cascade
await users.deleteAll();
});
test("should be able to add attribute routing to a newly created team form", async ({ page }) => {
const formId = await addForm(page, {
forTeam: true,
});
await addShortTextFieldAndSaveForm({
page,
formId,
});
// Click toggle group item
await page.locator('[data-testid="toggle-group-item-route-builder"]').nth(1).click();
await page.waitForURL("/routing/route-builder/**");
await page.click('[data-testid="add-route-button"]');
// This would select Round Robin event that we created above
await selectFirstEventRedirectOption(page);
await addAttributeRoutingRule(page);
await saveCurrentForm(page);
await (async function testPreviewWhereThereIsMatch() {
await page.click('[data-testid="preview-button"]');
await page.fill('[data-testid="form-field-short-text"]', "large");
await page.click('[data-testid="submit-button"]');
await page.waitForSelector("text=@example.com");
await page.click('[data-testid="close-results-button"]');
})();
await (async function testPreviewWhereThereIsNoMatch() {
await page.click('[data-testid="preview-button"]');
await page.fill('[data-testid="form-field-short-text"]', "medium");
await page.click('[data-testid="submit-button"]');
await expect(page.locator('[data-testid="attribute-logic-matched"]')).toHaveText("No");
await expect(page.locator('[data-testid="attribute-logic-fallback-matched"]')).toHaveText("Yes");
await page.click('[data-testid="close-results-button"]');
})();
});
});
test.describe("Form with Attribute Routing with Between operator - Not Matching - Team Form", () => {
test.beforeEach(async ({ page, users }) => {
const userFixture = await users.create(
{ username: "routing-forms" },
{
hasTeam: true,
isOrg: true,
hasSubteam: true,
schedulingType: SchedulingType.ROUND_ROBIN,
}
);
const orgMembership = await userFixture.getOrgMembership();
const createdAttribute = await prisma.attribute.create({
data: {
teamId: orgMembership.teamId,
type: AttributeType.NUMBER,
name: "Company Size",
slug: `company-size-orgId-${orgMembership.teamId}`,
options: {
create: [
{
slug: "10",
value: "10",
},
],
},
},
include: {
options: true,
},
});
await prisma.attributeToUser.create({
data: {
member: {
connect: {
userId_teamId: {
userId: userFixture.id,
teamId: orgMembership.teamId,
},
},
},
attributeOption: {
connect: {
id: createdAttribute.options[0].id,
},
},
},
});
await userFixture.apiLogin();
});
test.afterEach(async ({ users }) => {
// This also delete forms on cascade
await users.deleteAll();
});
test("should not match any member if between operator values are input such that", async ({ page }) => {
await addForm(page, {
forTeam: true,
});
await page.locator('[data-testid="toggle-group-item-route-builder"]').nth(1).click();
await page.waitForURL("/routing/route-builder/**");
await addNewRoute(page);
// This would select Round Robin event that we created above
await selectFirstEventRedirectOption(page);
await addAttributeRoutingRuleWithOperator(page, "1", "5");
await saveCurrentForm(page);
// asserting there is no match as attribute value for the member is 10, while input is between 1 and 5
await (async function testPreviewWhereThereIsNoMatch() {
await page.click('[data-testid="preview-button"]');
await page.click('[data-testid="submit-button"]');
await expect(page.locator('[data-testid="attribute-logic-matched"]')).toHaveText("No");
await expect(page.locator('[data-testid="attribute-logic-fallback-matched"]')).toHaveText("Yes");
await page.click('[data-testid="close-results-button"]');
})();
});
});
test.describe("Form with Attribute Routing with Between operator - Matching - Team Form", () => {
test.beforeEach(async ({ page, users }) => {
const userFixture = await users.create(
{ username: "routing-forms" },
{
hasTeam: true,
isOrg: true,
hasSubteam: true,
schedulingType: SchedulingType.ROUND_ROBIN,
}
);
const orgMembership = await userFixture.getOrgMembership();
const createdAttribute = await prisma.attribute.create({
data: {
teamId: orgMembership.teamId,
type: AttributeType.NUMBER,
name: "Company Size",
slug: `company-size-orgId-${orgMembership.teamId}`,
options: {
create: [
{
slug: "3",
value: "3",
},
],
},
},
include: {
options: true,
},
});
await prisma.attributeToUser.create({
data: {
member: {
connect: {
userId_teamId: {
userId: userFixture.id,
teamId: orgMembership.teamId,
},
},
},
attributeOption: {
connect: {
id: createdAttribute.options[0].id,
},
},
},
});
await userFixture.apiLogin();
});
test.afterEach(async ({ users }) => {
// This also delete forms on cascade
await users.deleteAll();
});
test("should match the member if between operator values are input such that", async ({ page }) => {
await addForm(page, {
forTeam: true,
});
await page.locator('[data-testid="toggle-group-item-route-builder"]').nth(1).click();
await page.waitForURL("/routing/route-builder/**");
await addNewRoute(page);
// This would select Round Robin event that we created above
await selectFirstEventRedirectOption(page);
await addAttributeRoutingRuleWithOperator(page, "1", "5");
await saveCurrentForm(page);
// asserting there is a match as attribute value for the member is 3 that is between 1 and 5
await (async function testPreviewWhereThereIsMatch() {
await page.click('[data-testid="preview-button"]');
await page.click('[data-testid="submit-button"]');
await page.waitForSelector("text=@example.com");
await page.click('[data-testid="close-results-button"]');
})();
});
});
test.describe("Team Routing Form", () => {
test.afterEach(async ({ users }) => {
await users.deleteAll();
});
const createUserAndTeamRoutingForm = async ({
users,
page,
}: {
users: Fixtures["users"];
page: Page;
}) => {
const owner = await users.create(
{ username: "routing-forms" },
{
hasTeam: true,
isOrg: true,
hasSubteam: true,
}
);
const { team } = await owner.getFirstTeamMembership();
await owner.apiLogin();
const formId = await addForm(page, {
forTeam: true,
});
await addShortTextFieldAndSaveForm({
page,
formId,
});
await page.locator('[data-testid="toggle-group-item-route-builder"]').nth(1).click();
await page.waitForURL("/routing/route-builder/**");
await addNewRoute(page);
await selectOption({
selector: {
selector: ".data-testid-select-routing-action",
nth: 0,
},
option: 2,
page,
});
await page.fill("[name=externalRedirectUrl]", `${WEBAPP_URL}/pro`);
await saveCurrentForm(page);
return {
formId,
teamId: team.id,
owner,
};
};
const selectSendMailToAllMembers = async ({ page, formId }: { page: Page; formId: string }) => {
await page.goto(`apps/routing-forms/form-edit/${formId}`);
await page.getByTestId("settings-button").click();
await page.getByTestId("assign-all-team-members-toggle").click();
await page.getByTestId("settings-slider-over-done").click();
await saveCurrentForm(page);
};
const selectSendMailToUser = async ({
page,
formId,
text,
}: {
page: Page;
formId: string;
text: string;
}) => {
await page.goto(`apps/routing-forms/form-edit/${formId}`);
await page.getByTestId("settings-button").click();
await page.click('[data-testid="routing-form-select-members"]');
// Wait for the react-select menu to appear and click the option within it
// This is more robust than using .nth(1) which assumes specific DOM structure
const menuListbox = page.locator('[id$="-listbox"]');
await menuListbox.waitFor({ state: "visible" });
await menuListbox.getByText(text).click();
await page.getByTestId("settings-slider-over-done").click();
await saveCurrentForm(page);
};
const addNewUserToTeam = async ({ users, teamId }: { users: Fixtures["users"]; teamId: number }) => {
const newUser = await users.create();
await prisma.membership.create({
data: {
teamId: teamId,
userId: newUser.id,
role: MembershipRole.MEMBER,
accepted: true,
},
});
return newUser;
};
const goToRoutingLinkAndSubmit = async ({ page, formId }: { page: Page; formId: string }) => {
await gotoRoutingLink({ page, formId });
await page.fill('[data-testid="form-field-short-text"]', "test");
await page.click('button[type="submit"]');
await page.waitForURL((url) => {
return url.pathname.endsWith("/pro");
});
};
test("newly added team member gets mail updates, when `Add all team members, including future members` switch is selected", async ({
page,
emails,
users,
}) => {
const { formId, teamId, owner } = await createUserAndTeamRoutingForm({ users, page });
await selectSendMailToAllMembers({ page, formId });
const newUser = await addNewUserToTeam({ users, teamId });
await goToRoutingLinkAndSubmit({ page, formId });
const receivedEmailsOwner = await getEmailsReceivedByUser({ emails, userEmail: owner.email });
expect(receivedEmailsOwner?.total).toBe(1);
const receivedEmailsNewUser = await getEmailsReceivedByUser({ emails, userEmail: newUser.email });
expect(receivedEmailsNewUser?.total).toBe(1);
});
test("newly added team member Not gets mail updates, when `Add all team members, including future members` switch is Not selected", async ({
page,
emails,
users,
}) => {
const { formId, teamId, owner } = await createUserAndTeamRoutingForm({ users, page });
await selectSendMailToUser({ page, formId, text: owner.email as string });
const newUser = await addNewUserToTeam({ users, teamId });
await goToRoutingLinkAndSubmit({ page, formId });
const receivedEmailsOwner = await getEmailsReceivedByUser({ emails, userEmail: owner.email });
expect(receivedEmailsOwner?.total).toBe(1);
const receivedEmailsNewUser = await getEmailsReceivedByUser({ emails, userEmail: newUser.email });
expect(receivedEmailsNewUser?.total).toBe(0);
});
});
});
async function disableForm(page: Page) {
await page.click('[data-testid="toggle-form-switch"]');
await page.waitForSelector(".data-testid-toast-success");
}
async function expectCurrentFormToHaveFields(
page: Page,
fields: {
[key: number]: { label: string; typeIndex: number };
},
types: string[]
) {
for (const [index, field] of Object.entries(fields)) {
expect(await page.inputValue(`[data-testid="fields.${index}.label"]`)).toBe(field.label);
expect(await page.locator(".data-testid-field-type").nth(+index).locator("div").nth(1).innerText()).toBe(
types[field.typeIndex]
);
}
}
async function fillSeededForm(page: Page, routingFormId: string) {
await gotoRoutingLink({ page, formId: routingFormId });
await (async function firstResponse() {
await page.fill('[data-testid="form-field-Test field"]', "event-routing");
await fillAllOptionsBasedFields();
page.click('button[type="submit"]');
await page.waitForURL((url) => {
return url.pathname.endsWith("/pro/30min");
});
})();
await gotoRoutingLink({ page, formId: routingFormId });
await (async function secondResponse() {
await page.fill('[data-testid="form-field-Test field"]', "external-redirect");
await fillAllOptionsBasedFields();
page.click('button[type="submit"]');
await page.waitForURL((url) => {
return url.pathname.endsWith("/pro");
});
})();
await gotoRoutingLink({ page, formId: routingFormId });
await (async function thirdResponse() {
await page.fill('[data-testid="form-field-Test field"]', "custom-page");
await fillAllOptionsBasedFields();
page.click('button[type="submit"]');
await expect(page.locator("text=Custom Page Result")).toBeVisible();
})();
async function fillAllOptionsBasedFields() {
await selectInteractions.chooseOption({
selector: `[data-testid="form-field-${Identifiers.multiNewFormat}"]`,
optionText: "Option-2",
page,
});
await selectInteractions.chooseOption({
selector: `[data-testid="form-field-${Identifiers.multi}"]`,
optionText: "Option-2",
page,
});
await selectInteractions.chooseOption({
selector: `[data-testid="form-field-${Identifiers.selectNewFormat}"]`,
optionText: "Option-2",
page,
});
await selectInteractions.chooseOption({
selector: `[data-testid="form-field-${Identifiers.select}"]`,
optionText: "Option-2",
page,
});
}
}
async function addAllTypesOfFieldsAndSaveForm(
formId: string,
page: Page,
form: { description: string; label: string }
) {
await page.goto(`apps/routing-forms/form-edit/${formId}`);
await page.click('[data-testid="add-field"]');
const { optionsInUi: fieldTypesList } = await verifySelectOptions(
{ selector: ".data-testid-field-type", nth: 0 },
["Email", "Long Text", "Multiple Selection", "Number", "Phone", "Single Selection", "Short Text"],
page
);
const fields = [];
for (let index = 0; index < fieldTypesList.length; index++) {
const fieldTypeLabel = fieldTypesList[index];
const nth = index;
const label = `${form.label} ${fieldTypeLabel}`;
let identifier = "";
if (index !== 0) {
identifier = label;
// Click on the field type dropdown.
await page.locator(".data-testid-field-type").nth(nth).click();
// Click on the dropdown option.
await page.locator(`[data-testid^="select-option-"]`).filter({ hasText: fieldTypeLabel }).click();
} else {
// Set the identifier manually for the first field to test out a case when identifier isn't computed from label automatically
// First field type is by default selected. So, no need to choose from dropdown
identifier = "firstField";
}
if (fieldTypeLabel === "Multiple Selection" || fieldTypeLabel === "Single Selection") {
await page.fill(`[data-testid="fields.${nth}.options.0-input"]`, "123");
await page.fill(`[data-testid="fields.${nth}.options.1-input"]`, "456");
await page.fill(`[data-testid="fields.${nth}.options.2-input"]`, "789");
await page.fill(`[data-testid="fields.${nth}.options.3-input"]`, "10-11-12");
}
await page.fill(`[name="fields.${nth}.label"]`, label);
if (identifier !== label) {
await page.fill(`[name="fields.${nth}.identifier"]`, identifier);
}
if (index !== fieldTypesList.length - 1) {
await page.click('[data-testid="add-field"]');
}
fields.push({ identifier: identifier, label, type: fieldTypeLabel });
}
await page.locator('[data-testid="settings-button"]').scrollIntoViewIfNeeded();
await page.click('[data-testid="settings-button"]');
await page.fill('[data-testid="description"]', form.description);
await page.click('[data-testid="settings-slider-over-done"]');
await saveCurrentForm(page);
return {
fieldTypesList,
fields,
};
}
async function addShortTextFieldAndSaveForm({ page, formId }: { page: Page; formId: string }) {
await page.goto(`apps/routing-forms/form-edit/${formId}`);
await page.click('[data-testid="add-field"]');
await page.locator(".data-testid-field-type").nth(0).click();
await page.fill(`[name="fields.0.label"]`, "Short Text");
await page.fill(`[name="fields.0.identifier"]`, "short-text");
await saveCurrentForm(page);
}
async function selectOption({
page,
selector,
option,
}: {
page: Page;
selector: { selector: string; nth: number };
/**
* Index of option to select. Starts from 1
*/
option: number;
}) {
const locatorForSelect = page.locator(selector.selector).nth(selector.nth);
await locatorForSelect.click();
await locatorForSelect
.locator('[id*="react-select-"][aria-disabled]')
.nth(option - 1)
.click();
}
export async function selectOptionUsingLocator({ locator, option }: { locator: Locator; option: number }) {
await locator.click();
await locator
.locator('[id*="react-select-"][aria-disabled]')
.nth(option - 1)
.click();
}
async function verifyFieldOptionsInRule(options: string[], page: Page) {
await verifySelectOptions(
{
selector: ".rule-container .data-testid-field-select",
nth: 0,
},
options,
page
);
}
async function addNewRoute(page: Page) {
await page.locator('[data-testid="add-route-button"]').click();
}