import type { Locator, Page, PlaywrightTestArgs } from "@playwright/test"; import { expect } from "@playwright/test"; import type { createUsersFixture } from "playwright/fixtures/users"; import { uuid } from "short-uuid"; import prisma from "@calcom/prisma"; import { WebhookTriggerEvents } from "@calcom/prisma/enums"; import { test } from "./lib/fixtures"; import { testBothBookers } from "./lib/new-booker"; import type { BookerVariants } from "./lib/new-booker"; import { createHttpServer, waitFor, selectFirstAvailableTimeSlotNextMonth } from "./lib/testUtils"; async function getLabelText(field: Locator) { return await field.locator("label").first().locator("span").first().innerText(); } test.describe("Manage Booking Questions", () => { test.afterEach(async ({ users }) => { await users.deleteAll(); }); testBothBookers.describe("For User EventType", (bookerVariant) => { test("Do a booking with a user added question and verify a few thing in b/w", async ({ page, users, context, }, testInfo) => { // Considering there are many steps in it, it would need more than default test timeout test.setTimeout(testInfo.timeout * 3); const user = await createAndLoginUserWithEventTypes({ users }); const webhookReceiver = await addWebhook(user); await test.step("Go to EventType Page ", async () => { const $eventTypes = page.locator("[data-testid=event-types] > li a"); const firstEventTypeElement = $eventTypes.first(); await firstEventTypeElement.click(); }); await runTestStepsCommonForTeamAndUserEventType(page, context, webhookReceiver, bookerVariant); }); }); testBothBookers.describe("For Team EventType", (bookerVariant) => { test("Do a booking with a user added question and verify a few thing in b/w", async ({ page, users, context, }, testInfo) => { // Considering there are many steps in it, it would need more than default test timeout test.setTimeout(testInfo.timeout * 3); const user = await createAndLoginUserWithEventTypes({ users }); const team = await prisma.team.findFirst({ where: { members: { some: { userId: user.id, }, }, }, select: { id: true, }, }); const teamId = team?.id; const webhookReceiver = await addWebhook(undefined, teamId); await test.step("Go to First Team Event", async () => { const $eventTypes = page.locator("[data-testid=event-types]").nth(1).locator("li a"); const firstEventTypeElement = $eventTypes.first(); await firstEventTypeElement.click(); }); await runTestStepsCommonForTeamAndUserEventType(page, context, webhookReceiver, bookerVariant); }); }); }); async function runTestStepsCommonForTeamAndUserEventType( page: Page, context: PlaywrightTestArgs["context"], webhookReceiver: { port: number; close: () => import("http").Server; // eslint-disable-next-line @typescript-eslint/no-explicit-any requestList: (import("http").IncomingMessage & { body?: any })[]; url: string; }, bookerVariant: BookerVariants ) { await page.click('[href$="tabName=advanced"]'); await test.step("Add Question and see that it's shown on Booking Page at appropriate position", async () => { await addQuestionAndSave({ page, question: { name: "how_are_you", type: "Name", label: "How are you?", placeholder: "I'm fine, thanks", required: true, }, }); await doOnFreshPreview(page, context, bookerVariant, async (page) => { const allFieldsLocator = await expectSystemFieldsToBeThere(page); const userFieldLocator = allFieldsLocator.nth(5); await expect(userFieldLocator.locator('[name="how_are_you"]')).toBeVisible(); // There are 2 labels right now. Will be one in future. The second one is hidden expect(await getLabelText(userFieldLocator)).toBe("How are you?"); await expect(userFieldLocator.locator("input[type=text]")).toBeVisible(); }); }); await test.step("Hide Question and see that it's not shown on Booking Page", async () => { await toggleQuestionAndSave({ name: "how_are_you", page, }); await doOnFreshPreview(page, context, bookerVariant, async (page) => { const formBuilderFieldLocator = page.locator('[data-fob-field-name="how_are_you"]'); await expect(formBuilderFieldLocator).toBeHidden(); }); }); await test.step("Show Question Again", async () => { await toggleQuestionAndSave({ name: "how_are_you", page, }); }); await test.step('Try to book without providing "How are you?" response', async () => { await doOnFreshPreview(page, context, bookerVariant, async (page) => { await bookTimeSlot({ page, name: "Booker", email: "booker@example.com" }); await expectErrorToBeThereFor({ page, name: "how_are_you" }); }); }); await test.step("Make rescheduleReason required - It won't be required for a fresh booking", async () => { await toggleQuestionRequireStatusAndSave({ required: true, name: "rescheduleReason", page, }); }); const previewTabPage = await test.step("Do a booking and notice that we can book without giving a value for rescheduleReason", async () => { return await doOnFreshPreview( page, context, bookerVariant, async (page) => { const formBuilderFieldLocator = page.locator('[data-fob-field-name="how_are_you"]'); await expect(formBuilderFieldLocator).toBeVisible(); expect( await formBuilderFieldLocator.locator('[name="how_are_you"]').getAttribute("placeholder") ).toBe("I'm fine, thanks"); expect(await getLabelText(formBuilderFieldLocator)).toBe("How are you?"); await formBuilderFieldLocator.locator('[name="how_are_you"]').fill("I am great!"); await bookTimeSlot({ page, name: "Booker", email: "booker@example.com" }); await expect(page.locator("[data-testid=success-page]")).toBeVisible(); expect( await page.locator('[data-testid="field-response"][data-fob-field="how_are_you"]').innerText() ).toBe("I am great!"); await waitFor(() => { expect(webhookReceiver.requestList.length).toBe(1); }); const [request] = webhookReceiver.requestList; const payload = request.body.payload; expect(payload.responses).toMatchObject({ email: { label: "email_address", value: "booker@example.com", }, how_are_you: { label: "How are you?", value: "I am great!", }, name: { label: "your_name", value: "Booker", }, }); expect(payload.location).toBe("integrations:daily"); expect(payload.attendees[0]).toMatchObject({ name: "Booker", email: "booker@example.com", }); expect(payload.userFieldsResponses).toMatchObject({ how_are_you: { label: "How are you?", value: "I am great!", }, }); }, true ); }); await test.step("Do a reschedule and notice that we can't book without giving a value for rescheduleReason", async () => { const page = previewTabPage; await rescheduleFromTheLinkOnPage({ page }); // eslint-disable-next-line playwright/no-page-pause await page.pause(); await expectErrorToBeThereFor({ page, name: "rescheduleReason" }); }); } async function expectSystemFieldsToBeThere(page: Page) { const allFieldsLocator = page.locator("[data-fob-field-name]:not(.hidden)"); const nameLocator = allFieldsLocator.nth(0); const emailLocator = allFieldsLocator.nth(1); // Location isn't rendered unless explicitly set which isn't the case here // const locationLocator = allFieldsLocator.nth(2); const additionalNotes = allFieldsLocator.nth(3); const guestsLocator = allFieldsLocator.nth(4); await expect(nameLocator.locator('[name="name"]')).toBeVisible(); await expect(emailLocator.locator('[name="email"]')).toBeVisible(); await expect(additionalNotes.locator('[name="notes"]')).toBeVisible(); await expect(guestsLocator.locator("button")).toBeVisible(); return allFieldsLocator; } //TODO: Add one question for each type and see they are rendering labels and only once and are showing appropriate native component // Verify webhook is sent with the correct data, DB is correct (including metadata) //TODO: Verify that prefill works async function bookTimeSlot({ page, name, email }: { page: Page; name: string; email: string }) { // --- fill form await page.fill('[name="name"]', name); await page.fill('[name="email"]', email); await page.press('[name="email"]', "Enter"); } /** * 'option' starts from 1 */ async function selectOption({ page, selector, optionText, }: { page: Page; selector: { selector: string; nth: number }; optionText: string; }) { const locatorForSelect = page.locator(selector.selector).nth(selector.nth); await locatorForSelect.click(); await locatorForSelect.locator(`text="${optionText}"`).click(); } async function addQuestionAndSave({ page, question, }: { page: Page; question: { name?: string; type?: string; label?: string; placeholder?: string; required?: boolean; }; }) { await page.click('[data-testid="add-field"]'); if (question.type !== undefined) { await selectOption({ page, selector: { selector: "[id=test-field-type]", nth: 0, }, optionText: question.type, }); } if (question.name !== undefined) { await page.fill('[name="name"]', question.name); } if (question.label !== undefined) { await page.fill('[name="label"]', question.label); } if (question.placeholder !== undefined) { await page.fill('[name="placeholder"]', question.placeholder); } if (question.required !== undefined) { // await page.fill('[name="name"]', question.required); } await page.click('[data-testid="field-add-save"]'); await saveEventType(page); } async function expectErrorToBeThereFor({ page, name }: { page: Page; name: string }) { await expect(page.locator(`[data-testid=error-message-${name}]`)).toHaveCount(1); // TODO: We should either verify the error message or error code in the test so we know that the correct error is shown // Checking for the error message isn't well maintainable as translation can change and we might want to verify in non english language as well. } /** * Opens a fresh preview window and runs the callback on it giving it the preview tab's `page` */ async function doOnFreshPreview( page: Page, context: PlaywrightTestArgs["context"], bookerVariant: BookerVariants, callback: (page: Page) => Promise, persistTab = false ) { const previewTabPage = await openBookingFormInPreviewTab(context, page, bookerVariant); await callback(previewTabPage); if (!persistTab) { await previewTabPage.close(); } return previewTabPage; } async function toggleQuestionAndSave({ name, page }: { name: string; page: Page }) { await page.locator(`[data-testid="field-${name}"]`).locator('[data-testid="toggle-field"]').click(); await saveEventType(page); } async function toggleQuestionRequireStatusAndSave({ required, name, page, }: { required: boolean; name: string; page: Page; }) { await page.locator(`[data-testid="field-${name}"]`).locator('[data-testid="edit-field-action"]').click(); await page .locator('[data-testid="edit-field-dialog"]') .locator('[data-testid="field-required"] button') .locator(`text=${required ? "Yes" : "No"}`) .click(); await page.locator('[data-testid="field-add-save"]').click(); await saveEventType(page); } async function createAndLoginUserWithEventTypes({ users }: { users: ReturnType }) { const user = await users.create(null, { hasTeam: true, }); await user.login(); return user; } async function rescheduleFromTheLinkOnPage({ page, bookerVariant, }: { page: Page; bookerVariant?: BookerVariants; }) { await page.locator('[data-testid="reschedule-link"]').click(); await page.waitForLoadState(); await selectFirstAvailableTimeSlotNextMonth(page); if (bookerVariant === "old-booker") { await page.waitForURL((url) => { return url.pathname.endsWith("/book"); }); } await page.click('[data-testid="confirm-reschedule-button"]'); } async function openBookingFormInPreviewTab( context: PlaywrightTestArgs["context"], page: Page, bookerVariant: BookerVariants ) { const previewTabPromise = context.waitForEvent("page"); await page.locator('[data-testid="preview-button"]').click(); const previewTabPage = await previewTabPromise; await previewTabPage.waitForLoadState(); await selectFirstAvailableTimeSlotNextMonth(previewTabPage); if (bookerVariant === "old-booker") { await previewTabPage.waitForURL((url) => { return url.pathname.endsWith("/book"); }); } return previewTabPage; } async function saveEventType(page: Page) { await page.locator("[data-testid=update-eventtype]").click(); } async function addWebhook( user?: Awaited>, teamId?: number | null ) { const webhookReceiver = createHttpServer(); const data: { id: string; subscriberUrl: string; eventTriggers: WebhookTriggerEvents[]; userId?: number; teamId?: number; } = { id: uuid(), subscriberUrl: webhookReceiver.url, eventTriggers: [ WebhookTriggerEvents.BOOKING_CREATED, WebhookTriggerEvents.BOOKING_CANCELLED, WebhookTriggerEvents.BOOKING_RESCHEDULED, ], }; if (teamId) { data.teamId = teamId; } else if (user) { data.userId = user.id; } await prisma.webhook.create({ data }); return webhookReceiver; }