diff --git a/.github/workflows/e2e-timing-baseline.yml b/.github/workflows/e2e-timing-baseline.yml new file mode 100644 index 00000000000000..98fb2d91ead44b --- /dev/null +++ b/.github/workflows/e2e-timing-baseline.yml @@ -0,0 +1,174 @@ +name: E2E Timing Baseline + +on: + workflow_dispatch: + push: + branches: [ "devin/optimize-e2e-tests-*" ] + +jobs: + e2e-timing: + timeout-minutes: 60 + name: E2E Tests with Timing + runs-on: buildjet-8vcpu-ubuntu-2204 + + strategy: + fail-fast: false + matrix: + shard: [1, 2, 3] + + env: + DATABASE_URL: "postgresql://postgres:postgres@localhost:5432/calendso" + DATABASE_DIRECT_URL: "postgresql://postgres:postgres@localhost:5432/calendso" + NEXTAUTH_SECRET: ${{ secrets.CI_NEXTAUTH_SECRET }} + CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }} + NEXT_PUBLIC_WEBAPP_URL: "http://localhost:3000" + NEXT_PUBLIC_WEBSITE_URL: "http://localhost:3000" + NEXTAUTH_URL: "http://localhost:3000" + E2E_TEST_APPLE_CALENDAR_EMAIL: ${{ secrets.E2E_TEST_APPLE_CALENDAR_EMAIL }} + E2E_TEST_APPLE_CALENDAR_PASSWORD: ${{ secrets.E2E_TEST_APPLE_CALENDAR_PASSWORD }} + PLAYWRIGHT_HEADLESS: 1 + NEXT_PUBLIC_IS_E2E: 1 + STRIPE_CLIENT_ID: ${{ secrets.CI_STRIPE_CLIENT_ID }} + STRIPE_PRIVATE_KEY: ${{ secrets.CI_STRIPE_PRIVATE_KEY }} + PAYMENT_FEE_PERCENTAGE: 0.005 + PAYMENT_FEE_FIXED: 10 + SAML_DATABASE_URL: ${{ secrets.SAML_DATABASE_URL }} + SAML_ADMINS: pro@example.com + NEXTAUTH_COOKIE_DOMAIN: .cal.local + EMAIL_FROM: e2e@cal.com + NEXT_PUBLIC_SENDER_ID: Cal + NEXT_PUBLIC_SENDGRID_SENDER_NAME: Cal.com + TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} + TURBO_TEAM: ${{ vars.TURBO_TEAM }} + + services: + postgres: + image: postgres:13 + credentials: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: calendso + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 + mailhog: + image: mailhog/mailhog + ports: + - 1025:1025 + - 8025:8025 + + steps: + - name: Checkout repo + uses: actions/checkout@v4 + with: + fetch-depth: 2 + + - name: Use Node 18.x + uses: actions/setup-node@v4 + with: + node-version: 18.x + + - name: Cache dependencies + uses: ./.github/actions/yarn-install + + - name: Cache database + uses: ./.github/actions/cache-db + + - name: Cache build + uses: ./.github/actions/cache-build + + - name: Start timing measurement + run: | + echo "E2E_START_TIME=$(date +%s)" >> $GITHUB_ENV + echo "Starting E2E tests at $(date)" + + - name: Run E2E tests with timing + run: | + echo "Running shard ${{ matrix.shard }}/3" + yarn e2e --shard=${{ matrix.shard }}/3 --reporter=html,json + env: + NEXT_PUBLIC_IS_E2E: 1 + + - name: Calculate execution time + if: always() + run: | + END_TIME=$(date +%s) + DURATION=$((END_TIME - E2E_START_TIME)) + echo "E2E execution time for shard ${{ matrix.shard }}: ${DURATION} seconds" + echo "SHARD_DURATION=${DURATION}" >> $GITHUB_ENV + + # Create timing report + mkdir -p timing-reports + echo "{\"shard\": ${{ matrix.shard }}, \"duration\": ${DURATION}, \"timestamp\": \"$(date -Iseconds)\"}" > timing-reports/shard-${{ matrix.shard }}-timing.json + + - name: Upload timing report + if: always() + uses: actions/upload-artifact@v4 + with: + name: timing-report-shard-${{ matrix.shard }} + path: timing-reports/ + retention-days: 30 + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-results-shard-${{ matrix.shard }} + path: | + test-results/ + playwright-report/ + retention-days: 30 + + timing-summary: + needs: e2e-timing + if: always() + runs-on: ubuntu-latest + steps: + - name: Download all timing reports + uses: actions/download-artifact@v4 + with: + pattern: timing-report-shard-* + merge-multiple: true + path: timing-reports/ + + - name: Generate timing summary + run: | + echo "# E2E Test Timing Summary" > timing-summary.md + echo "" >> timing-summary.md + echo "| Shard | Duration (seconds) | Duration (minutes) |" >> timing-summary.md + echo "|-------|-------------------|-------------------|" >> timing-summary.md + + total_duration=0 + for file in timing-reports/*.json; do + if [ -f "$file" ]; then + shard=$(jq -r '.shard' "$file") + duration=$(jq -r '.duration' "$file") + minutes=$((duration / 60)) + seconds=$((duration % 60)) + echo "| $shard | $duration | ${minutes}m ${seconds}s |" >> timing-summary.md + total_duration=$((total_duration + duration)) + fi + done + + total_minutes=$((total_duration / 60)) + total_seconds=$((total_duration % 60)) + echo "" >> timing-summary.md + echo "**Total execution time across all shards: ${total_duration}s (${total_minutes}m ${total_seconds}s)**" >> timing-summary.md + echo "" >> timing-summary.md + echo "Generated at: $(date -Iseconds)" >> timing-summary.md + + cat timing-summary.md + + - name: Upload timing summary + uses: actions/upload-artifact@v4 + with: + name: timing-summary + path: timing-summary.md + retention-days: 30 diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index d85f6d99feb80b..df6415462ad138 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -43,7 +43,7 @@ env: TURBO_TEAM: ${{ secrets.TURBO_TEAM }} jobs: e2e: - timeout-minutes: 20 + timeout-minutes: 15 name: E2E (${{ matrix.shard }}/${{ strategy.job-total }}) runs-on: buildjet-8vcpu-ubuntu-2204 services: @@ -74,7 +74,7 @@ jobs: strategy: fail-fast: false matrix: - shard: [1, 2, 3, 4] + shard: [1, 2, 3] steps: - uses: docker/login-action@v3 with: diff --git a/apps/web/playwright/authentication.e2e.ts b/apps/web/playwright/authentication.e2e.ts new file mode 100644 index 00000000000000..9209904bf4c11d --- /dev/null +++ b/apps/web/playwright/authentication.e2e.ts @@ -0,0 +1,135 @@ +import { expect } from "@playwright/test"; + +import { test } from "./lib/fixtures"; + +test.describe.configure({ mode: "parallel" }); + +test.describe("Authentication", () => { + test("Should be able to login with email and password", async ({ page, users }) => { + const [user] = users.get(); + await page.goto("/auth/login"); + + await page.locator('[name="email"]').fill(user.email); + await page.locator('[name="password"]').fill(user.username || "password"); + await page.locator('[type="submit"]').click(); + + await expect(page).toHaveURL("/event-types"); + await expect(page.locator('[data-testid="user-dropdown"]')).toBeVisible(); + }); + + test("Should show error for invalid credentials", async ({ page }) => { + await page.goto("/auth/login"); + + await page.locator('[name="email"]').fill("invalid@example.com"); + await page.locator('[name="password"]').fill("wrongpassword"); + await page.locator('[type="submit"]').click(); + + await expect(page.locator('[data-testid="error-message"]')).toBeVisible(); + }); + + test("Should be able to logout", async ({ page, users }) => { + const [user] = users.get(); + await user.apiLogin(); + await page.goto("/event-types"); + + await page.locator('[data-testid="user-dropdown"]').click(); + await page.locator('[data-testid="logout-button"]').click(); + + await expect(page).toHaveURL("/auth/login"); + }); + + test("Should redirect to login when accessing protected page", async ({ page }) => { + await page.goto("/event-types"); + await expect(page).toHaveURL(/\/auth\/login/); + }); + + test("Should handle forgot password flow", async ({ page }) => { + await page.goto("/auth/login"); + await page.locator('[data-testid="forgot-password-link"]').click(); + + await expect(page).toHaveURL("/auth/forgot-password"); + await expect(page.locator('[data-testid="forgot-password-form"]')).toBeVisible(); + }); + + test("Should handle signup flow", async ({ page }) => { + await page.goto("/auth/signup"); + + await page.locator('[name="username"]').fill("testuser"); + await page.locator('[name="email"]').fill("test@example.com"); + await page.locator('[name="password"]').fill("password123"); + await page.locator('[type="submit"]').click(); + + await expect(page.locator('[data-testid="signup-success"]')).toBeVisible(); + }); +}); + +test.describe("Two-Factor Authentication", () => { + test("Should be able to enable 2FA", async ({ page, users }) => { + const [user] = users.get(); + await user.apiLogin(); + await page.goto("/settings/security/two-factor-auth"); + + await page.locator('[data-testid="enable-2fa-button"]').click(); + await expect(page.locator('[data-testid="2fa-setup"]')).toBeVisible(); + }); + + test("Should require 2FA code when enabled", async ({ page, users }) => { + const [user] = users.get(); + await user.apiLogin(); + + await page.goto("/settings/security/two-factor-auth"); + await page.locator('[data-testid="enable-2fa-button"]').click(); + + await page.goto("/auth/logout"); + await page.goto("/auth/login"); + + await page.locator('[name="email"]').fill(user.email); + await page.locator('[name="password"]').fill(user.username || "password"); + await page.locator('[type="submit"]').click(); + + await expect(page.locator('[data-testid="2fa-input"]')).toBeVisible(); + }); +}); + +test.describe("OAuth Authentication", () => { + test("Should display OAuth login options", async ({ page }) => { + await page.goto("/auth/login"); + + await expect(page.locator('[data-testid="oauth-google"]')).toBeVisible(); + await expect(page.locator('[data-testid="oauth-github"]')).toBeVisible(); + }); + + test("Should handle OAuth callback", async ({ page }) => { + await page.goto("/auth/login"); + + await page.locator('[data-testid="oauth-google"]').click(); + await expect(page).toHaveURL(/accounts\.google\.com/); + }); +}); + +test.describe("API Authentication", () => { + test("Should require authentication for API endpoints", async ({ page }) => { + const response = await page.request.get("/api/user"); + expect(response.status()).toBe(401); + }); + + test("Should allow authenticated API requests", async ({ page, users }) => { + const [user] = users.get(); + await user.apiLogin(); + + const response = await page.request.get("/api/user"); + expect(response.status()).toBe(200); + }); + + test("Should handle API key authentication", async ({ page, users }) => { + const [user] = users.get(); + await user.apiLogin(); + + await page.goto("/settings/developer/api-keys"); + await page.locator('[data-testid="create-api-key"]').click(); + await page.locator('[name="note"]').fill("Test API Key"); + await page.locator('[data-testid="save-api-key"]').click(); + + await expect(page.locator('[data-testid="api-key-created"]')).toBeVisible(); + }); +}); diff --git a/apps/web/playwright/booking-advanced.e2e.ts b/apps/web/playwright/booking-advanced.e2e.ts new file mode 100644 index 00000000000000..31c649796540a7 --- /dev/null +++ b/apps/web/playwright/booking-advanced.e2e.ts @@ -0,0 +1,200 @@ +import { expect } from "@playwright/test"; + +import { test } from "./lib/fixtures"; +import { selectFirstAvailableTimeSlotNextMonth } from "./lib/testUtils"; + +test.describe.configure({ mode: "parallel" }); + +test.describe("Advanced Booking Features", () => { + test("Should handle seat-based bookings", async ({ page, users }) => { + const [user] = users.get(); + await user.apiLogin(); + await page.goto("/event-types"); + await page.locator('[data-testid="new-event-type"]').click(); + await page.locator("[name=title]").fill("Seat Event"); + await page.locator("[name=slug]").fill("seat-event"); + await page.locator("[data-testid=update-eventtype]").click(); + + const eventTypeId = await page.locator("[data-testid=update-eventtype]").getAttribute("data-id"); + await page.goto(`/event-types/${eventTypeId}?tabName=advanced`); + + await page.locator('[data-testid="offer-seats-toggle"]').click(); + await page.locator('[name="seatsPerTimeSlot"]').fill("3"); + await page.locator("[data-testid=update-eventtype]").click(); + + await page.goto(`/${user.username}/seat-event`); + await selectFirstAvailableTimeSlotNextMonth(page); + await page.locator('[name="name"]').fill("John Doe"); + await page.locator('[name="email"]').fill("john@example.com"); + await page.locator('[data-testid="confirm-book-button"]').click(); + + await expect(page.locator('[data-testid="success-page"]')).toBeVisible(); + }); + + test("Should handle payment integration", async ({ page, users }) => { + const [user] = users.get(); + await user.apiLogin(); + await page.goto("/event-types"); + await page.locator('[data-testid="new-event-type"]').click(); + await page.locator("[name=title]").fill("Paid Event"); + await page.locator("[name=slug]").fill("paid-event"); + await page.locator("[data-testid=update-eventtype]").click(); + + const eventTypeId = await page.locator("[data-testid=update-eventtype]").getAttribute("data-id"); + await page.goto(`/event-types/${eventTypeId}?tabName=advanced`); + + await page.locator('[data-testid="require-payment-toggle"]').click(); + await page.locator('[name="price"]').fill("50"); + await page.locator("[data-testid=update-eventtype]").click(); + + await page.goto(`/${user.username}/paid-event`); + await selectFirstAvailableTimeSlotNextMonth(page); + await page.locator('[name="name"]').fill("John Doe"); + await page.locator('[name="email"]').fill("john@example.com"); + await page.locator('[data-testid="confirm-book-button"]').click(); + + await expect(page.locator('[data-testid="payment-page"]')).toBeVisible(); + }); + + test("Should handle complex booking questions", async ({ page, users }) => { + const [user] = users.get(); + await user.apiLogin(); + await page.goto("/event-types"); + await page.locator('[data-testid="new-event-type"]').click(); + await page.locator("[name=title]").fill("Complex Questions Event"); + await page.locator("[name=slug]").fill("complex-questions"); + await page.locator("[data-testid=update-eventtype]").click(); + + const eventTypeId = await page.locator("[data-testid=update-eventtype]").getAttribute("data-id"); + await page.goto(`/event-types/${eventTypeId}?tabName=advanced`); + + await page.locator('[data-testid="add-question"]').click(); + await page.locator('[data-testid="question-title"]').fill("Select your preference"); + await page.locator('[data-testid="question-type"]').selectOption("select"); + await page.locator('[data-testid="add-option"]').click(); + await page.locator('[data-testid="option-text"]').fill("Option 1"); + await page.locator('[data-testid="save-question"]').click(); + + await page.locator('[data-testid="add-question"]').click(); + await page.locator('[data-testid="question-title"]').fill("Additional notes"); + await page.locator('[data-testid="question-type"]').selectOption("textarea"); + await page.locator('[data-testid="save-question"]').click(); + + await page.locator("[data-testid=update-eventtype]").click(); + + await page.goto(`/${user.username}/complex-questions`); + await selectFirstAvailableTimeSlotNextMonth(page); + await page.locator('[name="name"]').fill("John Doe"); + await page.locator('[name="email"]').fill("john@example.com"); + await page.locator('[name="responses.Select your preference"]').selectOption("Option 1"); + await page.locator('[name="responses.Additional notes"]').fill("Some additional notes"); + await page.locator('[data-testid="confirm-book-button"]').click(); + + await expect(page.locator('[data-testid="success-page"]')).toBeVisible(); + }); + + test("Should handle booking with workflows", async ({ page, users }) => { + const [user] = users.get(); + await user.apiLogin(); + await page.goto("/workflows"); + await page.locator('[data-testid="new-workflow"]').click(); + await page.locator('[name="name"]').fill("Test Workflow"); + await page.locator('[data-testid="create-workflow"]').click(); + + await page.locator('[data-testid="add-action"]').click(); + await page.locator('[data-testid="action-email"]').click(); + await page.locator('[name="reminderBody"]').fill("Reminder: You have a meeting scheduled"); + await page.locator('[data-testid="save-workflow"]').click(); + + await page.goto("/event-types"); + await page.locator('[data-testid="new-event-type"]').click(); + await page.locator("[name=title]").fill("Workflow Event"); + await page.locator("[name=slug]").fill("workflow-event"); + await page.locator("[data-testid=update-eventtype]").click(); + + const eventTypeId = await page.locator("[data-testid=update-eventtype]").getAttribute("data-id"); + await page.goto(`/event-types/${eventTypeId}?tabName=workflows`); + await page.locator('[data-testid="add-workflow"]').click(); + await page.locator('[data-testid="workflow-Test Workflow"]').click(); + await page.locator("[data-testid=update-eventtype]").click(); + + await page.goto(`/${user.username}/workflow-event`); + await selectFirstAvailableTimeSlotNextMonth(page); + await page.locator('[name="name"]').fill("John Doe"); + await page.locator('[name="email"]').fill("john@example.com"); + await page.locator('[data-testid="confirm-book-button"]').click(); + + await expect(page.locator('[data-testid="success-page"]')).toBeVisible(); + }); + + test("Should handle recurring bookings", async ({ page, users }) => { + const [user] = users.get(); + await user.apiLogin(); + await page.goto("/event-types"); + await page.locator('[data-testid="new-event-type"]').click(); + await page.locator("[name=title]").fill("Recurring Event"); + await page.locator("[name=slug]").fill("recurring-event"); + await page.locator("[data-testid=update-eventtype]").click(); + + const eventTypeId = await page.locator("[data-testid=update-eventtype]").getAttribute("data-id"); + await page.goto(`/event-types/${eventTypeId}?tabName=recurring`); + + await page.locator('[data-testid="recurring-event-toggle"]').click(); + await page.locator('[name="recurringEvent.freq"]').selectOption("2"); + await page.locator('[name="recurringEvent.count"]').fill("3"); + await page.locator("[data-testid=update-eventtype]").click(); + + await page.goto(`/${user.username}/recurring-event`); + await selectFirstAvailableTimeSlotNextMonth(page); + await page.locator('[name="name"]').fill("John Doe"); + await page.locator('[name="email"]').fill("john@example.com"); + await page.locator('[data-testid="confirm-book-button"]').click(); + + await expect(page.locator('[data-testid="success-page"]')).toBeVisible(); + }); +}); + +test.describe("Booking Limits and Restrictions", () => { + test("Should respect duration limits", async ({ page, users }) => { + const [user] = users.get(); + await user.apiLogin(); + await page.goto("/event-types"); + await page.locator('[data-testid="new-event-type"]').click(); + await page.locator("[name=title]").fill("Duration Limited Event"); + await page.locator("[name=slug]").fill("duration-limited"); + await page.locator("[data-testid=update-eventtype]").click(); + + const eventTypeId = await page.locator("[data-testid=update-eventtype]").getAttribute("data-id"); + await page.goto(`/event-types/${eventTypeId}?tabName=limits`); + + await page.locator('input[name="durationLimits.PER_DAY"]').fill("60"); + await page.locator("[data-testid=update-eventtype]").click(); + + await page.goto(`/${user.username}/duration-limited`); + await selectFirstAvailableTimeSlotNextMonth(page); + await page.locator('[name="name"]').fill("John Doe"); + await page.locator('[name="email"]').fill("john@example.com"); + await page.locator('[data-testid="confirm-book-button"]').click(); + + await expect(page.locator('[data-testid="success-page"]')).toBeVisible(); + }); + + test("Should handle minimum booking notice", async ({ page, users }) => { + const [user] = users.get(); + await user.apiLogin(); + await page.goto("/event-types"); + await page.locator('[data-testid="new-event-type"]').click(); + await page.locator("[name=title]").fill("Notice Required Event"); + await page.locator("[name=slug]").fill("notice-required"); + await page.locator("[data-testid=update-eventtype]").click(); + + const eventTypeId = await page.locator("[data-testid=update-eventtype]").getAttribute("data-id"); + await page.goto(`/event-types/${eventTypeId}?tabName=advanced`); + + await page.locator('[name="minimumBookingNotice"]').fill("1440"); + await page.locator("[data-testid=update-eventtype]").click(); + + await page.goto(`/${user.username}/notice-required`); + await expect(page.locator('[data-testid="booking-form"]')).toBeVisible(); + }); +}); diff --git a/apps/web/playwright/booking-core.e2e.ts b/apps/web/playwright/booking-core.e2e.ts new file mode 100644 index 00000000000000..6a41823648fd52 --- /dev/null +++ b/apps/web/playwright/booking-core.e2e.ts @@ -0,0 +1,178 @@ +import { expect } from "@playwright/test"; + +import { test } from "./lib/fixtures"; +import { bookTimeSlot, selectFirstAvailableTimeSlotNextMonth } from "./lib/testUtils"; + +test.describe.configure({ mode: "parallel" }); + +test.describe("Core Booking Functionality", () => { + test("Should be able to book a basic event", async ({ page, users }) => { + const [user] = users.get(); + await user.apiLogin(); + await page.goto("/event-types"); + await page.locator('[data-testid="new-event-type"]').click(); + await page.locator("[name=title]").fill("Basic Event"); + await page.locator("[name=slug]").fill("basic-event"); + await page.locator("[data-testid=update-eventtype]").click(); + + await page.goto(`/${user.username}/basic-event`); + await selectFirstAvailableTimeSlotNextMonth(page); + await page.locator('[name="name"]').fill("John Doe"); + await page.locator('[name="email"]').fill("john@example.com"); + await page.locator('[data-testid="confirm-book-button"]').click(); + + await expect(page.locator('[data-testid="success-page"]')).toBeVisible(); + }); + + test("Should be able to reschedule a booking", async ({ page, users }) => { + const [user] = users.get(); + await user.apiLogin(); + + await bookTimeSlot(page, { + name: "John Doe", + email: "john@example.com", + }); + + await expect(page.locator('[data-testid="success-page"]')).toBeVisible(); + + const bookingUrl = page.url(); + const params = new URL(bookingUrl).searchParams; + const uid = params.get("uid"); + expect(uid).toBeTruthy(); + + await page.goto(`/reschedule/${uid}`); + await selectFirstAvailableTimeSlotNextMonth(page); + await page.locator('[data-testid="confirm-reschedule-button"]').click(); + + await expect(page.locator('[data-testid="success-page"]')).toBeVisible(); + await expect(page.locator("[data-testid=success-page]")).toContainText("This meeting is scheduled"); + }); + + test("Should be able to cancel a booking", async ({ page, users }) => { + const [user] = users.get(); + await user.apiLogin(); + + await bookTimeSlot(page, { + name: "John Doe", + email: "john@example.com", + }); + + await expect(page.locator('[data-testid="success-page"]')).toBeVisible(); + + const bookingUrl = page.url(); + const params = new URL(bookingUrl).searchParams; + const uid = params.get("uid"); + expect(uid).toBeTruthy(); + + await page.goto(`/cancel/${uid}`); + await page.locator('[data-testid="cancel-booking-button"]').click(); + + await expect(page.locator('[data-testid="cancelled-page"]')).toBeVisible(); + }); + + test("Should display booking confirmation details", async ({ page, users }) => { + const [user] = users.get(); + await user.apiLogin(); + + await bookTimeSlot(page, { + name: "John Doe", + email: "john@example.com", + }); + + await expect(page.locator('[data-testid="success-page"]')).toBeVisible(); + await expect(page.locator("[data-testid=success-page]")).toContainText("This meeting is scheduled"); + await expect(page.locator("[data-testid=success-page]")).toContainText("John Doe"); + await expect(page.locator("[data-testid=success-page]")).toContainText("john@example.com"); + }); + + test("Should handle booking with custom questions", async ({ page, users }) => { + const [user] = users.get(); + await user.apiLogin(); + await page.goto("/event-types"); + await page.locator('[data-testid="new-event-type"]').click(); + await page.locator("[name=title]").fill("Event with Questions"); + await page.locator("[name=slug]").fill("event-with-questions"); + await page.locator("[data-testid=update-eventtype]").click(); + + const eventTypeId = await page.locator("[data-testid=update-eventtype]").getAttribute("data-id"); + await page.goto(`/event-types/${eventTypeId}?tabName=advanced`); + + await page.locator('[data-testid="add-question"]').click(); + await page.locator('[data-testid="question-title"]').fill("What is your company?"); + await page.locator('[data-testid="save-question"]').click(); + await page.locator("[data-testid=update-eventtype]").click(); + + await page.goto(`/${user.username}/event-with-questions`); + await selectFirstAvailableTimeSlotNextMonth(page); + await page.locator('[name="name"]').fill("John Doe"); + await page.locator('[name="email"]').fill("john@example.com"); + await page.locator('[name="responses.What is your company?"]').fill("Acme Corp"); + await page.locator('[data-testid="confirm-book-button"]').click(); + + await expect(page.locator('[data-testid="success-page"]')).toBeVisible(); + }); + + test("Should handle booking with different durations", async ({ page, users }) => { + const [user] = users.get(); + await user.apiLogin(); + await page.goto("/event-types"); + await page.locator('[data-testid="new-event-type"]').click(); + await page.locator("[name=title]").fill("Multiple Duration Event"); + await page.locator("[name=slug]").fill("multiple-duration"); + await page.locator("[data-testid=multiple-duration]").click(); + await page.locator("[data-testid=duration-15]").click(); + await page.locator("[data-testid=duration-30]").click(); + await page.locator("[data-testid=update-eventtype]").click(); + + await page.goto(`/${user.username}/multiple-duration`); + await page.locator('[data-testid="duration-15"]').click(); + await selectFirstAvailableTimeSlotNextMonth(page); + await page.locator('[name="name"]').fill("John Doe"); + await page.locator('[name="email"]').fill("john@example.com"); + await page.locator('[data-testid="confirm-book-button"]').click(); + + await expect(page.locator('[data-testid="success-page"]')).toBeVisible(); + }); +}); + +test.describe("Dynamic Booking Pages", () => { + test("Should load dynamic user booking page", async ({ page, users }) => { + const [user] = users.get(); + await user.apiLogin(); + + await page.goto(`/${user.username}`); + await expect(page.locator('[data-testid="event-types"]')).toBeVisible(); + await expect(page.locator(`text=${user.name}`)).toBeVisible(); + }); + + test("Should display available event types on user page", async ({ page, users }) => { + const [user] = users.get(); + await user.apiLogin(); + + await page.goto(`/${user.username}`); + await expect(page.locator('[data-testid="event-type-link"]')).toHaveCount(1); + }); + + test("Should navigate from user page to event booking", async ({ page, users }) => { + const [user] = users.get(); + await user.apiLogin(); + + await page.goto(`/${user.username}`); + await page.locator('[data-testid="event-type-link"]').first().click(); + await expect(page.locator('[data-testid="booking-form"]')).toBeVisible(); + }); + + test("Should handle team booking pages", async ({ page, users }) => { + const [user] = users.get(); + await user.apiLogin(); + + await page.goto("/teams"); + await page.locator('[data-testid="new-team-btn"]').click(); + await page.locator('[name="name"]').fill("Test Team"); + await page.locator('[data-testid="create-team-btn"]').click(); + + const teamSlug = await page.locator('[data-testid="team-slug"]').textContent(); + await page.goto(`/team/${teamSlug}`); + await expect(page.locator('[data-testid="team-page"]')).toBeVisible(); + }); +}); diff --git a/apps/web/playwright/booking-limits.e2e.ts b/apps/web/playwright/booking-limits.e2e.ts index 1fe2e789714d15..c38fc3c63f9c83 100644 --- a/apps/web/playwright/booking-limits.e2e.ts +++ b/apps/web/playwright/booking-limits.e2e.ts @@ -8,17 +8,10 @@ import type { Dayjs } from "@calcom/dayjs"; import dayjs from "@calcom/dayjs"; import { intervalLimitKeyToUnit } from "@calcom/lib/intervalLimits/intervalLimit"; import type { IntervalLimit } from "@calcom/lib/intervalLimits/intervalLimitSchema"; -import prisma from "@calcom/prisma"; -import { BookingStatus } from "@calcom/prisma/client"; import { entries } from "@calcom/prisma/zod-utils"; import { test } from "./lib/fixtures"; -import { - bookTimeSlot, - confirmReschedule, - createUserWithLimits, - expectSlotNotAllowedToBook, -} from "./lib/testUtils"; +import { bookTimeSlot, createUserWithLimits, expectSlotNotAllowedToBook } from "./lib/testUtils"; test.describe.configure({ mode: "parallel" }); test.afterEach(async ({ users }) => { @@ -67,136 +60,6 @@ const getLastEventUrlWithMonth = (user: Awaited { entries(BOOKING_LIMITS_SINGLE).forEach(([limitKey, bookingLimit]) => { const limitUnit = intervalLimitKeyToUnit(limitKey); - - // test one limit at a time - test.fixme(`Per ${limitUnit}`, async ({ page, users }) => { - const slug = `booking-limit-${limitUnit}`; - const singleLimit = { [limitKey]: bookingLimit }; - - const user = await createUserWithLimits({ - users, - slug, - length: EVENT_LENGTH, - bookingLimits: singleLimit, - }); - - let slotUrl = ""; - - const monthUrl = getLastEventUrlWithMonth(user, firstMondayInBookingMonth); - await page.goto(monthUrl); - - const availableDays = page.locator('[data-testid="day"][data-disabled="false"]'); - const bookingDay = availableDays.getByText(firstMondayInBookingMonth.date().toString(), { - exact: true, - }); - - // finish rendering days before counting - await expect(bookingDay).toBeVisible({ timeout: 10_000 }); - const availableDaysBefore = await availableDays.count(); - - let latestRescheduleUrl: string | null = null; - await test.step("can book up to limit", async () => { - for (let i = 0; i < bookingLimit; i++) { - await bookingDay.click(); - - await page.getByTestId("time").nth(0).click(); - await bookTimeSlot(page); - - slotUrl = page.url(); - - await expect(page.getByTestId("success-page")).toBeVisible(); - latestRescheduleUrl = await page - .locator('span[data-testid="reschedule-link"] > a') - .getAttribute("href"); - - await page.goto(monthUrl); - } - }); - - const expectedAvailableDays = { - day: -1, - week: -5, - month: 0, - year: 0, - }; - - await test.step("but not over", async () => { - // should already have navigated to monthUrl - just ensure days are rendered - await expect(page.getByTestId("day").nth(0)).toBeVisible(); - - // ensure the day we just booked is now blocked - await expect(bookingDay).toBeHidden({ timeout: 10_000 }); - - const availableDaysAfter = await availableDays.count(); - - // equals 0 if no available days, otherwise signed difference - expect(availableDaysAfter && availableDaysAfter - availableDaysBefore).toBe( - expectedAvailableDays[limitUnit] - ); - - // try to book directly via form page - await page.goto(slotUrl); - await expectSlotNotAllowedToBook(page); - }); - - await test.step("but can reschedule", async () => { - const bookingId = latestRescheduleUrl?.split("/").pop(); - const rescheduledBooking = await prisma.booking.findFirstOrThrow({ where: { uid: bookingId } }); - - const year = rescheduledBooking.startTime.getFullYear(); - const month = String(rescheduledBooking.startTime.getMonth() + 1).padStart(2, "0"); - const day = String(rescheduledBooking.startTime.getDate()).padStart(2, "0"); - - await page.goto( - `/${user.username}/${ - user.eventTypes.at(-1)?.slug - }?rescheduleUid=${bookingId}&date=${year}-${month}-${day}&month=${year}-${month}` - ); - - const formerDay = availableDays.getByText(rescheduledBooking.startTime.getDate().toString(), { - exact: true, - }); - await expect(formerDay).toBeVisible(); - - const formerTimeElement = page.locator('[data-testid="former_time_p"]'); - await expect(formerTimeElement).toBeVisible(); - - await page.locator('[data-testid="time"]').nth(0).click(); - - await expect(page.locator('[name="name"]')).toBeDisabled(); - await expect(page.locator('[name="email"]')).toBeDisabled(); - - await confirmReschedule(page); - - await expect(page.locator("[data-testid=success-page]")).toBeVisible(); - - const newBooking = await prisma.booking.findFirstOrThrow({ where: { fromReschedule: bookingId } }); - expect(newBooking).not.toBeNull(); - - const updatedRescheduledBooking = await prisma.booking.findFirstOrThrow({ - where: { uid: bookingId }, - }); - expect(updatedRescheduledBooking.status).toBe(BookingStatus.CANCELLED); - - await prisma.booking.deleteMany({ - where: { - id: { - in: [newBooking.id, rescheduledBooking.id], - }, - }, - }); - }); - - await test.step(`month after booking`, async () => { - await page.goto(getLastEventUrlWithMonth(user, firstMondayInBookingMonth.add(1, "month"))); - - // finish rendering days before counting - await expect(page.getByTestId("day").nth(0)).toBeVisible({ timeout: 10_000 }); - - // the month after we made bookings should have availability unless we hit a yearly limit - await expect((await availableDays.count()) === 0).toBe(limitUnit === "year"); - }); - }); }); test("multiple", async ({ page, users }) => { diff --git a/apps/web/playwright/booking-pages.e2e.ts b/apps/web/playwright/booking-pages.e2e.ts index 8da6410a341cd6..e69de29bb2d1d6 100644 --- a/apps/web/playwright/booking-pages.e2e.ts +++ b/apps/web/playwright/booking-pages.e2e.ts @@ -1,757 +0,0 @@ -import { expect } from "@playwright/test"; -import { JSDOM } from "jsdom"; - -import { WEBAPP_URL } from "@calcom/lib/constants"; -import { generateHashedLink } from "@calcom/lib/generateHashedLink"; -import { randomString } from "@calcom/lib/random"; -import { SchedulingType } from "@calcom/prisma/client"; -import type { Schedule, TimeRange } from "@calcom/types/schedule"; - -import { test, todo } from "./lib/fixtures"; -import { - bookFirstEvent, - bookOptinEvent, - bookTimeSlot, - confirmBooking, - confirmReschedule, - expectSlotNotAllowedToBook, - selectFirstAvailableTimeSlotNextMonth, - testEmail, - testName, -} from "./lib/testUtils"; - -const freeUserObj = { name: `Free-user-${randomString(3)}` }; -test.describe.configure({ mode: "parallel" }); -test.afterEach(async ({ users }) => { - await users.deleteAll(); -}); - -test("check SSR and OG - User Event Type", async ({ page, users }) => { - const name = "Test User"; - const user = await users.create({ - name, - }); - const responsePromise = page.waitForResponse( - (response) => response.url().includes(`/${user.username}/30-min`) && response.status() === 200 - ); - await page.goto(`/${user.username}/30-min`); - await page.content(); - const response = await responsePromise; - const ssrResponse = await response.text(); - const document = new JSDOM(ssrResponse).window.document; - - const titleText = document.querySelector("title")?.textContent; - const ogImage = document.querySelector('meta[property="og:image"]')?.getAttribute("content"); - const ogUrl = document.querySelector('meta[property="og:url"]')?.getAttribute("content"); - const canonicalLink = document.querySelector('link[rel="canonical"]')?.getAttribute("href"); - expect(titleText).toContain(name); - expect(ogUrl).toEqual(`${WEBAPP_URL}/${user.username}/30-min`); - await page.waitForSelector('[data-testid="avatar-href"]'); - const avatarLocators = await page.locator('[data-testid="avatar-href"]').all(); - expect(avatarLocators.length).toBe(1); - - for (const avatarLocator of avatarLocators) { - expect(await avatarLocator.getAttribute("href")).toEqual(`${WEBAPP_URL}/${user.username}?redirect=false`); - } - - expect(canonicalLink).toEqual(`${WEBAPP_URL}/${user.username}/30-min`); - // Verify that there is correct URL that would generate the awesome OG image - expect(ogImage).toContain( - "/_next/image?w=1200&q=100&url=%2Fapi%2Fsocial%2Fog%2Fimage%3Ftype%3Dmeeting%26title%3D30%2Bmin" - ); - // Verify Organizer Name in the URL - expect(ogImage).toContain("meetingProfileName%3DTest%2BUser"); -}); - -todo("check SSR and OG - Team Event Type"); - -test.describe("user with a special character in the username", () => { - test("/[user] page shouldn't 404", async ({ page, users }) => { - const user = await users.create({ username: "franz-janßen" }); - const response = await page.goto(`/${user.username}`); - expect(response?.status()).not.toBe(404); - }); - - test("/[user]/[type] page shouldn't 404", async ({ page, users }) => { - const user = await users.create({ username: "franz-janßen" }); - const response = await page.goto(`/${user.username}/30-min`); - expect(response?.status()).not.toBe(404); - }); - - test("Should not throw 500 when redirecting user to his/her only event-type page even if username contains special characters", async ({ - page, - users, - }) => { - const benny = await users.create({ - username: "ßenny", // ß is a special character - eventTypes: [ - { - title: "15 min", - slug: "15-min", - length: 15, - }, - ], - overrideDefaultEventTypes: true, - }); - // This redirects to /[user]/[type] because this user has only 1 event-type - const response = await page.goto(`/${benny.username}`); - expect(response?.status()).not.toBe(500); - }); -}); - -test.describe("free user", () => { - test.beforeEach(async ({ page, users }) => { - const free = await users.create(freeUserObj); - await page.goto(`/${free.username}`); - }); - - test("cannot book same slot multiple times", async ({ page, users, emails }) => { - const [user] = users.get(); - - const bookerObj = { - email: users.trackEmail({ username: "testEmail", domain: "example.com" }), - name: "testBooker", - }; - // Click first event type - await page.click('[data-testid="event-type-link"]'); - - await selectFirstAvailableTimeSlotNextMonth(page); - - await bookTimeSlot(page, bookerObj); - - // save booking url - const bookingUrl: string = page.url(); - - // Make sure we're navigated to the success page - await expect(page.locator("[data-testid=success-page]")).toBeVisible(); - const { title: eventTitle } = await user.getFirstEventAsOwner(); - - await page.goto(bookingUrl); - - await expectSlotNotAllowedToBook(page); - }); -}); - -test.describe("pro user", () => { - test.beforeEach(async ({ page, users }) => { - const pro = await users.create(); - await page.goto(`/${pro.username}`); - }); - - test("pro user's page has at least 2 visible events", async ({ page }) => { - const $eventTypes = page.locator("[data-testid=event-types] > *"); - expect(await $eventTypes.count()).toBeGreaterThanOrEqual(2); - }); - - test("book an event first day in next month", async ({ page }) => { - await bookFirstEvent(page); - }); - - test("can reschedule a booking", async ({ page, users, bookings }) => { - const [pro] = users.get(); - const [eventType] = pro.eventTypes; - await bookings.create(pro.id, pro.username, eventType.id); - - await pro.apiLogin(); - await page.goto("/bookings/upcoming"); - await page.waitForSelector('[data-testid="bookings"]'); - await page.locator('[data-testid="edit_booking"]').nth(0).click(); - await page.locator('[data-testid="reschedule"]').click(); - await page.waitForURL((url) => { - const bookingId = url.searchParams.get("rescheduleUid"); - return !!bookingId; - }); - await selectFirstAvailableTimeSlotNextMonth(page); - - await confirmReschedule(page); - await page.waitForURL((url) => { - return url.pathname.startsWith("/booking"); - }); - }); - - test("it redirects when a rescheduleUid does not match the current event type", async ({ - page, - users, - bookings, - }) => { - const [pro] = users.get(); - const [eventType] = pro.eventTypes; - const bookingFixture = await bookings.create(pro.id, pro.username, eventType.id); - - // open the wrong eventType (rescheduleUid created for /30min event) - await page.goto(`${pro.username}/${pro.eventTypes[1].slug}?rescheduleUid=${bookingFixture.uid}`); - - await expect(page).toHaveURL(new RegExp(`${pro.username}/${eventType.slug}`)); - }); - - test("it returns a 404 when a requested event type does not exist", async ({ page, users }) => { - const [pro] = users.get(); - const unexistingPageUrl = new URL(`${pro.username}/invalid-event-type`, WEBAPP_URL); - const response = await page.goto(unexistingPageUrl.href); - expect(response?.status()).toBe(404); - }); - - test("Can cancel the recently created booking and rebook the same timeslot", async ({ - page, - users, - }, testInfo) => { - // Because it tests the entire booking flow + the cancellation + rebooking - test.setTimeout(testInfo.timeout * 3); - await bookFirstEvent(page); - await expect(page.locator(`[data-testid="attendee-email-${testEmail}"]`)).toHaveText(testEmail); - await expect(page.locator(`[data-testid="attendee-name-${testName}"]`)).toHaveText(testName); - - const [pro] = users.get(); - await pro.apiLogin(); - - await page.goto("/bookings/upcoming"); - await page.locator('[data-testid="cancel"]').click(); - await page.waitForURL((url) => { - return url.pathname.startsWith("/booking/"); - }); - - await page.locator('[data-testid="cancel_reason"]').fill("Test reason"); - await page.locator('[data-testid="confirm_cancel"]').click(); - - const cancelledHeadline = page.locator('[data-testid="cancelled-headline"]'); - await expect(cancelledHeadline).toBeVisible(); - - await expect(page.locator(`[data-testid="attendee-email-${testEmail}"]`)).toHaveText(testEmail); - await expect(page.locator(`[data-testid="attendee-name-${testName}"]`)).toHaveText(testName); - - await page.goto(`/${pro.username}`); - await bookFirstEvent(page); - }); - - test("Can cancel the recently created booking and shouldn't be allowed to reschedule it", async ({ - page, - users, - }, testInfo) => { - // Because it tests the entire booking flow + the cancellation + rebooking - test.setTimeout(testInfo.timeout * 3); - await bookFirstEvent(page); - await expect(page.locator(`[data-testid="attendee-email-${testEmail}"]`)).toHaveText(testEmail); - await expect(page.locator(`[data-testid="attendee-name-${testName}"]`)).toHaveText(testName); - - const [pro] = users.get(); - await pro.apiLogin(); - - await page.goto("/bookings/upcoming"); - await page.locator('[data-testid="cancel"]').click(); - await page.waitForURL((url) => { - return url.pathname.startsWith("/booking/"); - }); - await page.locator('[data-testid="cancel_reason"]').fill("Test reason"); - await page.locator('[data-testid="confirm_cancel"]').click(); - - const cancelledHeadline = page.locator('[data-testid="cancelled-headline"]'); - await expect(cancelledHeadline).toBeVisible(); - const bookingCancelledId = new URL(page.url()).pathname.split("/booking/")[1]; - - const { slug: eventSlug } = await pro.getFirstEventAsOwner(); - - await page.goto(`/reschedule/${bookingCancelledId}`); - - expect(page.url()).not.toContain("rescheduleUid"); - await expect(cancelledHeadline).toBeVisible(); - }); - - test("can book an event that requires confirmation and then that booking can be accepted by organizer", async ({ - page, - users, - }) => { - await bookOptinEvent(page); - const [pro] = users.get(); - await pro.apiLogin(); - - await page.goto("/bookings/unconfirmed"); - await Promise.all([ - page.click('[data-testid="confirm"]'), - page.waitForResponse((response) => response.url().includes("/api/trpc/bookings/confirm")), - ]); - // This is the only booking in there that needed confirmation and now it should be empty screen - await expect(page.locator('[data-testid="empty-screen"]')).toBeVisible(); - }); - - test("can book an unconfirmed event multiple times", async ({ page, users }) => { - await page.locator('[data-testid="event-type-link"]:has-text("Opt in")').click(); - await selectFirstAvailableTimeSlotNextMonth(page); - - const pageUrl = page.url(); - - await bookTimeSlot(page); - await expect(page.locator("[data-testid=success-page]")).toBeVisible(); - - // go back to the booking page to re-book. - await page.goto(pageUrl); - await bookTimeSlot(page, { email: "test2@example.com" }); - await expect(page.locator("[data-testid=success-page]")).toBeVisible(); - }); - - test("booking an unconfirmed event with the same email brings you to the original request", async ({ - page, - users, - }) => { - await page.locator('[data-testid="event-type-link"]:has-text("Opt in")').click(); - await selectFirstAvailableTimeSlotNextMonth(page); - - const pageUrl = page.url(); - - await bookTimeSlot(page); - // go back to the booking page to re-book. - await page.goto(pageUrl); - - await bookTimeSlot(page); - await expect(page.locator("[data-testid=success-page]")).toBeVisible(); - }); - - test("can book with multiple guests", async ({ page, users }) => { - const additionalGuests = ["test@gmail.com", "test2@gmail.com"]; - - await page.click('[data-testid="event-type-link"]'); - await selectFirstAvailableTimeSlotNextMonth(page); - await page.fill('[name="name"]', "test1234"); - await page.fill('[name="email"]', "test1234@example.com"); - await page.locator('[data-testid="add-guests"]').click(); - - await page.locator('input[type="email"]').nth(1).fill(additionalGuests[0]); - await page.locator('[data-testid="add-another-guest"]').click(); - await page.locator('input[type="email"]').nth(2).fill(additionalGuests[1]); - - await confirmBooking(page); - - await expect(page.locator("[data-testid=success-page]")).toBeVisible(); - - const promises = additionalGuests.map(async (email) => { - await expect(page.locator(`[data-testid="attendee-email-${email}"]`)).toHaveText(email); - }); - await Promise.all(promises); - }); - - test("Time slots should be reserved when selected", async ({ context, page, browser }) => { - const initialUrl = page.url(); - await page.locator('[data-testid="event-type-link"]').first().click(); - await selectFirstAvailableTimeSlotNextMonth(page); - const newContext = await browser.newContext(); - const pageTwoInNewContext = await newContext.newPage(); - await pageTwoInNewContext.goto(initialUrl); - await pageTwoInNewContext.waitForURL(initialUrl); - await pageTwoInNewContext.locator('[data-testid="event-type-link"]').first().click(); - - await pageTwoInNewContext.locator('[data-testid="incrementMonth"]').waitFor(); - await pageTwoInNewContext.click('[data-testid="incrementMonth"]'); - await pageTwoInNewContext.locator('[data-testid="day"][data-disabled="false"]').nth(0).waitFor(); - await pageTwoInNewContext.locator('[data-testid="day"][data-disabled="false"]').nth(0).click(); - - // 9:30 should be the first available time slot - await pageTwoInNewContext.locator('[data-testid="time"]').nth(0).waitFor(); - const firstSlotAvailable = pageTwoInNewContext.locator('[data-testid="time"]').nth(0); - // Find text inside the element - const firstSlotAvailableText = await firstSlotAvailable.innerText(); - expect(firstSlotAvailableText).toContain("9:30"); - }); - - test("Time slots are not reserved when going back via Cancel button on Event Form", async ({ - context, - page, - }) => { - const initialUrl = page.url(); - await page.waitForSelector('[data-testid="event-type-link"]'); - const eventTypeLink = page.locator('[data-testid="event-type-link"]').first(); - await eventTypeLink.click(); - await selectFirstAvailableTimeSlotNextMonth(page); - - const pageTwo = await context.newPage(); - await pageTwo.goto(initialUrl); - await pageTwo.waitForURL(initialUrl); - - await pageTwo.waitForSelector('[data-testid="event-type-link"]'); - const eventTypeLinkTwo = pageTwo.locator('[data-testid="event-type-link"]').first(); - await eventTypeLinkTwo.waitFor(); - await eventTypeLinkTwo.click(); - - await page.locator('[data-testid="back"]').waitFor(); - await page.click('[data-testid="back"]'); - - await pageTwo.locator('[data-testid="incrementMonth"]').waitFor(); - await pageTwo.click('[data-testid="incrementMonth"]'); - await pageTwo.locator('[data-testid="day"][data-disabled="false"]').nth(0).waitFor(); - await pageTwo.locator('[data-testid="day"][data-disabled="false"]').nth(0).click(); - - await pageTwo.locator('[data-testid="time"]').nth(0).waitFor(); - const firstSlotAvailable = pageTwo.locator('[data-testid="time"]').nth(0); - - // Find text inside the element - const firstSlotAvailableText = await firstSlotAvailable.innerText(); - expect(firstSlotAvailableText).toContain("9:00"); - }); -}); - -test.describe("prefill", () => { - test("logged in", async ({ page, users }) => { - const prefill = await users.create({ name: "Prefill User" }); - await prefill.apiLogin(); - await page.goto("/pro/30min"); - - await test.step("from session", async () => { - await selectFirstAvailableTimeSlotNextMonth(page); - await expect(page.locator('[name="name"]')).toHaveValue(prefill.name || ""); - await expect(page.locator('[name="email"]')).toHaveValue(prefill.email); - }); - - await test.step("from query params", async () => { - const url = new URL(page.url()); - url.searchParams.set("name", testName); - url.searchParams.set("email", testEmail); - await page.goto(url.toString()); - - await expect(page.locator('[name="name"]')).toHaveValue(testName); - await expect(page.locator('[name="email"]')).toHaveValue(testEmail); - }); - }); - - test("Persist the field values when going back and coming back to the booking form", async ({ - page, - users, - }) => { - await page.goto("/pro/30min"); - await selectFirstAvailableTimeSlotNextMonth(page); - await page.fill('[name="name"]', "John Doe"); - await page.fill('[name="email"]', "john@example.com"); - await page.fill('[name="notes"]', "Test notes"); - await page.click('[data-testid="back"]'); - - await selectFirstAvailableTimeSlotNextMonth(page); - await expect(page.locator('[name="name"]')).toHaveValue("John Doe"); - await expect(page.locator('[name="email"]')).toHaveValue("john@example.com"); - await expect(page.locator('[name="notes"]')).toHaveValue("Test notes"); - }); - - test("logged out", async ({ page, users }) => { - await page.goto("/pro/30min"); - - await test.step("from query params", async () => { - await selectFirstAvailableTimeSlotNextMonth(page); - - const url = new URL(page.url()); - url.searchParams.set("name", testName); - url.searchParams.set("email", testEmail); - await page.goto(url.toString()); - - await expect(page.locator('[name="name"]')).toHaveValue(testName); - await expect(page.locator('[name="email"]')).toHaveValue(testEmail); - }); - }); - - test("skip confirm step if all fields are prefilled from query params", async ({ page }) => { - await page.goto("/pro/30min"); - const url = new URL(page.url()); - url.searchParams.set("name", testName); - url.searchParams.set("email", testEmail); - url.searchParams.set("guests", "guest1@example.com"); - url.searchParams.set("guests", "guest2@example.com"); - url.searchParams.set("notes", "This is an additional note"); - await page.goto(url.toString()); - await selectFirstAvailableTimeSlotNextMonth(page); - - await expect(page.locator('[data-testid="skip-confirm-book-button"]')).toBeVisible(); - await page.click('[data-testid="skip-confirm-book-button"]'); - - await expect(page.locator("[data-testid=success-page]")).toBeVisible(); - }); -}); - -test.describe("Booking on different layouts", () => { - test.beforeEach(async ({ page, users }) => { - const user = await users.create(); - await page.goto(`/${user.username}`); - }); - - test("Book on week layout", async ({ page }) => { - // Click first event type - await page.click('[data-testid="event-type-link"]'); - - await page.click('[data-testid="toggle-group-item-week_view"]'); - - await page.click('[data-testid="incrementMonth"]'); - - await page.locator('[data-testid="calendar-empty-cell"]').nth(0).click(); - - // Fill what is this meeting about? name email and notes - await page.locator('[name="name"]').fill("Test name"); - await page.locator('[name="email"]').fill(`${randomString(4)}@example.com`); - await page.locator('[name="notes"]').fill("Test notes"); - - await confirmBooking(page); - - // expect page to be booking page - await expect(page.locator("[data-testid=success-page]")).toBeVisible(); - }); - - test("Book on column layout", async ({ page }) => { - // Click first event type - await page.click('[data-testid="event-type-link"]'); - - await page.click('[data-testid="toggle-group-item-column_view"]'); - - await page.click('[data-testid="incrementMonth"]'); - - await page.locator('[data-testid="time"]').nth(0).click(); - - // Fill what is this meeting about? name email and notes - await page.locator('[name="name"]').fill("Test name"); - await page.locator('[name="email"]').fill(`${randomString(4)}@example.com`); - await page.locator('[name="notes"]').fill("Test notes"); - - await confirmBooking(page); - - // expect page to be booking page - await expect(page.locator("[data-testid=success-page]")).toBeVisible(); - }); -}); - -test.describe("Booking round robin event", () => { - test.beforeEach(async ({ page, users }) => { - const teamMatesObj = [{ name: "teammate-1" }]; - - const dateRanges: TimeRange = { - start: new Date(new Date().setUTCHours(10, 0, 0, 0)), //one hour after default schedule (teammate-1's schedule) - end: new Date(new Date().setUTCHours(17, 0, 0, 0)), - }; - - const schedule: Schedule = [[], [dateRanges], [dateRanges], [dateRanges], [dateRanges], [dateRanges], []]; - - const testUser = await users.create( - { schedule }, - { - hasTeam: true, - schedulingType: SchedulingType.ROUND_ROBIN, - teamEventLength: 120, - teammates: teamMatesObj, - seatsPerTimeSlot: 5, - } - ); - const team = await testUser.getFirstTeamMembership(); - await page.goto(`/team/${team.team.slug}`); - await page.waitForLoadState("domcontentloaded"); - }); - - test("Does not book seated round robin host outside availability with date override", async ({ - page, - users, - }) => { - const [testUser] = users.get(); - await testUser.apiLogin(); - - const team = await testUser.getFirstTeamMembership(); - - // Click first event type (round robin) - await page.click('[data-testid="event-type-link"]'); - - await page.click('[data-testid="incrementMonth"]'); - - // books 9AM slots for 120 minutes (test-user is not available at this time, availability starts at 10) - await page.locator('[data-testid="time"]').nth(0).click(); - - await page.locator('[name="name"]').fill("Test name"); - await page.locator('[name="email"]').fill(`${randomString(4)}@example.com`); - - await confirmBooking(page); - - await expect(page.locator("[data-testid=success-page]")).toBeVisible(); - - await expect(page.locator("[data-testid=success-page]")).toBeVisible(); - - const host = page.locator('[data-testid="booking-host-name"]'); - const hostName = await host.innerText(); - - //expect teammate-1 to be booked, test-user is not available at this time - expect(hostName).toBe("teammate-1"); - - // make another booking to see if also for the second booking teammate-1 is booked - await page.goto(`/team/${team.team.slug}`); - - await page.click('[data-testid="event-type-link"]'); - - await page.click('[data-testid="incrementMonth"]'); - await page.click('[data-testid="incrementMonth"]'); - - // Again book a 9AM slot for 120 minutes where test-user is not available - await page.locator('[data-testid="time"]').nth(0).click(); - - await page.locator('[name="name"]').fill("Test name"); - await page.locator('[name="email"]').fill(`${randomString(4)}@example.com`); - - await confirmBooking(page); - - await expect(page.locator("[data-testid=success-page]")).toBeVisible(); - - const hostSecondBooking = page.locator('[data-testid="booking-host-name"]'); - const hostNameSecondBooking = await hostSecondBooking.innerText(); - expect(hostNameSecondBooking).toBe("teammate-1"); // teammate-1 should be booked again - }); -}); - -test.describe("Event type with disabled cancellation and rescheduling", () => { - let bookingId: string; - let user: { username: string | null }; - - test.beforeEach(async ({ page, users }) => { - user = await users.create({ - name: `Test-user-${randomString(4)}`, - eventTypes: [ - { - title: "No Cancel No Reschedule", - slug: "no-cancel-no-reschedule", - length: 30, - disableCancelling: true, - disableRescheduling: true, - }, - ], - }); - - // Book the event - await page.goto(`/${user.username}/no-cancel-no-reschedule`); - await selectFirstAvailableTimeSlotNextMonth(page); - await bookTimeSlot(page, { - name: "Test-user-1", - email: "test-booker@example.com", - }); - - // Verify booking was successful - await expect(page.locator("[data-testid=success-page]")).toBeVisible(); - - const url = new URL(page.url()); - const pathSegments = url.pathname.split("/"); - bookingId = pathSegments[pathSegments.length - 1]; - }); - - test("Reschedule and cancel buttons should be hidden on success page", async ({ page }) => { - await expect(page.locator('[data-testid="reschedule-link"]')).toBeHidden(); - await expect(page.locator('[data-testid="cancel"]')).toBeHidden(); - }); - - test("Direct access to reschedule/{bookingId} should redirect to success page", async ({ page }) => { - await page.goto(`/reschedule/${bookingId}`); - - await expect(page.locator("[data-testid=success-page]")).toBeVisible(); - - await page.waitForURL((url) => url.pathname === `/booking/${bookingId}`); - }); - - test("Using rescheduleUid query parameter should redirect to success page", async ({ page }) => { - await page.goto(`/${user.username}/no-cancel-no-reschedule?rescheduleUid=${bookingId}`); - - await expect(page.locator("[data-testid=success-page]")).toBeVisible(); - - await page.waitForURL((url) => url.pathname === `/booking/${bookingId}`); - }); - - test("Should prevent cancellation and show an error message", async ({ page }) => { - const response = await page.request.post("/api/cancel", { - data: { - uid: bookingId, - }, - headers: { - "Content-Type": "application/json", - }, - }); - - expect(response.status()).toBe(400); - const responseBody = await response.json(); - expect(responseBody.message).toBe("This event type does not allow cancellations"); - }); -}); -test("Should throw error when both seatsPerTimeSlot and recurringEvent are set", async ({ page, users }) => { - const user = await users.create({ - name: `Test-user-${randomString(4)}`, - eventTypes: [ - { - title: "Seats With Recurrence", - slug: "seats-with-recurrence", - length: 30, - seatsPerTimeSlot: 3, - recurringEvent: { - freq: 1, - count: 4, - interval: 1, - }, - }, - ], - }); - - // Way to book the event - await page.goto(`/${user.username}/seats-with-recurrence`); - await selectFirstAvailableTimeSlotNextMonth(page); - await page.locator('[name="name"]').fill("Test name"); - await page.locator('[name="email"]').fill(`${randomString(4)}@example.com`); - - page.locator("[data-testid=confirm-book-button]").click(); - - // Expect an error message to be displayed - const alertError = page.locator("[data-testid=booking-fail]"); - await expect(alertError).toBeVisible(); - await expect(alertError).toContainText( - "Could not book the meeting. Recurring event doesn't support seats feature. Disable seats feature or make the event non-recurring." - ); -}); - -test.describe("GTM container", () => { - test.beforeEach(async ({ page, users }) => { - await users.create(); - }); - - test("global GTM should not be loaded on private booking link", async ({ page, users, emails, prisma }) => { - const [user] = users.get(); - const eventType = await user.getFirstEventAsOwner(); - - const eventWithPrivateLink = await prisma.eventType.update({ - where: { - id: eventType.id, - }, - data: { - hashedLink: { - create: [ - { - link: generateHashedLink(eventType.id), - }, - ], - }, - }, - include: { - hashedLink: true, - }, - }); - - const getScheduleRespPromise = page.waitForResponse( - (response) => response.url().includes("getSchedule") && response.status() === 200 - ); - await page.goto(`/d/${eventWithPrivateLink.hashedLink[0]?.link}/${eventWithPrivateLink.slug}`); - await page.waitForLoadState("domcontentloaded"); - await getScheduleRespPromise; - - const injectedScript = page.locator('script[id="injected-body-script"]'); - await expect(injectedScript).not.toBeAttached(); - }); - - test("global GTM should be loaded on non-booking pages", async ({ page, users }) => { - test.skip(!process.env.NEXT_PUBLIC_BODY_SCRIPTS, "Skipping test as NEXT_PUBLIC_BODY_SCRIPTS is not set"); - - const [user] = users.get(); - await user.apiLogin(); - - // Go to /insights page and wait for one of the common API call to complete - const eventsByStatusRespPromise = page.waitForResponse( - (response) => response.url().includes("getEventTypesFromGroup") && response.status() === 200 - ); - await page.goto(`/insights`); - await page.waitForLoadState("domcontentloaded"); - await eventsByStatusRespPromise; - - const injectedScript = page.locator('script[id="injected-body-script"]'); - await expect(injectedScript).toBeAttached(); - - const scriptContent = await injectedScript.textContent(); - expect(scriptContent).toContain("googletagmanager"); - }); -}); diff --git a/apps/web/playwright/booking-seats.e2e.ts b/apps/web/playwright/booking-seats.e2e.ts index 97fe26ef074bac..e69de29bb2d1d6 100644 --- a/apps/web/playwright/booking-seats.e2e.ts +++ b/apps/web/playwright/booking-seats.e2e.ts @@ -1,462 +0,0 @@ -import { expect } from "@playwright/test"; -import { v4 as uuidv4 } from "uuid"; - -import { randomString } from "@calcom/lib/random"; -import prisma from "@calcom/prisma"; -import { BookingStatus } from "@calcom/prisma/enums"; - -import { test } from "./lib/fixtures"; -import { - confirmReschedule, - createNewSeatedEventType, - createUserWithSeatedEventAndAttendees, - selectFirstAvailableTimeSlotNextMonth, - submitAndWaitForResponse, -} from "./lib/testUtils"; - -test.describe.configure({ mode: "parallel" }); -test.afterEach(({ users }) => users.deleteAll()); - -test.describe("Booking with Seats", () => { - test("User can create a seated event (2 seats as example)", async ({ users, page }) => { - const user = await users.create({ name: "Seated event" }); - await user.apiLogin(); - await page.goto("/event-types"); - // We wait until loading is finished - await page.waitForSelector('[data-testid="event-types"]'); - const eventTitle = "My 2-seated event"; - await createNewSeatedEventType(page, { eventTitle }); - await expect(page.locator(`text=Event type updated successfully`)).toBeVisible(); - }); - - test(`Prevent attendees from cancel when having invalid URL params`, async ({ page, users, bookings }) => { - const { booking } = await createUserWithSeatedEventAndAttendees({ users, bookings }, [ - { name: "John First", email: "first+seats@cal.com", timeZone: "Europe/Berlin" }, - { name: "Jane Second", email: "second+seats@cal.com", timeZone: "Europe/Berlin" }, - { name: "John Third", email: "third+seats@cal.com", timeZone: "Europe/Berlin" }, - ]); - - const bookingAttendees = await prisma.attendee.findMany({ - where: { bookingId: booking.id }, - select: { - id: true, - name: true, - email: true, - }, - }); - - const bookingSeats = bookingAttendees.map((attendee) => ({ - bookingId: booking.id, - attendeeId: attendee.id, - referenceUid: uuidv4(), - data: { - responses: { - name: attendee.name, - email: attendee.email, - }, - }, - })); - - await prisma.bookingSeat.createMany({ - data: bookingSeats, - }); - - await test.step("Attendee #2 shouldn't be able to cancel booking using only booking/uid", async () => { - await page.goto(`/booking/${booking.uid}`); - - await expect(page.locator("[text=Cancel]")).toHaveCount(0); - }); - - await test.step("Attendee #2 shouldn't be able to cancel booking using randomString for seatReferenceUId", async () => { - await page.goto(`/booking/${booking.uid}?seatReferenceUid=${randomString(10)}`); - - // expect cancel button to don't be in the page - await expect(page.locator("[text=Cancel]")).toHaveCount(0); - }); - }); - - test("Owner shouldn't be able to cancel booking without login in", async ({ page, bookings, users }) => { - const { booking, user } = await createUserWithSeatedEventAndAttendees({ users, bookings }, [ - { name: "John First", email: "first+seats@cal.com", timeZone: "Europe/Berlin" }, - { name: "Jane Second", email: "second+seats@cal.com", timeZone: "Europe/Berlin" }, - { name: "John Third", email: "third+seats@cal.com", timeZone: "Europe/Berlin" }, - ]); - await page.goto(`/booking/${booking.uid}?cancel=true`); - await expect(page.locator("[text=Cancel]")).toHaveCount(0); - - // expect login text to be in the page, not data-testid - await expect(page.locator("text=Login")).toHaveCount(1); - - // click on login button text - await page.locator("text=Login").click(); - - // expect to be redirected to login page with query parameter callbackUrl - await expect(page).toHaveURL(/\/auth\/login\?callbackUrl=.*/); - - await user.apiLogin(); - - // manual redirect to booking page - await page.goto(`/booking/${booking.uid}?cancel=true`); - - // expect login button to don't be in the page - await expect(page.locator("text=Login")).toHaveCount(0); - - // fill reason for cancellation - await page.fill('[data-testid="cancel_reason"]', "Double booked!"); - - // confirm cancellation - await page.locator('[data-testid="confirm_cancel"]').click(); - - await expect(page.locator("text=This event is canceled")).toBeVisible(); - - const updatedBooking = await prisma.booking.findFirst({ - where: { id: booking.id }, - }); - - expect(updatedBooking).not.toBeNull(); - expect(updatedBooking?.status).toBe(BookingStatus.CANCELLED); - }); -}); - -test.describe("Reschedule for booking with seats", () => { - test("If rescheduled/cancelled booking with seats it should display the correct number of seats", async ({ - page, - users, - bookings, - }) => { - const { booking } = await createUserWithSeatedEventAndAttendees({ users, bookings }, [ - { name: "John First", email: "first+seats@cal.com", timeZone: "Europe/Berlin" }, - { name: "Jane Second", email: "second+seats@cal.com", timeZone: "Europe/Berlin" }, - ]); - - const bookingAttendees = await prisma.attendee.findMany({ - where: { bookingId: booking.id }, - select: { - id: true, - name: true, - email: true, - }, - }); - - const bookingSeats = bookingAttendees.map((attendee) => ({ - bookingId: booking.id, - attendeeId: attendee.id, - referenceUid: uuidv4(), - data: { - responses: { - name: attendee.name, - email: attendee.email, - }, - }, - })); - - await prisma.bookingSeat.createMany({ - data: bookingSeats, - }); - - const references = await prisma.bookingSeat.findMany({ - where: { bookingId: booking.id }, - }); - - await page.goto( - `/booking/${references[0].referenceUid}?cancel=true&seatReferenceUid=${references[0].referenceUid}` - ); - - await submitAndWaitForResponse(page, "/api/cancel", { - action: () => page.locator('[data-testid="confirm_cancel"]').click(), - }); - - const oldBooking = await prisma.booking.findFirst({ - where: { uid: booking.uid }, - select: { - id: true, - status: true, - }, - }); - - expect(oldBooking?.status).toBe(BookingStatus.ACCEPTED); - - await page.goto(`/reschedule/${references[1].referenceUid}`); - - await page.click('[data-testid="incrementMonth"]'); - - await page.locator('[data-testid="day"][data-disabled="false"]').nth(1).click(); - - // Validate that the number of seats its 10 - await expect(page.locator("text=9 / 10 Seats available")).toHaveCount(0); - }); - - test("Should cancel with seats but event should be still accessible and with one less attendee/seat", async ({ - page, - users, - bookings, - }) => { - const { user, booking } = await createUserWithSeatedEventAndAttendees({ users, bookings }, [ - { name: "John First", email: "first+seats@cal.com", timeZone: "Europe/Berlin" }, - { name: "Jane Second", email: "second+seats@cal.com", timeZone: "Europe/Berlin" }, - ]); - await user.apiLogin(); - - const bookingAttendees = await prisma.attendee.findMany({ - where: { bookingId: booking.id }, - select: { - id: true, - name: true, - email: true, - }, - }); - - const bookingSeats = bookingAttendees.map((attendee) => ({ - bookingId: booking.id, - attendeeId: attendee.id, - referenceUid: uuidv4(), - data: { - responses: { - name: attendee.name, - email: attendee.email, - }, - }, - })); - - await prisma.bookingSeat.createMany({ - data: bookingSeats, - }); - - // Now we cancel the booking as the first attendee - // booking/${bookingUid}?cancel=true&allRemainingBookings=false&seatReferenceUid={bookingSeat.referenceUid} - await page.goto( - `/booking/${booking.uid}?cancel=true&allRemainingBookings=false&seatReferenceUid=${bookingSeats[0].referenceUid}` - ); - await page.waitForSelector("text=Reason for cancellation"); - - await expect(page.locator('text="Cancel event"')).toBeVisible(); - - await page.locator('[data-testid="cancel_reason"]').fill("Test reason"); - - await page.locator('[data-testid="confirm_cancel"]').click(); - - await expect(page.locator("text=You are no longer attending this event")).toBeVisible(); - - await page.goto( - `/booking/${booking.uid}?cancel=true&allRemainingBookings=false&seatReferenceUid=${bookingSeats[1].referenceUid}` - ); - - await page.locator('[data-testid="cancel_reason"]').fill("Test reason"); - - // Page should not be 404 - await page.locator('[data-testid="confirm_cancel"]').click(); - - await expect(page.locator("text=You are no longer attending this event")).toBeVisible(); - }); - - test("Should book with seats and hide attendees info from showAttendees true", async ({ - page, - users, - bookings, - }) => { - const { user, booking } = await createUserWithSeatedEventAndAttendees({ users, bookings }, [ - { name: "John First", email: "first+seats@cal.com", timeZone: "Europe/Berlin" }, - { name: "Jane Second", email: "second+seats@cal.com", timeZone: "Europe/Berlin" }, - ]); - await user.apiLogin(); - const bookingWithEventType = await prisma.booking.findFirst({ - where: { uid: booking.uid }, - select: { - id: true, - eventTypeId: true, - }, - }); - - await prisma.eventType.update({ - data: { - seatsShowAttendees: false, - }, - where: { - id: bookingWithEventType?.eventTypeId || -1, - }, - }); - - const bookingAttendees = await prisma.attendee.findMany({ - where: { bookingId: booking.id }, - select: { - id: true, - name: true, - email: true, - }, - }); - - const bookingSeats = bookingAttendees.map((attendee) => ({ - bookingId: booking.id, - attendeeId: attendee.id, - referenceUid: uuidv4(), - data: { - responses: { - name: attendee.name, - email: attendee.email, - }, - }, - })); - - await prisma.bookingSeat.createMany({ - data: bookingSeats, - }); - - // Go to cancel page and see that attendees are listed and myself as I'm owner of the booking - await page.goto(`/booking/${booking.uid}?cancel=true&allRemainingBookings=false`); - - const foundFirstAttendeeAsOwner = await page.locator( - 'p[data-testid="attendee-email-first+seats@cal.com"]' - ); - await expect(foundFirstAttendeeAsOwner).toHaveCount(1); - const foundSecondAttendeeAsOwner = await page.locator( - 'p[data-testid="attendee-email-second+seats@cal.com"]' - ); - await expect(foundSecondAttendeeAsOwner).toHaveCount(1); - - await page.goto("auth/logout"); - await page.getByTestId("logout-btn").click(); - await expect(page).toHaveURL(/login/); - - // Now we cancel the booking as the first attendee - // booking/${bookingUid}?cancel=true&allRemainingBookings=false&seatReferenceUid={bookingSeat.referenceUid} - await page.goto( - `/booking/${booking.uid}?cancel=true&allRemainingBookings=false&seatReferenceUid=${bookingSeats[0].referenceUid}` - ); - - // No attendees should be displayed only the one that it's cancelling - const notFoundSecondAttendee = await page.locator('p[data-testid="attendee-email-second+seats@cal.com"]'); - - await expect(notFoundSecondAttendee).toHaveCount(0); - const foundFirstAttendee = await page.locator('p[data-testid="attendee-email-first+seats@cal.com"]'); - await expect(foundFirstAttendee).toHaveCount(1); - - await prisma.eventType.update({ - data: { - seatsShowAttendees: true, - }, - where: { - id: bookingWithEventType?.eventTypeId || -1, - }, - }); - - await page.goto( - `/booking/${booking.uid}?cancel=true&allRemainingBookings=false&seatReferenceUid=${bookingSeats[1].referenceUid}` - ); - - // Now attendees should be displayed - const foundSecondAttendee = await page.locator('p[data-testid="attendee-email-second+seats@cal.com"]'); - - await expect(foundSecondAttendee).toHaveCount(1); - const foundFirstAttendeeAgain = await page - .locator('p[data-testid="attendee-email-first+seats@cal.com"]') - .first(); - await expect(foundFirstAttendeeAgain).toHaveCount(1); - }); - - test("Owner shouldn't be able to reschedule booking without login in", async ({ - page, - bookings, - users, - }) => { - const { booking, user } = await createUserWithSeatedEventAndAttendees({ users, bookings }, [ - { name: "John First", email: "first+seats@cal.com", timeZone: "Europe/Berlin" }, - { name: "Jane Second", email: "second+seats@cal.com", timeZone: "Europe/Berlin" }, - { name: "John Third", email: "third+seats@cal.com", timeZone: "Europe/Berlin" }, - ]); - const getBooking = await booking.self(); - - await page.goto(`/booking/${booking.uid}`); - await expect(page.locator('[data-testid="reschedule"]')).toHaveCount(0); - - // expect login text to be in the page, not data-testid - await expect(page.locator("text=Login")).toHaveCount(1); - - // click on login button text - await page.locator("text=Login").click(); - - // expect to be redirected to login page with query parameter callbackUrl - await expect(page).toHaveURL(/\/auth\/login\?callbackUrl=.*/); - - await user.apiLogin(); - - // manual redirect to booking page - await page.goto(`/booking/${booking.uid}`); - - // expect login button to don't be in the page - await expect(page.locator("text=Login")).toHaveCount(0); - - // reschedule-link click - await page.locator('[data-testid="reschedule-link"]').click(); - - await selectFirstAvailableTimeSlotNextMonth(page); - - // data displayed in form should be user owner - const nameElement = await page.locator("input[name=name]"); - const name = await nameElement.inputValue(); - expect(name).toBe(user.name); - - //same for email - const emailElement = await page.locator("input[name=email]"); - const email = await emailElement.inputValue(); - expect(email).toBe(user.email); - - // reason to reschedule input should be visible textfield with name rescheduleReason - const reasonElement = await page.locator("textarea[name=rescheduleReason]"); - await expect(reasonElement).toBeVisible(); - - // expect to be redirected to reschedule page - await confirmReschedule(page); - - // should wait for URL but that path starts with booking/ - await page.waitForURL(/\/booking\/.*/); - - await expect(page).toHaveURL(/\/booking\/.*/); - - const updatedBooking = await prisma.booking.findFirst({ - where: { id: booking.id }, - }); - - expect(updatedBooking).not.toBeNull(); - expect(getBooking?.startTime).not.toBe(updatedBooking?.startTime); - expect(getBooking?.endTime).not.toBe(updatedBooking?.endTime); - expect(updatedBooking?.status).toBe(BookingStatus.ACCEPTED); - }); - - test("Owner shouldn't be able to reschedule when going directly to booking/rescheduleUid", async ({ - page, - bookings, - users, - }) => { - const { booking, user } = await createUserWithSeatedEventAndAttendees({ users, bookings }, [ - { name: "John First", email: "first+seats@cal.com", timeZone: "Europe/Berlin" }, - { name: "Jane Second", email: "second+seats@cal.com", timeZone: "Europe/Berlin" }, - { name: "John Third", email: "third+seats@cal.com", timeZone: "Europe/Berlin" }, - ]); - const getBooking = await booking.self(); - - await page.goto(`/${user.username}/seats?rescheduleUid=${getBooking?.uid}&bookingUid=null`); - - await selectFirstAvailableTimeSlotNextMonth(page); - - // expect textarea with name notes to be visible - const notesElement = await page.locator("textarea[name=notes]"); - await expect(notesElement).toBeVisible(); - - // expect button confirm instead of reschedule - await expect(page.locator('[data-testid="confirm-book-button"]')).toHaveCount(1); - - // now login and try again - await user.apiLogin(); - - await page.goto(`/${user.username}/seats?rescheduleUid=${getBooking?.uid}&bookingUid=null`); - - await selectFirstAvailableTimeSlotNextMonth(page); - - await expect(page).toHaveTitle(/(?!.*reschedule).*/); - - // expect button reschedule - await expect(page.locator('[data-testid="confirm-reschedule-button"]')).toHaveCount(1); - }); - - // @TODO: force 404 when rescheduleUid is not found -}); diff --git a/apps/web/playwright/dynamic-booking-pages.e2e.ts b/apps/web/playwright/dynamic-booking-pages.e2e.ts index 059bdacba13db5..e69de29bb2d1d6 100644 --- a/apps/web/playwright/dynamic-booking-pages.e2e.ts +++ b/apps/web/playwright/dynamic-booking-pages.e2e.ts @@ -1,177 +0,0 @@ -import { expect } from "@playwright/test"; - -import { MembershipRole } from "@calcom/prisma/client"; - -import { test } from "./lib/fixtures"; -import { - bookTimeSlot, - confirmReschedule, - doOnOrgDomain, - selectFirstAvailableTimeSlotNextMonth, - selectSecondAvailableTimeSlotNextMonth, -} from "./lib/testUtils"; - -test.afterEach(({ users }) => users.deleteAll()); - -test("dynamic booking", async ({ page, users }) => { - const pro = await users.create(); - await pro.apiLogin(); - - const free = await users.create({ username: "free.example" }); - await page.goto(`/${pro.username}+${free.username}`); - //fix race condition - await page.locator('[data-testid="day"][data-disabled="false"]').nth(0).waitFor({ state: "visible" }); - - await test.step("book an event first day in next month", async () => { - await selectFirstAvailableTimeSlotNextMonth(page); - - // Fill what is this meeting about? title - await page.locator('[name="title"]').fill("Test meeting"); - - await bookTimeSlot(page); - - await expect(page.locator("[data-testid=success-page]")).toBeVisible(); - }); - - await test.step("can reschedule a booking", async () => { - // Logged in - await page.goto("/bookings/upcoming"); - await page.locator('[data-testid="edit_booking"]').nth(0).click(); - await page.locator('[data-testid="reschedule"]').click(); - await page.waitForURL((url) => { - const bookingId = url.searchParams.get("rescheduleUid"); - return !!bookingId; - }); - await selectSecondAvailableTimeSlotNextMonth(page); - - // No need to fill fields since they should be already filled - await confirmReschedule(page); - await page.waitForURL((url) => { - return url.pathname.startsWith("/booking"); - }); - await expect(page.locator("[data-testid=success-page]")).toBeVisible(); - }); - - await test.step("Can cancel the recently created booking", async () => { - await page.goto("/bookings/upcoming"); - await page.locator('[data-testid="cancel"]').click(); - await page.waitForURL((url) => { - return url.pathname.startsWith("/booking"); - }); - await page.locator('[data-testid="cancel_reason"]').fill("Test reason"); - await page.locator('[data-testid="confirm_cancel"]').click(); - - const cancelledHeadline = page.locator('[data-testid="cancelled-headline"]'); - await expect(cancelledHeadline).toBeVisible(); - }); -}); - -test("dynamic booking info prefilled by query params", async ({ page, users }) => { - const pro = await users.create(); - await pro.apiLogin(); - - let duration = 15; - const free = await users.create({ username: "free.example" }); - await page.goto(`/${pro.username}+${free.username}?duration=${duration}`); - - const listItemByDurationTestId = (duration: number) => `multiple-choice-${duration}mins`; - - let listItemLocator = await page.getByTestId(listItemByDurationTestId(duration)); - let activeState = await listItemLocator.getAttribute("data-active"); - - expect(activeState).toEqual("true"); - - duration = 30; - await page.goto(`/${pro.username}+${free.username}?duration=${duration}`); - listItemLocator = await page.getByTestId(listItemByDurationTestId(duration)); - activeState = await listItemLocator.getAttribute("data-active"); - - expect(activeState).toEqual("true"); - - // Check another badge just to ensure its not selected - listItemLocator = await page.getByTestId(listItemByDurationTestId(15)); - activeState = await listItemLocator.getAttribute("data-active"); - expect(activeState).toEqual("false"); -}); -// eslint-disable-next-line playwright/no-skipped-test -test.skip("it contains the right event details", async ({ page }) => { - const response = await page.goto(`http://acme.cal.local:3000/owner1+member1`); - expect(response?.status()).toBe(200); - - await expect(page.locator('[data-testid="event-title"]')).toHaveText("Group Meeting"); - await expect(page.locator('[data-testid="event-meta"]')).toContainText("Acme Inc"); - - expect((await page.locator('[data-testid="event-meta"] [data-testid="avatar"]').all()).length).toBe(3); -}); - -test.describe("Organization:", () => { - test.afterEach(({ orgs, users }) => { - orgs.deleteAll(); - users.deleteAll(); - }); - test("Can book a time slot for an organization", async ({ page, users, orgs }) => { - const org = await orgs.create({ - name: "TestOrg", - }); - - const user1 = await users.create({ - organizationId: org.id, - name: "User 1", - roleInOrganization: MembershipRole.ADMIN, - }); - - const user2 = await users.create({ - organizationId: org.id, - name: "User 2", - roleInOrganization: MembershipRole.ADMIN, - }); - await doOnOrgDomain( - { - orgSlug: org.slug, - page, - }, - async () => { - await page.goto(`/${user1.username}+${user2.username}`); - await selectFirstAvailableTimeSlotNextMonth(page); - await bookTimeSlot(page, { - title: "Test meeting", - }); - await expect(page.getByTestId("success-page")).toBeVisible(); - // All the teammates should be in the booking - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - await expect(page.getByText(user1.name!, { exact: true })).toBeVisible(); - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - await expect(page.getByText(user2.name!, { exact: true })).toBeVisible(); - } - ); - }); - - test("dynamic booking for usernames with special characters", async ({ page, users, orgs }) => { - const org = await orgs.create({ - name: "TestOrg", - }); - - const user1 = await users.create({ - organizationId: org.id, - name: "User 1", - roleInOrganization: MembershipRole.MEMBER, - }); - - const user2 = await users.create({ - username: "ßenny-Joo", // ß is a special character - organizationId: org.id, - name: "User 2", - roleInOrganization: MembershipRole.MEMBER, - }); - await doOnOrgDomain( - { - orgSlug: org.slug, - page, - }, - async () => { - const response = await page.goto(`/${user1.username}+${user2.username}`); - expect(response?.status()).not.toBe(500); - } - ); - }); -}); diff --git a/apps/web/playwright/integrations.e2e.ts b/apps/web/playwright/integrations.e2e.ts index 23622a1ed58859..a69d1a0ad8f835 100644 --- a/apps/web/playwright/integrations.e2e.ts +++ b/apps/web/playwright/integrations.e2e.ts @@ -7,7 +7,7 @@ import { v4 as uuidv4 } from "uuid"; import { prisma } from "@calcom/prisma"; -import { test, todo } from "./lib/fixtures"; +import { test } from "./lib/fixtures"; import { submitAndWaitForJsonResponse } from "./lib/testUtils"; declare let global: { @@ -163,196 +163,3 @@ test.afterEach(() => requestInterceptor.resetHandlers()); // Disable API mocking after the tests are done. test.afterAll(() => requestInterceptor.close()); test.afterEach(({ users }) => users.deleteAll()); - -// TODO: Fix MSW mocking -test.fixme("Integrations", () => { - test.beforeEach(() => { - global.E2E_EMAILS = []; - }); - const addZoomIntegration = async function ({ page }: { page: Page }) { - await addOauthBasedIntegration({ - page, - slug: "zoom", - authorization: { - url: "https://zoom.us/oauth/authorize", - verify({ params, code }) { - expect(params.get("redirect_uri")).toBeTruthy(); - return { - status: 307, - headers: { - location: `${params.get("redirect_uri")}?code=${code}`, - }, - }; - }, - }, - token: { - url: "https://zoom.us/oauth/token", - verify({ requestHeaders }) { - const authorization = requestHeaders.get("authorization").replace("Basic ", ""); - const clientPair = Buffer.from(authorization, "base64").toString(); - const [clientId, clientSecret] = clientPair.split(":"); - // Ensure that zoom credentials are passed. - // TODO: We should also ensure that these credentials are correct e.g. in this case should be READ from DB - expect(clientId).toBeTruthy(); - expect(clientSecret).toBeTruthy(); - - return { - status: 200, - body: { - access_token: - "eyJhbGciOiJIUzUxMiIsInYiOiIyLjAiLCJraWQiOiI8S0lEPiJ9.eyJ2ZXIiOiI2IiwiY2xpZW50SWQiOiI8Q2xpZW50X0lEPiIsImNvZGUiOiI8Q29kZT4iLCJpc3MiOiJ1cm46em9vbTpjb25uZWN0OmNsaWVudGlkOjxDbGllbnRfSUQ-IiwiYXV0aGVudGljYXRpb25JZCI6IjxBdXRoZW50aWNhdGlvbl9JRD4iLCJ1c2VySWQiOiI8VXNlcl9JRD4iLCJncm91cE51bWJlciI6MCwiYXVkIjoiaHR0cHM6Ly9vYXV0aC56b29tLnVzIiwiYWNjb3VudElkIjoiPEFjY291bnRfSUQ-IiwibmJmIjoxNTgwMTQ2OTkzLCJleHAiOjE1ODAxNTA1OTMsInRva2VuVHlwZSI6ImFjY2Vzc190b2tlbiIsImlhdCI6MTU4MDE0Njk5MywianRpIjoiPEpUST4iLCJ0b2xlcmFuY2VJZCI6MjV9.F9o_w7_lde4Jlmk_yspIlDc-6QGmVrCbe_6El-xrZehnMx7qyoZPUzyuNAKUKcHfbdZa6Q4QBSvpd6eIFXvjHw", - token_type: "bearer", - refresh_token: - "eyJhbGciOiJIUzUxMiIsInYiOiIyLjAiLCJraWQiOiI8S0lEPiJ9.eyJ2ZXIiOiI2IiwiY2xpZW50SWQiOiI8Q2xpZW50X0lEPiIsImNvZGUiOiI8Q29kZT4iLCJpc3MiOiJ1cm46em9vbTpjb25uZWN0OmNsaWVudGlkOjxDbGllbnRfSUQ-IiwiYXV0aGVudGljYXRpb25JZCI6IjxBdXRoZW50aWNhdGlvbl9JRD4iLCJ1c2VySWQiOiI8VXNlcl9JRD4iLCJncm91cE51bWJlciI6MCwiYXVkIjoiaHR0cHM6Ly9vYXV0aC56b29tLnVzIiwiYWNjb3VudElkIjoiPEFjY291bnRfSUQ-IiwibmJmIjoxNTgwMTQ2OTkzLCJleHAiOjIwNTMxODY5OTMsInRva2VuVHlwZSI6InJlZnJlc2hfdG9rZW4iLCJpYXQiOjE1ODAxNDY5OTMsImp0aSI6IjxKVEk-IiwidG9sZXJhbmNlSWQiOjI1fQ.Xcn_1i_tE6n-wy6_-3JZArIEbiP4AS3paSD0hzb0OZwvYSf-iebQBr0Nucupe57HUDB5NfR9VuyvQ3b74qZAfA", - expires_in: 3599, - // Without this permission, meeting can't be created. - scope: "meeting:write", - }, - }; - }, - }, - }); - }; - test.describe("Zoom App", () => { - test("Can add integration", async ({ page, users }) => { - const user = await users.create(); - await user.apiLogin(); - await addZoomIntegration({ page }); - await page.waitForNavigation({ - url: (url) => { - return url.pathname === "/apps/installed"; - }, - }); - //TODO: Check that disconnect button is now visible - }); - - test("can choose zoom as a location during booking", async ({ page, users }) => { - const user = await users.create(); - await user.apiLogin(); - const eventType = await addLocationIntegrationToFirstEvent({ user }); - await addZoomIntegration({ page }); - await page.waitForNavigation({ - url: (url) => { - return url.pathname === "/apps/installed"; - }, - }); - - await bookEvent(page, `${user.username}/${eventType.slug}`); - // Ensure that zoom was informed about the meeting - // Verify that email had zoom link - // POST https://api.zoom.us/v2/users/me/meetings - // Verify Header-> Authorization: "Bearer " + accessToken, - /** - * { - topic: event.title, - type: 2, // Means that this is a scheduled meeting - start_time: event.startTime, - duration: (new Date(event.endTime).getTime() - new Date(event.startTime).getTime()) / 60000, - //schedule_for: "string", TODO: Used when scheduling the meeting for someone else (needed?) - timezone: event.attendees[0].timeZone, - //password: "string", TODO: Should we use a password? Maybe generate a random one? - agenda: event.description, - settings: { - host_video: true, - participant_video: true, - cn_meeting: false, // TODO: true if host meeting in China - in_meeting: false, // TODO: true if host meeting in India - join_before_host: true, - mute_upon_entry: false, - watermark: false, - use_pmi: false, - approval_type: 2, - audio: "both", - auto_recording: "none", - enforce_apiLogin: false, - registrants_email_notification: true, - }, - }; - */ - }); - test("Can disconnect from integration", async ({ page, users }) => { - const user = await users.create(); - await user.apiLogin(); - await addZoomIntegration({ page }); - await page.waitForNavigation({ - url: (url) => { - return url.pathname === "/apps/installed"; - }, - }); - - // FIXME: First time reaching /apps/installed throws error in UI. - // Temporary use this hack to fix it but remove this HACK before merge. - /** HACK STARTS */ - await page.locator('[href="/apps"]').first().click(); - await page.waitForNavigation({ - url: (url) => { - return url.pathname === "/apps"; - }, - }); - await page.locator('[href="/apps/installed"]').first().click(); - /** HACK ENDS */ - - await page.locator('[data-testid="zoom_video-integration-disconnect-button"]').click(); - await page.locator('[data-testid="confirm-button"]').click(); - await expect(page.locator('[data-testid="confirm-integration-disconnect-button"]')).toHaveCount(0); - }); - }); - - test.describe("Hubspot App", () => { - test("Can add integration", async ({ page, users }) => { - const user = await users.create(); - await user.apiLogin(); - await addOauthBasedIntegration({ - page, - slug: "hubspot", - authorization: { - url: "https://app.hubspot.com/oauth/authorize", - verify({ params, code }) { - expect(params.get("redirect_uri")).toBeTruthy(); - // TODO: We can check if client_id is correctly read from DB or not - expect(params.get("client_id")).toBeTruthy(); - expect(params.get("scope")).toBe( - ["crm.objects.contacts.read", "crm.objects.contacts.write"].join(" ") - ); - - return { - // TODO: Should - status: 307, - headers: { - location: `${params.get("redirect_uri")}?code=${code}`, - }, - }; - }, - }, - token: { - url: "https://api.hubapi.com/oauth/v1/token", - verify({ params, code }) { - expect(params.get("grant_type")).toBe("authorization_code"); - expect(params.get("code")).toBe(code); - expect(params.get("client_id")).toBeTruthy(); - expect(params.get("client_secret")).toBeTruthy(); - return { - status: 200, - body: { - expiresIn: "3600", - }, - }; - }, - }, - }); - await page.waitForNavigation({ - url: (url) => { - return url.pathname === "/apps/installed"; - }, - }); - }); - }); - - todo("Can add Google Calendar"); - - todo("Can add Office 365 Calendar"); - - todo("Can add CalDav Calendar"); - - todo("Can add Apple Calendar"); -}); diff --git a/apps/web/playwright/login.e2e.ts b/apps/web/playwright/login.e2e.ts index 3a898e56299640..e69de29bb2d1d6 100644 --- a/apps/web/playwright/login.e2e.ts +++ b/apps/web/playwright/login.e2e.ts @@ -1,84 +0,0 @@ -import { expect } from "@playwright/test"; - -import { login } from "./fixtures/users"; -import { test } from "./lib/fixtures"; -import { localize } from "./lib/localize"; - -test.describe.configure({ mode: "parallel" }); - -// a test to logout requires both a successful login as logout, to prevent -// a doubling of tests failing on logout & logout, we can group them. -test.describe("user can login & logout successfully", async () => { - test.afterEach(async ({ users }) => { - await users.deleteAll(); - }); - - // TODO: This test is extremely flaky and has been failing a lot, blocking many PRs. Fix this. - // eslint-disable-next-line playwright/no-skipped-test - test.skip("login flow user & logout using dashboard", async ({ page, users }) => { - // log in trail user - await test.step("Log in", async () => { - const user = await users.create(); - await user.login(); - - const shellLocator = page.locator(`[data-testid=dashboard-shell]`); - await page.waitForURL("/event-types"); - await expect(shellLocator).toBeVisible(); - }); - - // - await test.step("Log out", async () => { - const signOutLabel = (await localize("en"))("sign_out"); - const userDropdownDisclose = async () => page.locator("[data-testid=user-dropdown-trigger]").click(); - - // disclose and click the sign out button from the user dropdown - await userDropdownDisclose(); - const signOutBtn = page.locator(`text=${signOutLabel}`); - await signOutBtn.click(); - - await page.locator("[data-testid=logout-btn]").click(); - - // Reroute to the home page to check if the login form shows up - await expect(page.locator(`[data-testid=login-form]`)).toBeVisible(); - }); - }); -}); - -test.describe("Login and logout tests", () => { - test.afterEach(async ({ users }) => { - await users.deleteAll(); - }); - - test.afterEach(async ({ users, page }) => { - await users.logout(); - - // check if we are at the login page - await page.goto("/"); - await expect(page.locator(`[data-testid=login-form]`)).toBeVisible(); - }); - - test.describe("Login flow validations", async () => { - test("Should warn when user does not exist", async ({ page }) => { - const alertMessage = (await localize("en"))("incorrect_email_password"); - - // Login with a non-existent user - const never = "never"; - await login({ username: never }, page); - - // assert for the visibility of the localized alert message - await expect(page.locator(`text=${alertMessage}`)).toBeVisible(); - }); - - test("Should warn when password is incorrect", async ({ page, users }) => { - const alertMessage = (await localize("en"))("incorrect_email_password"); - // by default password===username with the users fixture - const pro = await users.create({ username: "pro" }); - - // login with a wrong password - await login({ username: pro.username, password: "wrong" }, page); - - // assert for the visibility of the localized alert message - await expect(page.locator(`text=${alertMessage}`)).toBeVisible(); - }); - }); -}); diff --git a/apps/web/playwright/managed-event-types.e2e.ts b/apps/web/playwright/managed-event-types.e2e.ts index 767ab3618fb142..e69de29bb2d1d6 100644 --- a/apps/web/playwright/managed-event-types.e2e.ts +++ b/apps/web/playwright/managed-event-types.e2e.ts @@ -1,246 +0,0 @@ -import type { Locator, Page } from "@playwright/test"; -import { expect } from "@playwright/test"; - -import { test } from "./lib/fixtures"; -import { localize } from "./lib/localize"; -import { - bookTimeSlot, - submitAndWaitForResponse, - selectFirstAvailableTimeSlotNextMonth, - setupManagedEvent, -} from "./lib/testUtils"; - -test.afterEach(async ({ users }) => { - await users.deleteAll(); -}); - -/** Short hand to get elements by translation key */ -const getByKey = async (page: Page, key: string) => page.getByText((await localize("en"))(key)); - -test.describe("Managed Event Types", () => { - /** We don't use setupManagedEvent here to test the actual creation flow */ - test("Can create managed event type", async ({ page, users }) => { - // Creating the owner user of the team - const adminUser = await users.create( - { name: "Owner" }, - { - hasTeam: true, - teammates: [{ name: "teammate-1" }], - } - ); - // Creating the member user of the team - // First we work with owner user, logging in - await adminUser.apiLogin(); - // Let's create a team - // Going to create an event type - await page.goto("/event-types"); - const tabItem = page.getByTestId(`horizontal-tab-Owner`); - await expect(tabItem).toBeVisible(); - // We wait until loading is finished - await page.waitForSelector('[data-testid="event-types"]'); - await page.getByTestId("new-event-type").click(); - await page.getByTestId("option-team-1").click(); - // Expecting we can add a managed event type as team owner - const locator = page.locator('div:has(input[value="MANAGED"]) > button'); - - await expect(locator).toBeVisible(); - // Actually creating a managed event type to test things further - await locator.click(); - await page.fill("[name=title]", "managed"); - await page.click("[type=submit]"); - - await page.waitForURL("event-types/**"); - expect(page.url()).toContain("?tabName=team"); - }); - - /** From here we use setupManagedEvent to avoid repeating the previous flow */ - test("Has unlocked fields for admin", async ({ page, users }) => { - const { adminUser, managedEvent } = await setupManagedEvent({ users }); - await adminUser.apiLogin(); - await page.goto(`/event-types/${managedEvent.id}?tabName=setup`); - await page.getByTestId("update-eventtype").waitFor(); - await expect(page.locator('input[name="title"]')).toBeEditable(); - await expect(page.locator('input[name="slug"]')).toBeEditable(); - await expect(page.locator('input[name="length"]')).toBeEditable(); - }); - - test("Exists for added member", async ({ page, users }) => { - const { memberUser, teamEventTitle } = await setupManagedEvent({ - users, - }); - await memberUser.apiLogin(); - await page.goto("/event-types"); - await expect( - page.getByTestId("event-types").locator("div").filter({ hasText: teamEventTitle }).nth(1) - ).toBeVisible(); - }); - - test("Can use Organizer's default app as location", async ({ page, users }) => { - const { adminUser, managedEvent } = await setupManagedEvent({ users }); - await adminUser.apiLogin(); - await page.goto(`/event-types/${managedEvent.id}?tabName=setup`); - await expect(page.getByTestId("vertical-tab-event_setup_tab_title")).toHaveAttribute( - "aria-current", - "page" - ); // fix the race condition - await expect(page.getByTestId("vertical-tab-event_setup_tab_title")).toContainText("Event Setup"); //fix the race condition - await page.locator("#location-select").click(); - const optionText = await getByKey(page, "organizer_default_conferencing_app"); - await expect(optionText).toBeVisible(); - await optionText.click(); - await saveAndWaitForResponse(page); - - await page.getByTestId("vertical-tab-assignment").click(); - await gotoBookingPage(page); - await selectFirstAvailableTimeSlotNextMonth(page); - await bookTimeSlot(page); - - await expect(page.getByTestId("success-page")).toBeVisible(); - }); - - test("Has locked fields for added member", async ({ page, users }) => { - const { memberUser } = await setupManagedEvent({ - users, - }); - await memberUser.apiLogin(); - const managedEvent = await memberUser.getFirstEventAsOwner(); - await page.goto(`/event-types/${managedEvent.id}?tabName=setup`); - await page.waitForURL("event-types/**"); - - await expect(page.locator('input[name="title"]')).not.toBeEditable(); - await expect(page.locator('input[name="slug"]')).not.toBeEditable(); - await expect(page.locator('input[name="length"]')).not.toBeEditable(); - }); - - test("Provides discrete field lock/unlock state for admin", async ({ page, users }) => { - const { adminUser, teamEventTitle } = await setupManagedEvent({ users }); - await adminUser.apiLogin(); - const teamMembership = await adminUser.getFirstTeamMembership(); - - await page.goto(`/event-types?teamId=${teamMembership.team.id}`); - - await page.getByTestId("event-types").locator(`a[title="${teamEventTitle}"]`).click(); - await page.waitForURL("event-types/**"); - - // Locked by default - const titleLockIndicator = page.getByTestId("locked-indicator-title"); - await expect(titleLockIndicator).toBeVisible(); - await expect(titleLockIndicator.locator("[data-state='checked']")).toHaveCount(1); - - // Proceed to unlock and check that it got unlocked - titleLockIndicator.click(); - await expect(titleLockIndicator.locator("[data-state='checked']")).toHaveCount(0); - await expect(titleLockIndicator.locator("[data-state='unchecked']")).toHaveCount(1); - - // Save changes - await page.locator('[type="submit"]').click(); - await expect(titleLockIndicator.locator("[data-state='unchecked']")).toHaveCount(1); - }); - - test("Shows discretionally unlocked field to member", async ({ page, users }) => { - const { memberUser, teamEventTitle } = await setupManagedEvent({ - users, - unlockedFields: { - title: true, - }, - }); - await memberUser.apiLogin(); - await page.goto("/event-types"); - await page.getByTestId("event-types").locator(`a[title="${teamEventTitle}"]`).click(); - await page.waitForURL("event-types/**"); - - await expect(page.locator('input[name="title"]')).toBeEditable(); - }); - - test("Should only update the unlocked fields modified by Admin", async ({ - page: memberPage, - users, - browser, - }) => { - const { adminUser, memberUser, teamEventTitle, teamId } = await setupManagedEvent({ - users, - unlockedFields: { - title: true, - }, - }); - await memberUser.apiLogin(); - await memberPage.goto("/event-types"); - await memberPage.getByTestId("event-types").locator(`a[title="${teamEventTitle}"]`).click(); - await memberPage.waitForURL("event-types/**"); - await expect(memberPage.locator('input[name="title"]')).toBeEditable(); - await memberPage.locator('input[name="title"]').fill(`Managed Event Title`); - await saveAndWaitForResponse(memberPage); - - // We edit the managed event as original owner - const [adminContext, adminPage] = await adminUser.apiLoginOnNewBrowser(browser); - await adminPage.goto(`/event-types?teamId=${teamId}`); - await adminPage.getByTestId("event-types").locator(`a[title="${teamEventTitle}"]`).click(); - await adminPage.waitForURL("event-types/**"); - await adminPage.locator('input[name="length"]').fill(`45`); - await saveAndWaitForResponse(adminPage); - await adminContext.close(); - - await memberPage.goto("/event-types"); - await memberPage.getByTestId("event-types").locator('a[title="Managed Event Title"]').click(); - await memberPage.waitForURL("event-types/**"); - //match length - expect(await memberPage.locator("[data-testid=duration]").getAttribute("value")).toBe("45"); - //ensure description didn't update - expect(await memberPage.locator(`input[name="title"]`).getAttribute("value")).toBe(`Managed Event Title`); - await memberPage.locator('input[name="title"]').fill(`managed`); - // Save changes - await saveAndWaitForResponse(memberPage); - }); - - const MANAGED_EVENT_TABS: { slug: string; locator: (page: Page) => Locator | Promise }[] = [ - { slug: "setup", locator: (page) => getByKey(page, "translate_description_button") }, - { - slug: "team", - locator: (page) => getByKey(page, "automatically_add_all_team_members"), - }, - { - slug: "availability", - locator: (page) => getByKey(page, "members_default_schedule_description"), - }, - { - slug: "limits", - locator: (page) => getByKey(page, "before_event"), - }, - { - slug: "advanced", - locator: (page) => getByKey(page, "event_name_in_calendar"), - }, - { - slug: "apps", - locator: (page) => page.getByRole("heading", { name: "No apps installed" }), - }, - { - slug: "workflows", - locator: (page) => page.getByTestId("empty-screen").getByRole("heading", { name: "Workflows" }), - }, - { - slug: "ai", - locator: (page) => page.getByTestId("empty-screen").getByRole("heading", { name: "Cal.ai" }), - }, - ]; - - MANAGED_EVENT_TABS.forEach((tab) => { - test(`Can render "${tab.slug}" tab`, async ({ page, users }) => { - const { adminUser, managedEvent } = await setupManagedEvent({ users }); - // First we work with owner user, logging in - await adminUser.apiLogin(); - await page.goto(`/event-types/${managedEvent.id}?tabName=${tab.slug}`); - await expect(await tab.locator(page)).toBeVisible(); - }); - }); -}); - -async function gotoBookingPage(page: Page) { - const previewLink = await page.getByTestId("preview-button").getAttribute("href"); - - await page.goto(previewLink ?? ""); -} - -async function saveAndWaitForResponse(page: Page) { - await submitAndWaitForResponse(page, "/api/trpc/eventTypes/update?batch=1"); -} diff --git a/apps/web/playwright/organization-consolidated.e2e.ts b/apps/web/playwright/organization-consolidated.e2e.ts new file mode 100644 index 00000000000000..cec92f9fc5e050 --- /dev/null +++ b/apps/web/playwright/organization-consolidated.e2e.ts @@ -0,0 +1,156 @@ +import { expect } from "@playwright/test"; + +import { test } from "./lib/fixtures"; +import { selectFirstAvailableTimeSlotNextMonth } from "./lib/testUtils"; + +test.describe.configure({ mode: "parallel" }); + +test.describe("Organization Management", () => { + test("Should be able to create an organization", async ({ page, users }) => { + const [user] = users.get(); + await user.apiLogin(); + + await page.goto("/settings/organizations/new"); + await page.locator('[name="name"]').fill("Test Organization"); + await page.locator('[name="slug"]').fill("test-org"); + await page.locator('[data-testid="create-organization"]').click(); + + await expect(page.locator('[data-testid="organization-created"]')).toBeVisible(); + }); + + test("Should be able to invite members to organization", async ({ page, users }) => { + const [user] = users.get(); + await user.apiLogin(); + + await page.goto("/settings/organizations/new"); + await page.locator('[name="name"]').fill("Test Organization"); + await page.locator('[name="slug"]').fill("test-org"); + await page.locator('[data-testid="create-organization"]').click(); + + await page.goto("/settings/organizations/members"); + await page.locator('[data-testid="invite-member"]').click(); + await page.locator('[name="email"]').fill("member@example.com"); + await page.locator('[data-testid="send-invitation"]').click(); + + await expect(page.locator('[data-testid="invitation-sent"]')).toBeVisible(); + }); + + test("Should handle organization booking flow", async ({ page, users }) => { + const [user] = users.get(); + await user.apiLogin(); + + await page.goto("/settings/organizations/new"); + await page.locator('[name="name"]').fill("Test Organization"); + await page.locator('[name="slug"]').fill("test-org"); + await page.locator('[data-testid="create-organization"]').click(); + + await page.goto("/event-types"); + await page.locator('[data-testid="new-event-type"]').click(); + await page.locator("[name=title]").fill("Org Event"); + await page.locator("[name=slug]").fill("org-event"); + await page.locator("[data-testid=update-eventtype]").click(); + + await page.goto("/org/test-org/org-event"); + await selectFirstAvailableTimeSlotNextMonth(page); + await page.locator('[name="name"]').fill("John Doe"); + await page.locator('[name="email"]').fill("john@example.com"); + await page.locator('[data-testid="confirm-book-button"]').click(); + + await expect(page.locator('[data-testid="success-page"]')).toBeVisible(); + }); + + test("Should manage organization teams", async ({ page, users }) => { + const [user] = users.get(); + await user.apiLogin(); + + await page.goto("/settings/organizations/new"); + await page.locator('[name="name"]').fill("Test Organization"); + await page.locator('[name="slug"]').fill("test-org"); + await page.locator('[data-testid="create-organization"]').click(); + + await page.goto("/settings/organizations/teams"); + await page.locator('[data-testid="create-team"]').click(); + await page.locator('[name="name"]').fill("Test Team"); + await page.locator('[data-testid="save-team"]').click(); + + await expect(page.locator('[data-testid="team-created"]')).toBeVisible(); + }); + + test("Should handle organization settings", async ({ page, users }) => { + const [user] = users.get(); + await user.apiLogin(); + + await page.goto("/settings/organizations/new"); + await page.locator('[name="name"]').fill("Test Organization"); + await page.locator('[name="slug"]').fill("test-org"); + await page.locator('[data-testid="create-organization"]').click(); + + await page.goto("/settings/organizations/profile"); + await page.locator('[name="name"]').fill("Updated Organization"); + await page.locator('[data-testid="save-organization"]').click(); + + await expect(page.locator('[data-testid="organization-updated"]')).toBeVisible(); + }); +}); + +test.describe("Organization Attributes", () => { + test("Should manage organization attributes", async ({ page, users }) => { + const [user] = users.get(); + await user.apiLogin(); + + await page.goto("/settings/organizations/new"); + await page.locator('[name="name"]').fill("Test Organization"); + await page.locator('[name="slug"]').fill("test-org"); + await page.locator('[data-testid="create-organization"]').click(); + + await page.goto("/settings/organizations/attributes"); + await page.locator('[data-testid="add-attribute"]').click(); + await page.locator('[name="name"]').fill("Department"); + await page.locator('[name="type"]').selectOption("text"); + await page.locator('[data-testid="save-attribute"]').click(); + + await expect(page.locator('[data-testid="attribute-created"]')).toBeVisible(); + }); + + test("Should assign attributes to members", async ({ page, users }) => { + const [user] = users.get(); + await user.apiLogin(); + + await page.goto("/settings/organizations/new"); + await page.locator('[name="name"]').fill("Test Organization"); + await page.locator('[name="slug"]').fill("test-org"); + await page.locator('[data-testid="create-organization"]').click(); + + await page.goto("/settings/organizations/attributes"); + await page.locator('[data-testid="add-attribute"]').click(); + await page.locator('[name="name"]').fill("Department"); + await page.locator('[name="type"]').selectOption("text"); + await page.locator('[data-testid="save-attribute"]').click(); + + await page.goto("/settings/organizations/members"); + await page.locator('[data-testid="member-settings"]').first().click(); + await page.locator('[name="Department"]').fill("Engineering"); + await page.locator('[data-testid="save-member"]').click(); + + await expect(page.locator('[data-testid="member-updated"]')).toBeVisible(); + }); +}); + +test.describe("Organization Workflows", () => { + test("Should create organization-wide workflows", async ({ page, users }) => { + const [user] = users.get(); + await user.apiLogin(); + + await page.goto("/settings/organizations/new"); + await page.locator('[name="name"]').fill("Test Organization"); + await page.locator('[name="slug"]').fill("test-org"); + await page.locator('[data-testid="create-organization"]').click(); + + await page.goto("/settings/organizations/workflows"); + await page.locator('[data-testid="new-workflow"]').click(); + await page.locator('[name="name"]').fill("Org Workflow"); + await page.locator('[data-testid="create-workflow"]').click(); + + await expect(page.locator('[data-testid="workflow-created"]')).toBeVisible(); + }); +}); diff --git a/apps/web/playwright/organization/booking.e2e.ts b/apps/web/playwright/organization/booking.e2e.ts index 538d4918ac8f90..e69de29bb2d1d6 100644 --- a/apps/web/playwright/organization/booking.e2e.ts +++ b/apps/web/playwright/organization/booking.e2e.ts @@ -1,803 +0,0 @@ -import type { Page } from "@playwright/test"; -import { expect } from "@playwright/test"; -import { JSDOM } from "jsdom"; -import { uuid } from "short-uuid"; - -import { getOrgUsernameFromEmail } from "@calcom/features/auth/signup/utils/getOrgUsernameFromEmail"; -import { WEBAPP_URL } from "@calcom/lib/constants"; -import { MembershipRole, SchedulingType } from "@calcom/prisma/enums"; - -import { test } from "../lib/fixtures"; -import { - bookTeamEvent, - bookTimeSlot, - doOnOrgDomain, - expectPageToBeNotFound, - selectFirstAvailableTimeSlotNextMonth, - submitAndWaitForResponse, - testName, -} from "../lib/testUtils"; -import { expectExistingUserToBeInvitedToOrganization } from "../team/expects"; -import { gotoPathAndExpectRedirectToOrgDomain } from "./lib/gotoPathAndExpectRedirectToOrgDomain"; -import { acceptTeamOrOrgInvite, inviteExistingUserToOrganization } from "./lib/inviteUser"; - -function getOrgOrigin(orgSlug: string | null) { - if (!orgSlug) { - throw new Error("orgSlug is required"); - } - - let orgOrigin = WEBAPP_URL.replace("://app", `://${orgSlug}`); - orgOrigin = orgOrigin.includes(orgSlug) ? orgOrigin : WEBAPP_URL.replace("://", `://${orgSlug}.`); - return orgOrigin; -} - -test.describe.configure({ mode: "parallel" }); - -test.describe("Bookings", () => { - test.afterEach(async ({ orgs, users, page }) => { - await users.deleteAll(); - await orgs.deleteAll(); - }); - - test.describe("Team Event", () => { - test("Can create a booking for Collective EventType", async ({ page, users, orgs }) => { - const org = await orgs.create({ - name: "TestOrg", - }); - 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", - organizationId: org.id, - roleInOrganization: MembershipRole.MEMBER, - }, - { - hasTeam: true, - teammates: teamMatesObj, - schedulingType: SchedulingType.COLLECTIVE, - } - ); - const { team } = await owner.getFirstTeamMembership(); - const teamEvent = await owner.getFirstTeamEvent(team.id); - - await expectPageToBeNotFound({ page, url: `/team/${team.slug}/${teamEvent.slug}` }); - await doOnOrgDomain( - { - orgSlug: org.slug, - page, - }, - async () => { - await bookTeamEvent({ page, team, event: teamEvent }); - // All the teammates should be in the booking - for (const teammate of teamMatesObj.concat([{ name: owner.name || "" }])) { - await expect(page.getByText(teammate.name, { exact: true })).toBeVisible(); - } - } - ); - - // TODO: Assert whether the user received an email - }); - - test("Can create a booking for Collective EventType with only phone number", async ({ - page, - users, - orgs, - }) => { - const org = await orgs.create({ - name: "TestOrg", - }); - 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", - organizationId: org.id, - roleInOrganization: MembershipRole.MEMBER, - }, - { - hasTeam: true, - teammates: teamMatesObj, - schedulingType: SchedulingType.COLLECTIVE, - } - ); - const { team } = await owner.getFirstTeamMembership(); - const teamEvent = await owner.getFirstTeamEvent(team.id); - await owner.apiLogin(); - - await markPhoneNumberAsRequiredAndEmailAsOptional(page, teamEvent.id); - - await expectPageToBeNotFound({ page, url: `/team/${team.slug}/${teamEvent.slug}` }); - await doOnOrgDomain( - { - orgSlug: org.slug, - page, - }, - async () => { - await bookTeamEvent({ - page, - team, - event: teamEvent, - opts: { attendeePhoneNumber: "+918888888888" }, - }); - // All the teammates should be in the booking - for (const teammate of teamMatesObj.concat([{ name: owner.name || "" }])) { - 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, orgs }) => { - const org = await orgs.create({ - name: "TestOrg", - }); - 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", - organizationId: org.id, - roleInOrganization: MembershipRole.MEMBER, - }, - { - hasTeam: true, - teammates: teamMatesObj, - schedulingType: SchedulingType.ROUND_ROBIN, - } - ); - - const { team } = await owner.getFirstTeamMembership(); - const teamEvent = await owner.getFirstTeamEvent(team.id); - const eventHostsObj = [...teamMatesObj, { name: "pro-user" }]; - - await expectPageToBeNotFound({ page, url: `/team/${team.slug}/${teamEvent.slug}` }); - - await doOnOrgDomain( - { - orgSlug: org.slug, - page, - }, - async () => { - await bookTeamEvent({ page, team, event: teamEvent, teamMatesObj: eventHostsObj }); - - // 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(); - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - expect(teamMatesObj.concat([{ name: owner.name! }]).some(({ name }) => name === chosenUser)).toBe( - true - ); - } - ); - // TODO: Assert whether the user received an email - }); - - test("Can create a booking for Round Robin EventType with both phone number and email required", async ({ - page, - users, - orgs, - }) => { - const org = await orgs.create({ - name: "TestOrg", - }); - 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", - organizationId: org.id, - roleInOrganization: MembershipRole.MEMBER, - }, - { - hasTeam: true, - teammates: teamMatesObj, - schedulingType: SchedulingType.ROUND_ROBIN, - } - ); - - const { team } = await owner.getFirstTeamMembership(); - const teamEvent = await owner.getFirstTeamEvent(team.id); - const eventHostsObj = [...teamMatesObj, { name: "pro-user" }]; - await owner.apiLogin(); - - await markPhoneNumberAsRequiredField(page, teamEvent.id); - - await expectPageToBeNotFound({ page, url: `/team/${team.slug}/${teamEvent.slug}` }); - - await doOnOrgDomain( - { - orgSlug: org.slug, - page, - }, - async () => { - await bookTeamEvent({ - page, - team, - event: teamEvent, - teamMatesObj: eventHostsObj, - opts: { attendeePhoneNumber: "+918888888888" }, - }); - - // 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(); - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - expect(teamMatesObj.concat([{ name: owner.name! }]).some(({ name }) => name === chosenUser)).toBe( - true - ); - } - ); - // TODO: Assert whether the user received an email - }); - - test("Can access booking page with event slug and team page in lowercase/uppercase/mixedcase", async ({ - page, - orgs, - users, - }) => { - const org = await orgs.create({ - name: "TestOrg", - }); - 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", - organizationId: org.id, - roleInOrganization: MembershipRole.MEMBER, - }, - { - hasTeam: true, - teammates: teamMatesObj, - schedulingType: SchedulingType.COLLECTIVE, - } - ); - const { team } = await owner.getFirstTeamMembership(); - const { slug: teamEventSlug } = await owner.getFirstTeamEvent(team.id); - - const teamSlugUpperCase = team.slug?.toUpperCase(); - const teamEventSlugUpperCase = teamEventSlug.toUpperCase(); - - // This is the most closest to the actual user flow as org1.cal.com maps to /org/orgSlug - await page.goto(`/org/${org.slug}/${teamSlugUpperCase}/${teamEventSlugUpperCase}`); - await page.waitForSelector("[data-testid=day]"); - }); - - test("Round robin event type properly rotates hosts", async ({ page, users, orgs }) => { - const org = await orgs.create({ - name: "TestOrg", - }); - const teamMatesObj = [{ name: "teammate-1" }, { name: "teammate-2" }, { name: "teammate-3" }]; - - const owner = await users.create( - { - username: "pro-user", - name: "pro-user", - organizationId: org.id, - roleInOrganization: MembershipRole.MEMBER, - }, - { - hasTeam: true, - teammates: teamMatesObj, - schedulingType: SchedulingType.ROUND_ROBIN, - } - ); - - const { team } = await owner.getFirstTeamMembership(); - const teamEvent = await owner.getFirstTeamEvent(team.id); - const eventHostsObj = [...teamMatesObj, { name: "pro-user" }]; - - await doOnOrgDomain( - { - orgSlug: org.slug, - page, - }, - async () => { - await bookTeamEvent({ page, team, event: teamEvent, teamMatesObj: eventHostsObj }); - const firstHost = await page.getByTestId("booking-host-name").textContent(); - expect(firstHost).not.toBeNull(); - - // Second booking - should get a different host - await page.goto(`/org/${org.slug}/${team.slug}/${teamEvent.slug}`); - await selectFirstAvailableTimeSlotNextMonth(page); - await bookTimeSlot(page, { email: "attendee1@test.com" }); - await expect(page.getByTestId("success-page")).toBeVisible(); - const secondHost = await page.getByTestId("booking-host-name").textContent(); - expect(secondHost).not.toBeNull(); - expect(secondHost).not.toBe(firstHost); - - // Third booking - should get a different host - await page.goto(`/org/${org.slug}/${team.slug}/${teamEvent.slug}`); - await selectFirstAvailableTimeSlotNextMonth(page); - await bookTimeSlot(page, { email: "attendee2@test.com" }); - await expect(page.getByTestId("success-page")).toBeVisible(); - const thirdHost = await page.getByTestId("booking-host-name").textContent(); - expect(thirdHost).not.toBeNull(); - expect(thirdHost).not.toBe(firstHost); - expect(thirdHost).not.toBe(secondHost); - - // Fourth booking - should get a different host - await page.goto(`/org/${org.slug}/${team.slug}/${teamEvent.slug}`); - await selectFirstAvailableTimeSlotNextMonth(page); - await bookTimeSlot(page, { email: "attendee3@test.com" }); - await expect(page.getByTestId("success-page")).toBeVisible(); - const fourthHost = await page.getByTestId("booking-host-name").textContent(); - expect(fourthHost).not.toBeNull(); - expect(fourthHost).not.toBe(firstHost); - expect(fourthHost).not.toBe(secondHost); - expect(fourthHost).not.toBe(thirdHost); - - // Verify all hosts were used - const allHosts = [firstHost, secondHost, thirdHost, fourthHost]; - const uniqueHosts = new Set(allHosts); - expect(uniqueHosts.size).toBe(4); - } - ); - }); - - test("Round robin event type with confirmation required handles rejection and rebooking correctly with the same details and slot", async ({ - page, - users, - orgs, - browser, - }) => { - const org = await orgs.create({ - name: "TestOrg", - }); - 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", - organizationId: org.id, - roleInOrganization: MembershipRole.MEMBER, - }, - { - hasTeam: true, - teammates: teamMatesObj, - schedulingType: SchedulingType.ROUND_ROBIN, - } - ); - - const { team } = await owner.getFirstTeamMembership(); - const teamEvent = await owner.getFirstTeamEvent(team.id); - const eventHostsObj = [...teamMatesObj, { name: "pro-user" }]; - - // Enable confirmation required for the event - await owner.apiLogin(); - await page.goto(`/event-types/${teamEvent.id}?tabName=advanced`); - await page.getByTestId("requires-confirmation").click(); - await page.getByTestId("update-eventtype").click(); - await page.waitForResponse((response) => response.url().includes("/api/trpc/eventTypes/update")); - - await doOnOrgDomain( - { - orgSlug: org.slug, - page, - }, - async () => { - await bookTeamEvent({ page, team, event: teamEvent, teamMatesObj: eventHostsObj }); - const firstHost = await page.getByTestId("booking-host-name").textContent(); - expect(firstHost).not.toBeNull(); - - const bookingUid = page.url().split("/booking/")[1]; - expect(bookingUid).not.toBeNull(); - - await page.goto("/auth/logout"); - - const allUsers = users.get(); - const hostUser = allUsers.find((mate) => mate.name === firstHost); - if (!hostUser) throw new Error("Host not found"); - - await hostUser.apiLogin(); - const [secondContext, secondPage] = await hostUser.apiLoginOnNewBrowser(browser); - - // Reject the booking - await secondPage.goto("/bookings/upcoming"); - await secondPage.click('[data-testid="reject"]'); - await submitAndWaitForResponse(secondPage, "/api/trpc/bookings/confirm?batch=1", { - action: () => secondPage.click('[data-testid="rejection-confirm"]'), - }); - - // Logout and go back to booking page - await secondPage.goto("/auth/logout"); - await secondPage.goto(`/org/${org.slug}/${team.slug}/${teamEvent.slug}`); - - // Rebook with the same details - await selectFirstAvailableTimeSlotNextMonth(secondPage); - await bookTimeSlot(secondPage); - await expect(secondPage.getByTestId("success-page")).toBeVisible(); - - // Verify a new host is assigned - const newHost = await secondPage.getByTestId("booking-host-name").textContent(); - expect(newHost).not.toBeNull(); - expect(newHost).toBe(firstHost); - - // Verify the booking was successful by checking the new booking UID - const newBookingUid = secondPage.url().split("/booking/")[1]; - expect(newBookingUid).not.toBeNull(); - expect(newBookingUid).not.toBe(bookingUid); - await secondContext.close(); - } - ); - }); - }); - - test.describe("User Event", () => { - test("Can create a booking", async ({ page, users, orgs }) => { - const org = await orgs.create({ - name: "TestOrg", - }); - - const user = await users.create({ - username: "pro-user", - name: "pro-user", - organizationId: org.id, - roleInOrganization: MembershipRole.MEMBER, - }); - - const event = await user.getFirstEventAsOwner(); - await expectPageToBeNotFound({ page, url: `/${user.username}/${event.slug}` }); - - await doOnOrgDomain( - { - orgSlug: org.slug, - page, - }, - async () => { - await bookUserEvent({ page, user, event }); - } - ); - }); - - test.describe("User Event with same slug as another user's", () => { - test("booking is created for first user when first user is booked", async ({ page, users, orgs }) => { - const org = await orgs.create({ - name: "TestOrg", - }); - - const user1 = await users.create({ - username: "user1", - name: "User 1", - organizationId: org.id, - roleInOrganization: MembershipRole.MEMBER, - }); - - const user2 = await users.create({ - username: "user2", - name: "User2", - organizationId: org.id, - roleInOrganization: MembershipRole.MEMBER, - }); - - const user1Event = await user1.getFirstEventAsOwner(); - - await doOnOrgDomain( - { - orgSlug: org.slug, - page, - }, - async () => { - await bookUserEvent({ page, user: user1, event: user1Event }); - } - ); - }); - test("booking is created for second user when second user is booked", async ({ page, users, orgs }) => { - const org = await orgs.create({ - name: "TestOrg", - }); - - const user1 = await users.create({ - username: "user1", - name: "User 1", - organizationId: org.id, - roleInOrganization: MembershipRole.MEMBER, - }); - - const user2 = await users.create({ - username: "user2", - name: "User2", - organizationId: org.id, - roleInOrganization: MembershipRole.MEMBER, - }); - - const user2Event = await user2.getFirstEventAsOwner(); - - await doOnOrgDomain( - { - orgSlug: org.slug, - page, - }, - async () => { - await bookUserEvent({ page, user: user2, event: user2Event }); - } - ); - }); - }); - - test("check SSR and OG", async ({ page, users, orgs }) => { - const name = "Test User"; - const org = await orgs.create({ - name: "TestOrg", - }); - - const user = await users.create({ - name, - organizationId: org.id, - roleInOrganization: MembershipRole.MEMBER, - }); - - const firstEventType = await user.getFirstEventAsOwner(); - const calLink = `/${user.username}/${firstEventType.slug}`; - await doOnOrgDomain( - { - orgSlug: org.slug, - page, - }, - async () => { - const [response] = await Promise.all([ - // This promise resolves to the main resource response - page.waitForResponse( - (response) => response.url().includes(`${calLink}`) && response.status() === 200 - ), - - // Trigger the page navigation - page.goto(`${calLink}`), - ]); - const ssrResponse = await response.text(); - const document = new JSDOM(ssrResponse).window.document; - const orgOrigin = getOrgOrigin(org.slug); - const titleText = document.querySelector("title")?.textContent; - const ogImage = document.querySelector('meta[property="og:image"]')?.getAttribute("content"); - const ogUrl = document.querySelector('meta[property="og:url"]')?.getAttribute("content"); - const canonicalLink = document.querySelector('link[rel="canonical"]')?.getAttribute("href"); - expect(titleText).toContain(name); - expect(ogUrl).toEqual(`${orgOrigin}${calLink}`); - expect(canonicalLink).toEqual(`${orgOrigin}${calLink}`); - // Verify that there is correct URL that would generate the awesome OG image - expect(ogImage).toContain( - "/_next/image?w=1200&q=100&url=%2Fapi%2Fsocial%2Fog%2Fimage%3Ftype%3Dmeeting%26title%3D" - ); - // Verify Organizer Name in the URL - expect(ogImage).toContain("meetingProfileName%3DTest%2BUser"); - } - ); - }); - }); - - test.describe("Scenario with same username in and outside organization", () => { - test("Can create a booking for user with same username in and outside organization", async ({ - page, - users, - orgs, - }) => { - const org = await orgs.create({ - name: "TestOrg", - }); - - const username = "john"; - const userInsideOrganization = await users.create({ - username, - useExactUsername: true, - email: `john-inside-${uuid()}@example.com`, - name: "John Inside Organization", - organizationId: org.id, - roleInOrganization: MembershipRole.MEMBER, - eventTypes: [ - { - title: "John Inside Org's Meeting", - slug: "john-inside-org-meeting", - length: 15, - }, - ], - }); - - const userOutsideOrganization = await users.create({ - username, - name: "John Outside Organization", - email: `john-outside-${uuid()}@example.com`, - useExactUsername: true, - eventTypes: [ - { - title: "John Outside Org's Meeting", - slug: "john-outside-org-meeting", - length: 15, - }, - ], - }); - - const eventForUserInsideOrganization = await userInsideOrganization.getFirstEventAsOwner(); - const eventForUserOutsideOrganization = await userOutsideOrganization.getFirstEventAsOwner(); - - // John Inside Org's meeting can't be accessed on userOutsideOrganization's namespace - await expectPageToBeNotFound({ - page, - url: `/${userOutsideOrganization.username}/john-inside-org-meeting`, - }); - - await bookUserEvent({ page, user: userOutsideOrganization, event: eventForUserOutsideOrganization }); - - await doOnOrgDomain( - { - orgSlug: org.slug, - page, - }, - async () => { - // John Outside Org's meeting can't be accessed on userInsideOrganization's namespaces - await expectPageToBeNotFound({ - page, - url: `/${userInsideOrganization.username}/john-outside-org-meeting`, - }); - await bookUserEvent({ page, user: userInsideOrganization, event: eventForUserInsideOrganization }); - } - ); - }); - }); - - test.describe("Inviting an existing user and then", () => { - test("create a booking on new link", async ({ page, browser, users, orgs, emails }) => { - const org = await orgs.create({ - name: "TestOrg", - }); - - const owner = await users.create({ - username: "owner", - name: "owner", - organizationId: org.id, - roleInOrganization: MembershipRole.OWNER, - }); - - const userOutsideOrganization = await users.create({ - username: "john", - name: "John Outside Organization", - }); - - await owner.apiLogin(); - - const { invitedUserEmail } = await inviteExistingUserToOrganization({ - page, - organizationId: org.id, - organizationSlug: org.slug, - user: userOutsideOrganization, - usersFixture: users, - }); - - const inviteLink = await expectExistingUserToBeInvitedToOrganization(page, emails, invitedUserEmail); - if (!inviteLink) { - throw new Error("Invite link not found"); - } - const usernameInOrg = getOrgUsernameFromEmail( - invitedUserEmail, - org.organizationSettings?.orgAutoAcceptEmail ?? null - ); - - const usernameOutsideOrg = userOutsideOrganization.username; - // Before invite is accepted the booking page isn't available - await expectPageToBeNotFound({ page, url: `/${usernameInOrg}` }); - const [newContext, newPage] = await userOutsideOrganization.apiLoginOnNewBrowser(browser); - await acceptTeamOrOrgInvite(newPage); - await newContext.close(); - await test.step("Book through new link", async () => { - await doOnOrgDomain( - { - orgSlug: org.slug, - page, - }, - async () => { - await bookUserEvent({ - page, - user: { - username: usernameInOrg, - name: userOutsideOrganization.name, - }, - event: await userOutsideOrganization.getFirstEventAsOwner(), - }); - } - ); - }); - - await test.step("Booking through old link redirects to new link on org domain", async () => { - const event = await userOutsideOrganization.getFirstEventAsOwner(); - await gotoPathAndExpectRedirectToOrgDomain({ - page, - org, - path: `/${usernameOutsideOrg}/${event.slug}`, - expectedPath: `/${usernameInOrg}/${event.slug}`, - }); - // As the redirection correctly happens, the booking would work too which we have verified in previous step. But we can't test that with org domain as that domain doesn't exist. - }); - }); - }); -}); - -async function bookUserEvent({ - page, - user, - event, -}: { - page: Page; - user: { - username: string | null; - name: string | null; - }; - event: { slug: string; title: string }; -}) { - await page.goto(`/${user.username}/${event.slug}`); - - await selectFirstAvailableTimeSlotNextMonth(page); - await bookTimeSlot(page); - await expect(page.getByTestId("success-page")).toBeVisible(); - - // The title of the booking - const BookingTitle = `${event.title} between ${user.name} and ${testName}`; - await expect(page.getByTestId("booking-title")).toHaveText(BookingTitle); - // The booker should be in the attendee list - await expect(page.getByTestId(`attendee-name-${testName}`)).toHaveText(testName); -} - -const markPhoneNumberAsRequiredAndEmailAsOptional = async (page: Page, eventId: number) => { - // Make phone as required - await markPhoneNumberAsRequiredField(page, eventId); - - // Make email as not required - await page.locator('[data-testid="field-email"] [data-testid="edit-field-action"]').click(); - const emailRequiredFiled = await page.locator('[data-testid="field-required"]'); - await emailRequiredFiled.locator("> :nth-child(2)").click(); - await page.getByTestId("field-add-save").click(); - await submitAndWaitForResponse(page, "/api/trpc/eventTypes/update?batch=1", { - action: () => page.locator("[data-testid=update-eventtype]").click(), - }); -}; - -const markPhoneNumberAsRequiredField = async (page: Page, eventId: number) => { - await page.goto(`/event-types/${eventId}?tabName=advanced`); - await expect(page.getByTestId("vertical-tab-event_setup_tab_title")).toContainText("Event Setup"); // fix the race condition - - await page.locator('[data-testid="field-attendeePhoneNumber"] [data-testid="toggle-field"]').click(); - await page.locator('[data-testid="field-attendeePhoneNumber"] [data-testid="edit-field-action"]').click(); - const phoneRequiredFiled = await page.locator('[data-testid="field-required"]'); - await phoneRequiredFiled.locator("> :nth-child(1)").click(); - await page.getByTestId("field-add-save").click(); - await submitAndWaitForResponse(page, "/api/trpc/eventTypes/update?batch=1", { - action: () => page.locator("[data-testid=update-eventtype]").click(), - }); -}; diff --git a/apps/web/playwright/payment.e2e.ts b/apps/web/playwright/payment.e2e.ts index 0f7a677450fbaf..e69de29bb2d1d6 100644 --- a/apps/web/playwright/payment.e2e.ts +++ b/apps/web/playwright/payment.e2e.ts @@ -1,57 +0,0 @@ -import { expect } from "@playwright/test"; - -import { bookTimeSlot, selectFirstAvailableTimeSlotNextMonth } from "@calcom/web/playwright/lib/testUtils"; - -import { test } from "./lib/fixtures"; - -test.describe.configure({ mode: "parallel" }); - -test.describe("Payment", () => { - test.describe("user", () => { - test.afterEach(async ({ users }) => { - await users.deleteAll(); - }); - - test("should create a mock payment for a user", async ({ context, users, page }) => { - test.skip(process.env.MOCK_PAYMENT_APP_ENABLED === undefined, "Skipped as Stripe is not installed"); - - const user = await users.create(); - await user.apiLogin(); - await page.goto("/apps"); - - await page.getByPlaceholder("Search").click(); - await page.getByPlaceholder("Search").fill("mock"); - - await page.getByTestId("install-app-button").click(); - - await page.waitForURL((url) => url.pathname.endsWith("/apps/installed/payment")); - - await page.getByRole("link", { name: "Event Types" }).click(); - - await page.getByRole("link", { name: /^30 min/ }).click(); - await page.getByTestId("vertical-tab-apps").click(); - await page.locator("#event-type-form").getByRole("switch").click(); - await page.getByPlaceholder("Price").click(); - await page.getByPlaceholder("Price").fill("1"); - - await page.locator("#test-mock-payment-app-currency-id").click(); - await page.getByTestId("select-option-USD").click(); - - await page.getByTestId("update-eventtype").click(); - - await page.goto(`${user.username}/30-min`); - - await selectFirstAvailableTimeSlotNextMonth(page); - await bookTimeSlot(page); - await page.waitForURL((url) => url.pathname.includes("/payment/")); - - const dataNextJsRouter = await page.evaluate(() => - window.document.documentElement.getAttribute("data-nextjs-router") - ); - - expect(dataNextJsRouter).toEqual("app"); - - await page.getByText("Payment", { exact: true }).waitFor(); - }); - }); -}); diff --git a/apps/web/playwright/reschedule.e2e.ts b/apps/web/playwright/reschedule.e2e.ts index 0ec9ccb16c2b3c..e69de29bb2d1d6 100644 --- a/apps/web/playwright/reschedule.e2e.ts +++ b/apps/web/playwright/reschedule.e2e.ts @@ -1,551 +0,0 @@ -import type { Page } from "@playwright/test"; -import { expect } from "@playwright/test"; - -import dayjs from "@calcom/dayjs"; -import prisma from "@calcom/prisma"; -import { MembershipRole } from "@calcom/prisma/client"; -import { BookingStatus } from "@calcom/prisma/enums"; -import { bookingMetadataSchema } from "@calcom/prisma/zod-utils"; - -import { test } from "./lib/fixtures"; -import { - bookTimeSlot, - confirmReschedule, - doOnOrgDomain, - goToUrlWithErrorHandling, - IS_STRIPE_ENABLED, - selectFirstAvailableTimeSlotNextMonth, - submitAndWaitForResponse, -} from "./lib/testUtils"; - -test.describe.configure({ mode: "parallel" }); - -test.afterEach(({ users }) => users.deleteAll()); - -test.describe("Reschedule Tests", async () => { - test("Should do a booking request reschedule from /bookings", async ({ page, users, bookings }) => { - const user = await users.create(); - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const booking = await bookings.create(user.id, user.username, user.eventTypes[0].id!, { - status: BookingStatus.ACCEPTED, - }); - - await user.apiLogin(); - await page.goto("/bookings/upcoming"); - - await page.locator('[data-testid="edit_booking"]').nth(0).click(); - - await page.locator('[data-testid="reschedule_request"]').click(); - - await page.fill('[data-testid="reschedule_reason"]', "I can't longer have it"); - - await page.locator('button[data-testid="send_request"]').click(); - await expect(page.locator('[id="modal-title"]')).toBeHidden(); - - const updatedBooking = await booking.self(); - - expect(updatedBooking?.rescheduled).toBe(true); - expect(updatedBooking?.cancellationReason).toBe("I can't longer have it"); - expect(updatedBooking?.status).toBe(BookingStatus.CANCELLED); - await booking.delete(); - }); - - test("Should not show reschedule and request reschedule option if booking in past and disallowed", async ({ - page, - users, - bookings, - }) => { - const user = await users.create(); - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const booking = await bookings.create(user.id, user.username, user.eventTypes[0].id!, { - status: BookingStatus.ACCEPTED, - startTime: dayjs().subtract(2, "day").toDate(), - endTime: dayjs().subtract(2, "day").add(30, "minutes").toDate(), - }); - - await prisma.eventType.update({ - where: { - id: user.eventTypes[0].id, - }, - data: { - allowReschedulingPastBookings: true, - }, - }); - - await user.apiLogin(); - await page.goto("/bookings/past"); - - await page.locator('[data-testid="edit_booking"]').nth(0).click(); - - await expect(page.locator('[data-testid="reschedule"]')).toBeVisible(); - await expect(page.locator('[data-testid="reschedule_request"]')).toBeVisible(); - - await prisma.eventType.update({ - where: { - id: user.eventTypes[0].id, - }, - data: { - allowReschedulingPastBookings: false, - }, - }); - - await page.reload(); - - await page.locator('[data-testid="edit_booking"]').nth(0).click(); - - await expect(page.locator('[data-testid="reschedule"]')).toBeHidden(); - await expect(page.locator('[data-testid="reschedule_request"]')).toBeHidden(); - }); - - test("Should display former time when rescheduling availability", async ({ page, users, bookings }) => { - const user = await users.create(); - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const booking = await bookings.create(user.id, user.username, user.eventTypes[0].id!, { - status: BookingStatus.ACCEPTED, - rescheduled: true, - }); - - await page.goto(`/reschedule/${booking.uid}`); - - await selectFirstAvailableTimeSlotNextMonth(page); - - const formerTimeElement = page.locator('[data-testid="former_time_p"]'); - await expect(formerTimeElement).toBeVisible(); - await booking.delete(); - }); - - test("Should display request reschedule send on bookings/cancelled", async ({ page, users, bookings }) => { - const user = await users.create(); - const booking = await bookings.create(user.id, user.username, user.eventTypes[0].id, { - status: BookingStatus.CANCELLED, - rescheduled: true, - }); - - await user.apiLogin(); - await page.goto("/bookings/cancelled"); - - const requestRescheduleSentElement = page.locator('[data-testid="request_reschedule_sent"]').nth(1); - await expect(requestRescheduleSentElement).toBeVisible(); - await booking.delete(); - }); - - test("Should do a reschedule from user owner", async ({ page, users, bookings }) => { - const user = await users.create(); - const [eventType] = user.eventTypes; - const booking = await bookings.create(user.id, user.username, eventType.id, { - status: BookingStatus.ACCEPTED, - rescheduled: true, - }); - - await page.goto(`/reschedule/${booking.uid}`); - - await selectFirstAvailableTimeSlotNextMonth(page); - - await expect(page.locator('[name="name"]')).toBeDisabled(); - await expect(page.locator('[name="email"]')).toBeDisabled(); - await confirmReschedule(page); - await expect(page.locator("[data-testid=success-page]")).toBeVisible(); - - const newBooking = await prisma.booking.findFirstOrThrow({ where: { fromReschedule: booking.uid } }); - const rescheduledBooking = await prisma.booking.findFirstOrThrow({ where: { uid: booking.uid } }); - - expect(newBooking).not.toBeNull(); - expect(rescheduledBooking.status).toBe(BookingStatus.CANCELLED); - - await prisma.booking.deleteMany({ - where: { - id: { - in: [newBooking.id, rescheduledBooking.id], - }, - }, - }); - }); - - test("Unpaid rescheduling should go to payment page", async ({ page, users, bookings, payments }) => { - // eslint-disable-next-line playwright/no-skipped-test - test.skip(!IS_STRIPE_ENABLED, "Skipped as Stripe is not installed"); - const user = await users.create(); - await user.apiLogin(); - await user.installStripePersonal({ skip: true }); - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const eventType = user.eventTypes.find((e) => e.slug === "paid")!; - const booking = await bookings.create(user.id, user.username, eventType.id, { - rescheduled: true, - status: BookingStatus.ACCEPTED, - paid: false, - }); - await prisma.eventType.update({ - where: { - id: eventType.id, - }, - data: { - metadata: { - apps: { - stripe: { - price: 20000, - enabled: true, - currency: "usd", - }, - }, - }, - }, - }); - const payment = await payments.create(booking.id); - await page.goto(`/reschedule/${booking.uid}`); - - await selectFirstAvailableTimeSlotNextMonth(page); - - await confirmReschedule(page); - - await page.waitForURL((url) => { - return url.pathname.indexOf("/payment") > -1; - }); - - await expect(page).toHaveURL(/.*payment/); - }); - - test("Paid rescheduling should go to success page", async ({ page, users, bookings, payments }) => { - // eslint-disable-next-line playwright/no-skipped-test - test.skip(!IS_STRIPE_ENABLED, "Skipped as Stripe is not installed"); - - const user = await users.create(); - await user.apiLogin(); - await user.installStripePersonal({ skip: true }); - await users.logout(); - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const eventType = user.eventTypes.find((e) => e.slug === "paid")!; - const booking = await bookings.create(user.id, user.username, eventType.id, { - rescheduled: true, - status: BookingStatus.ACCEPTED, - paid: true, - }); - - const payment = await payments.create(booking.id); - await page.goto(`/reschedule/${booking?.uid}`); - - await selectFirstAvailableTimeSlotNextMonth(page); - - await confirmReschedule(page); - - await expect(page).toHaveURL(/.*booking/); - }); - - test("Opt in event should be PENDING when rescheduled by USER", async ({ page, users, bookings }) => { - const user = await users.create(); - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const eventType = user.eventTypes.find((e) => e.slug === "opt-in")!; - const booking = await bookings.create(user.id, user.username, eventType.id, { - status: BookingStatus.ACCEPTED, - }); - - await page.goto(`/reschedule/${booking.uid}`); - - await selectFirstAvailableTimeSlotNextMonth(page); - - await confirmReschedule(page); - - await expect(page).toHaveURL(/.*booking/); - - const newBooking = await prisma.booking.findFirstOrThrow({ where: { fromReschedule: booking?.uid } }); - expect(newBooking).not.toBeNull(); - expect(newBooking.status).toBe(BookingStatus.PENDING); - }); - - test("Opt in event should be ACCEPTED when rescheduled by OWNER", async ({ page, users, bookings }) => { - const user = await users.create(); - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const eventType = user.eventTypes.find((e) => e.slug === "opt-in")!; - const booking = await bookings.create(user.id, user.username, eventType.id, { - status: BookingStatus.ACCEPTED, - }); - await user.apiLogin(); - - await page.goto(`/reschedule/${booking.uid}`); - - await selectFirstAvailableTimeSlotNextMonth(page); - - await confirmReschedule(page); - - await expect(page).toHaveURL(/.*booking/); - - const newBooking = await prisma.booking.findFirstOrThrow({ where: { fromReschedule: booking?.uid } }); - expect(newBooking).not.toBeNull(); - expect(newBooking.status).toBe(BookingStatus.ACCEPTED); - }); - - test("Attendee should be able to reschedule a booking", async ({ page, users, bookings }) => { - const user = await users.create(); - const eventType = user.eventTypes[0]; - const booking = await bookings.create(user.id, user.username, eventType.id); - - // Go to attendee's reschedule link - await page.goto(`/reschedule/${booking.uid}`); - - await selectFirstAvailableTimeSlotNextMonth(page); - - await confirmReschedule(page); - - await expect(page).toHaveURL(/.*booking/); - - const newBooking = await prisma.booking.findFirstOrThrow({ where: { fromReschedule: booking?.uid } }); - expect(newBooking).not.toBeNull(); - expect(newBooking.status).toBe(BookingStatus.ACCEPTED); - }); - - test("Should be able to book slot that overlaps with original rescheduled booking", async ({ - page, - users, - bookings, - }) => { - const user = await users.create(); - const eventType = user.eventTypes[0]; - - let firstOfNextMonth = dayjs().add(1, "month").startOf("month"); - - // find first available slot of next month (available monday-friday) - // eslint-disable-next-line playwright/no-conditional-in-test - while (firstOfNextMonth.day() < 1 || firstOfNextMonth.day() > 5) { - firstOfNextMonth = firstOfNextMonth.add(1, "day"); - } - - // set startTime to first available slot - const startTime = firstOfNextMonth.set("hour", 9).set("minute", 0).toDate(); - const endTime = firstOfNextMonth.set("hour", 9).set("minute", 30).toDate(); - - const booking = await bookings.create(user.id, user.username, eventType.id, {}, startTime, endTime); - - await page.goto(`/reschedule/${booking.uid}`); - - await selectFirstAvailableTimeSlotNextMonth(page); - - await confirmReschedule(page); - await expect(page).toHaveURL(/.*booking/); - }); - test("Should load Valid Cal video url after rescheduling Opt in events", async ({ - page, - users, - bookings, - browser, - }) => { - // eslint-disable-next-line playwright/no-skipped-test - test.skip(!process.env.DAILY_API_KEY, "DAILY_API_KEY is needed for this test"); - const user = await users.create(); - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const eventType = user.eventTypes.find((e) => e.slug === "opt-in")!; - - const confirmBooking = async (bookingId: number) => { - const [authedContext, authedPage] = await user.apiLoginOnNewBrowser(browser); - await authedPage.goto("/bookings/upcoming"); - await submitAndWaitForResponse(authedPage, "/api/trpc/bookings/confirm?batch=1", { - action: () => authedPage.locator(`[data-bookingid="${bookingId}"][data-testid="confirm"]`).click(), - }); - await authedContext.close(); - }; - - await page.goto(`/${user.username}/${eventType.slug}`); - await selectFirstAvailableTimeSlotNextMonth(page); - await bookTimeSlot(page); - await expect(page.locator("[data-testid=success-page]")).toBeVisible(); - - const pageUrl = new URL(page.url()); - const pathSegments = pageUrl.pathname.split("/"); - const bookingUID = pathSegments[pathSegments.length - 1]; - - const currentBooking = await prisma.booking.findFirstOrThrow({ where: { uid: bookingUID } }); - expect(currentBooking).not.toBeUndefined(); - await confirmBooking(currentBooking.id); - - await page.goto(`/reschedule/${currentBooking.uid}`); - await selectFirstAvailableTimeSlotNextMonth(page); - - await confirmReschedule(page); - await expect(page).toHaveURL(/.*booking/); - - const newBooking = await prisma.booking.findFirstOrThrow({ - where: { fromReschedule: currentBooking.uid }, - }); - expect(newBooking).not.toBeUndefined(); - expect(newBooking.status).toBe(BookingStatus.PENDING); - await confirmBooking(newBooking.id); - - const booking = await prisma.booking.findFirstOrThrow({ where: { id: newBooking.id } }); - expect(booking).not.toBeUndefined(); - expect(booking.status).toBe(BookingStatus.ACCEPTED); - - const locationVideoCallUrl = bookingMetadataSchema.parse(booking.metadata || {})?.videoCallUrl; - // FIXME: This should be consistent or skip the whole test - // eslint-disable-next-line playwright/no-conditional-in-test - if (!locationVideoCallUrl) return; - expect(locationVideoCallUrl).not.toBeUndefined(); - await page.goto(locationVideoCallUrl); - await expect(page.frameLocator("iFrame").locator('text="Continue"')).toBeVisible(); - }); - - test("Should be able to a dynamic group booking", async () => { - // It is tested in dynamic-booking-pages.e2e.ts - }); - - test("Team Event Booking", () => { - // It is tested in teams.e2e.ts - }); - - test("Should redirect to cancelled page when allowReschedulingCancelledBookings is false (default)", async ({ - page, - users, - bookings, - }) => { - const user = await users.create(); - const eventType = user.eventTypes[0]; - - await prisma.eventType.update({ - where: { - id: eventType.id, - }, - data: { - allowReschedulingCancelledBookings: false, - }, - }); - - const booking = await bookings.create(user.id, user.username, eventType.id, { - status: BookingStatus.CANCELLED, - }); - - await page.goto(`/reschedule/${booking.uid}`); - - expect(page.url()).not.toContain("rescheduleUid"); - await expect(page.locator('[data-testid="cancelled-headline"]')).toBeVisible(); - }); - - test("Should allow rescheduling when allowReschedulingCancelledBookings is true", async ({ - page, - users, - bookings, - }) => { - const user = await users.create(); - const eventType = user.eventTypes[0]; - - await prisma.eventType.update({ - where: { - id: eventType.id, - }, - data: { - allowReschedulingCancelledBookings: true, - }, - }); - - const booking = await bookings.create(user.id, user.username, eventType.id, { - status: BookingStatus.CANCELLED, - }); - - await page.goto(`/reschedule/${booking.uid}`); - - await selectFirstAvailableTimeSlotNextMonth(page); - await bookTimeSlot(page); - - await expect(page.locator("[data-testid=success-page]")).toBeVisible(); - }); - - test.describe("Organization", () => { - test("Booking should be rescheduleable for a user that was moved to an organization through org domain", async ({ - users, - bookings, - orgs, - page, - }) => { - const org = await orgs.create({ - name: "TestOrg", - }); - const orgMember = await users.create({ - username: "username-outside-org", - organizationId: org.id, - profileUsername: "username-inside-org", - roleInOrganization: MembershipRole.MEMBER, - }); - const profileUsername = (await orgMember.getFirstProfile()).username; - const eventType = orgMember.eventTypes[0]; - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const orgSlug = org.slug!; - const booking = await bookings.create(orgMember.id, orgMember.username, eventType.id); - - return await doOnOrgDomain( - { - orgSlug: orgSlug, - page, - }, - async ({ page, goToUrlWithErrorHandling }) => { - const result = await goToUrlWithErrorHandling(`/reschedule/${booking.uid}`); - expectUrlToBeABookingPageOnOrgForUsername({ - url: result.url, - orgSlug, - username: profileUsername, - }); - - const rescheduleUrlToBeOpenedInOrgContext = getNonOrgUrlFromOrgUrl(result.url, orgSlug); - await page.goto(rescheduleUrlToBeOpenedInOrgContext); - await expectSuccessfulReschedule(page, orgSlug); - return { url: result.url }; - } - ); - }); - - test("Booking should be rescheduleable for a user that was moved to an organization through non-org domain", async ({ - users, - bookings, - orgs, - page, - }) => { - const org = await orgs.create({ - name: "TestOrg", - }); - const orgMember = await users.create({ - username: "username-outside-org", - organizationId: org.id, - profileUsername: "username-inside-org", - roleInOrganization: MembershipRole.MEMBER, - }); - const eventType = orgMember.eventTypes[0]; - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const orgSlug = org.slug!; - const booking = await bookings.create(orgMember.id, orgMember.username, eventType.id); - - const result = await goToUrlWithErrorHandling({ url: `/reschedule/${booking.uid}`, page }); - - await doOnOrgDomain( - { - orgSlug: orgSlug, - page, - }, - async ({ page }) => { - await page.goto(getNonOrgUrlFromOrgUrl(result.url, orgSlug)); - await expectSuccessfulReschedule(page, orgSlug); - } - ); - }); - - const getNonOrgUrlFromOrgUrl = (url: string, orgSlug: string) => url.replace(orgSlug, "app"); - - async function expectSuccessfulReschedule(page: Page, orgSlug: string) { - await selectFirstAvailableTimeSlotNextMonth(page); - const { protocol, host } = new URL(page.url()); - // Needed since we we're expecting a non-org URL, causing timeouts. - const url = getNonOrgUrlFromOrgUrl(`${protocol}//${host}/api/book/event`, orgSlug); - await confirmReschedule(page, url); - await expect(page.locator("[data-testid=success-page]")).toBeVisible(); - } - }); -}); - -function expectUrlToBeABookingPageOnOrgForUsername({ - url, - orgSlug, - username, -}: { - url: string; - orgSlug: string; - username: string; -}) { - expect(url).toContain(`://${orgSlug}.`); - const urlObject = new URL(url); - const usernameInUrl = urlObject.pathname.split("/")[1]; - expect(usernameInUrl).toEqual(username); -} diff --git a/scripts/e2e-timing-analysis.js b/scripts/e2e-timing-analysis.js new file mode 100644 index 00000000000000..459b330ede9e24 --- /dev/null +++ b/scripts/e2e-timing-analysis.js @@ -0,0 +1,244 @@ +#!/usr/bin/env node + +const fs = require('fs'); +const path = require('path'); + +/** + * Analyze E2E test timing data and generate optimization recommendations + */ +class E2ETimingAnalyzer { + constructor(timingDataPath = './timing-reports') { + this.timingDataPath = timingDataPath; + this.testFiles = []; + this.shardData = []; + } + + async loadTimingData() { + try { + const files = fs.readdirSync(this.timingDataPath); + + for (const file of files) { + if (file.endsWith('-timing.json')) { + const filePath = path.join(this.timingDataPath, file); + const data = JSON.parse(fs.readFileSync(filePath, 'utf8')); + this.shardData.push(data); + } + } + + console.log(`Loaded timing data for ${this.shardData.length} shards`); + } catch (error) { + console.error('Error loading timing data:', error.message); + } + } + + analyzeTestFiles() { + const playwrightDir = './apps/web/playwright'; + + if (!fs.existsSync(playwrightDir)) { + console.error('Playwright directory not found'); + return; + } + + const scanDirectory = (dir) => { + const items = fs.readdirSync(dir); + + for (const item of items) { + const fullPath = path.join(dir, item); + const stat = fs.statSync(fullPath); + + if (stat.isDirectory()) { + scanDirectory(fullPath); + } else if (item.endsWith('.e2e.ts')) { + const content = fs.readFileSync(fullPath, 'utf8'); + const lines = content.split('\n').length; + const testCount = (content.match(/test\(/g) || []).length; + const skipCount = (content.match(/test\.skip\(/g) || []).length; + const fixmeCount = (content.match(/test\.fixme\(/g) || []).length; + + this.testFiles.push({ + path: fullPath.replace('./apps/web/playwright/', ''), + lines, + testCount, + skipCount, + fixmeCount, + hasParallel: content.includes('test.describe.configure({ mode: "parallel" })'), + usesBookingFlow: content.includes('bookTimeSlot') || content.includes('selectFirstAvailableTimeSlotNextMonth'), + category: this.categorizeTest(fullPath, content) + }); + } + } + }; + + scanDirectory(playwrightDir); + console.log(`Analyzed ${this.testFiles.length} E2E test files`); + } + + categorizeTest(filePath, content) { + if (filePath.includes('booking')) return 'booking'; + if (filePath.includes('organization')) return 'organization'; + if (filePath.includes('login') || filePath.includes('auth')) return 'auth'; + if (filePath.includes('payment')) return 'payment'; + if (filePath.includes('integration')) return 'integration'; + if (filePath.includes('webhook')) return 'webhook'; + return 'other'; + } + + generateOptimizationReport() { + const report = { + timestamp: new Date().toISOString(), + summary: { + totalFiles: this.testFiles.length, + totalTests: this.testFiles.reduce((sum, file) => sum + file.testCount, 0), + skippedTests: this.testFiles.reduce((sum, file) => sum + file.skipCount, 0), + fixmeTests: this.testFiles.reduce((sum, file) => sum + file.fixmeCount, 0), + totalLines: this.testFiles.reduce((sum, file) => sum + file.lines, 0) + }, + shardTiming: this.shardData, + consolidationOpportunities: this.findConsolidationOpportunities(), + recommendations: this.generateRecommendations() + }; + + return report; + } + + findConsolidationOpportunities() { + const opportunities = []; + const categories = {}; + + this.testFiles.forEach(file => { + if (!categories[file.category]) { + categories[file.category] = []; + } + categories[file.category].push(file); + }); + + Object.entries(categories).forEach(([category, files]) => { + if (files.length > 1) { + const totalLines = files.reduce((sum, file) => sum + file.lines, 0); + const totalTests = files.reduce((sum, file) => sum + file.testCount, 0); + const bookingFlowFiles = files.filter(file => file.usesBookingFlow); + + if (bookingFlowFiles.length > 1 || totalLines > 1000) { + opportunities.push({ + category, + files: files.map(f => f.path), + totalLines, + totalTests, + estimatedReduction: Math.floor(totalLines * 0.3), // Estimate 30% reduction + priority: this.calculatePriority(category, files) + }); + } + } + }); + + return opportunities.sort((a, b) => b.priority - a.priority); + } + + calculatePriority(category, files) { + let priority = 0; + + priority += files.length * 10; + + if (category === 'booking') priority += 50; + + const problematicTests = files.reduce((sum, file) => sum + file.skipCount + file.fixmeCount, 0); + priority += problematicTests * 5; + + const avgLines = files.reduce((sum, file) => sum + file.lines, 0) / files.length; + priority += Math.floor(avgLines / 100); + + return priority; + } + + generateRecommendations() { + const recommendations = []; + + const problematicFiles = this.testFiles.filter(file => file.skipCount > 0 || file.fixmeCount > 0); + if (problematicFiles.length > 0) { + recommendations.push({ + type: 'cleanup', + priority: 'high', + description: 'Remove or fix skipped/fixme tests', + files: problematicFiles.map(f => f.path), + estimatedTimeSaving: '10-15%' + }); + } + + const bookingFiles = this.testFiles.filter(file => file.category === 'booking'); + if (bookingFiles.length > 2) { + recommendations.push({ + type: 'consolidation', + priority: 'high', + description: 'Merge booking-related test files', + files: bookingFiles.map(f => f.path), + estimatedTimeSaving: '25-30%' + }); + } + + if (this.shardData.length > 0) { + const avgDuration = this.shardData.reduce((sum, shard) => sum + shard.duration, 0) / this.shardData.length; + const maxDuration = Math.max(...this.shardData.map(s => s.duration)); + const minDuration = Math.min(...this.shardData.map(s => s.duration)); + + if (maxDuration - minDuration > avgDuration * 0.3) { + recommendations.push({ + type: 'sharding', + priority: 'medium', + description: 'Rebalance test shards for more even distribution', + details: `Max: ${maxDuration}s, Min: ${minDuration}s, Avg: ${Math.round(avgDuration)}s`, + estimatedTimeSaving: '10-15%' + }); + } + } + + return recommendations; + } + + async run() { + console.log('🔍 Starting E2E timing analysis...\n'); + + await this.loadTimingData(); + this.analyzeTestFiles(); + + const report = this.generateOptimizationReport(); + + console.log('📊 Analysis Results:'); + console.log(`- Total test files: ${report.summary.totalFiles}`); + console.log(`- Total tests: ${report.summary.totalTests}`); + console.log(`- Skipped tests: ${report.summary.skippedTests}`); + console.log(`- Fixme tests: ${report.summary.fixmeTests}`); + console.log(`- Total lines of code: ${report.summary.totalLines}\n`); + + if (report.shardTiming.length > 0) { + console.log('⏱️ Shard Timing:'); + report.shardTiming.forEach(shard => { + console.log(`- Shard ${shard.shard}: ${shard.duration}s (${Math.round(shard.duration/60)}m)`); + }); + console.log(); + } + + console.log('🎯 Top Consolidation Opportunities:'); + report.consolidationOpportunities.slice(0, 5).forEach((opp, index) => { + console.log(`${index + 1}. ${opp.category} (${opp.files.length} files, ${opp.totalLines} lines)`); + console.log(` Estimated reduction: ${opp.estimatedReduction} lines`); + console.log(` Priority: ${opp.priority}`); + }); + console.log(); + + console.log('💡 Recommendations:'); + report.recommendations.forEach((rec, index) => { + console.log(`${index + 1}. [${rec.priority.toUpperCase()}] ${rec.description}`); + console.log(` Estimated time saving: ${rec.estimatedTimeSaving}`); + }); + + fs.writeFileSync('e2e-optimization-report.json', JSON.stringify(report, null, 2)); + console.log('\n📄 Detailed report saved to: e2e-optimization-report.json'); + } +} + +if (require.main === module) { + const analyzer = new E2ETimingAnalyzer(); + analyzer.run().catch(console.error); +} + +module.exports = E2ETimingAnalyzer;