diff --git a/.github/actions/cache-db/action.yml b/.github/actions/cache-db/action.yml index 32b9b0c083bda3..84308b518863ab 100644 --- a/.github/actions/cache-db/action.yml +++ b/.github/actions/cache-db/action.yml @@ -7,6 +7,14 @@ inputs: path: required: false default: "backups/backup.sql" + SEED_PLATFORM_OAUTH_CLIENT_ID: + required: false + SEED_PLATFORM_OAUTH_CLIENT_SECRET: + required: false + SEED_PLATFORM_OAUTH_CLIENT_ID_E2E: + required: false + SEED_PLATFORM_OAUTH_CLIENT_SECRET_E2E: + required: false runs: using: "composite" steps: @@ -29,6 +37,11 @@ runs: - run: yarn db-seed if: steps.cache-db.outputs.cache-hit != 'true' shell: bash + env: + SEED_PLATFORM_OAUTH_CLIENT_ID: ${{ inputs.SEED_PLATFORM_OAUTH_CLIENT_ID }} + SEED_PLATFORM_OAUTH_CLIENT_SECRET: ${{ inputs.SEED_PLATFORM_OAUTH_CLIENT_SECRET }} + SEED_PLATFORM_OAUTH_CLIENT_ID_E2E: ${{ inputs.SEED_PLATFORM_OAUTH_CLIENT_ID_E2E }} + SEED_PLATFORM_OAUTH_CLIENT_SECRET_E2E: ${{ inputs.SEED_PLATFORM_OAUTH_CLIENT_SECRET_E2E }} - name: Postgres Dump Backup if: steps.cache-db.outputs.cache-hit != 'true' uses: tj-actions/pg-dump@v2.3 diff --git a/.github/workflows/e2e-api-v2.yml b/.github/workflows/e2e-api-v2.yml index 254d6c90858351..5b22c617ac1680 100644 --- a/.github/workflows/e2e-api-v2.yml +++ b/.github/workflows/e2e-api-v2.yml @@ -27,6 +27,11 @@ env: VAPID_PRIVATE_KEY: ${{ secrets.VAPID_PRIVATE_KEY }} JWT_SECRET: ${{ secrets.CI_JWT_SECRET }} NODE_ENV: ${{ vars.CI_NODE_ENV }} + ## seed script env variables + SEED_PLATFORM_OAUTH_CLIENT_ID: ${{ secrets.SEED_PLATFORM_OAUTH_CLIENT_ID }} + SEED_PLATFORM_OAUTH_CLIENT_SECRET: ${{ secrets.SEED_PLATFORM_OAUTH_CLIENT_SECRET }} + SEED_PLATFORM_OAUTH_CLIENT_ID_E2E: ${{ secrets.SEED_PLATFORM_OAUTH_CLIENT_ID_E2E }} + SEED_PLATFORM_OAUTH_CLIENT_SECRET_E2E: ${{ secrets.SEED_PLATFORM_OAUTH_CLIENT_SECRET_E2E }} jobs: e2e: timeout-minutes: 20 @@ -72,6 +77,11 @@ jobs: - uses: ./.github/actions/dangerous-git-checkout - uses: ./.github/actions/yarn-install - uses: ./.github/actions/cache-db + with: + SEED_PLATFORM_OAUTH_CLIENT_ID: ${{ secrets.SEED_PLATFORM_OAUTH_CLIENT_ID }} + SEED_PLATFORM_OAUTH_CLIENT_SECRET: ${{ secrets.SEED_PLATFORM_OAUTH_CLIENT_SECRET }} + SEED_PLATFORM_OAUTH_CLIENT_ID_E2E: ${{ secrets.SEED_PLATFORM_OAUTH_CLIENT_ID_E2E }} + SEED_PLATFORM_OAUTH_CLIENT_SECRET_E2E: ${{ secrets.SEED_PLATFORM_OAUTH_CLIENT_SECRET_E2E }} - name: Generate Swagger working-directory: apps/api/v2 diff --git a/.github/workflows/e2e-atoms.yml b/.github/workflows/e2e-atoms.yml index f6b347dbaa395f..f5a6a8b353418f 100644 --- a/.github/workflows/e2e-atoms.yml +++ b/.github/workflows/e2e-atoms.yml @@ -1,28 +1,105 @@ name: E2E Atoms on: - workflow_call: + pull_request: + branches: + - main + paths: + - "packages/platform/atoms/**" + - "packages/platform/examples/base/**" + - "apps/api/v2/**" + - ".github/workflows/e2e-atoms.yml" + permissions: actions: write contents: read env: - NODE_OPTIONS: --max-old-space-size=8096 - ATOMS_E2E_OAUTH_CLIENT_ID: ${{ secrets.ATOMS_E2E_OAUTH_CLIENT_ID }} - ATOMS_E2E_OAUTH_CLIENT_SECRET: ${{ secrets.ATOMS_E2E_OAUTH_CLIENT_SECRET }} - ATOMS_E2E_API_URL: ${{ secrets.ATOMS_E2E_API_URL }} - ATOMS_E2E_ORG_ID: ${{ secrets.ATOMS_E2E_ORG_ID }} - ATOMS_E2E_OAUTH_CLIENT_ID_BOOKER_EMBED: ${{ secrets.ATOMS_E2E_OAUTH_CLIENT_ID_BOOKER_EMBED }} - ATOMS_E2E_APPLE_ID: ${{ secrets.ATOMS_E2E_APPLE_ID }} - ATOMS_E2E_APPLE_CONNECT_APP_SPECIFIC_PASSCODE: ${{ secrets.ATOMS_E2E_APPLE_CONNECT_APP_SPECIFIC_PASSCODE }} + ## api v2 env + ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }} + API_KEY_PREFIX: ${{ secrets.CI_API_KEY_PREFIX }} + API_PORT: ${{ vars.CI_API_V2_PORT }} + CALCOM_LICENSE_KEY: ${{ secrets.CI_CALCOM_LICENSE_KEY }} + DAILY_API_KEY: ${{ secrets.CI_DAILY_API_KEY }} + DATABASE_READ_URL: ${{ secrets.CI_DATABASE_URL }} + DATABASE_WRITE_URL: ${{ secrets.CI_DATABASE_URL }} + GOOGLE_API_CREDENTIALS: ${{ secrets.CI_GOOGLE_API_CREDENTIALS }} + IS_E2E: true + CI: true + NEXTAUTH_SECRET: ${{ secrets.CI_NEXTAUTH_SECRET }} + NEXTAUTH_URL: ${{ secrets.CI_NEXTAUTH_URL }} + REDIS_URL: "redis://localhost:6379" + LINGO_DOT_DEV_API_KEY: ${{ secrets.CI_LINGO_DOT_DEV_API_KEY }} + STRIPE_PRIVATE_KEY: ${{ secrets.CI_STRIPE_PRIVATE_KEY }} + STRIPE_API_KEY: ${{ secrets.CI_STRIPE_PRIVATE_KEY }} + STRIPE_CLIENT_ID: ${{ secrets.CI_STRIPE_CLIENT_ID }} + STRIPE_WEBHOOK_SECRET: ${{ secrets.CI_STRIPE_WEBHOOK_SECRET }} + SLOTS_CACHE_TTL: ${{ secrets.CI_SLOTS_CACHE_TTL }} + NEXT_PUBLIC_VAPID_PUBLIC_KEY: ${{ secrets.NEXT_PUBLIC_VAPID_PUBLIC_KEY }} + VAPID_PRIVATE_KEY: ${{ secrets.VAPID_PRIVATE_KEY }} + JWT_SECRET: ${{ secrets.CI_JWT_SECRET }} + NODE_ENV: ${{ vars.CI_NODE_ENV }} + + ## atoms e2e examples app env + ATOMS_E2E_OAUTH_CLIENT_ID_2025_12: ${{ secrets.ATOMS_E2E_OAUTH_CLIENT_ID_2025_12 }} + ATOMS_E2E_OAUTH_CLIENT_SECRET_2025_12: ${{ secrets.ATOMS_E2E_OAUTH_CLIENT_SECRET_2025_12 }} + ATOMS_E2E_API_URL_2025_12: ${{ secrets.ATOMS_E2E_API_URL_2025_12 }} + ATOMS_E2E_ORG_ID_2025_12: ${{ secrets.ATOMS_E2E_ORG_ID_2025_12 }} + ATOMS_E2E_OAUTH_CLIENT_ID_BOOKER_EMBED_2025_12: ${{ secrets.ATOMS_E2E_OAUTH_CLIENT_ID_BOOKER_EMBED_2025_12 }} + ATOMS_E2E_APPLE_ID_2025_12: ${{ secrets.ATOMS_E2E_APPLE_ID_2025_12 }} + ATOMS_E2E_APPLE_CONNECT_APP_SPECIFIC_PASSCODE_2025_12: ${{ secrets.ATOMS_E2E_APPLE_CONNECT_APP_SPECIFIC_PASSCODE_2025_12 }} + + ## atoms e2e examples app env aliases for playwright + NEXT_PUBLIC_X_CAL_ID: ${{ secrets.ATOMS_E2E_OAUTH_CLIENT_ID_2025_12 }} + X_CAL_SECRET_KEY: ${{ secrets.ATOMS_E2E_OAUTH_CLIENT_SECRET_2025_12 }} + NEXT_PUBLIC_CALCOM_API_URL: ${{ secrets.ATOMS_E2E_API_URL_2025_12 }} + + ## env variables needed for both api v2 and examples app DATABASE_DIRECT_URL: ${{ secrets.CI_DATABASE_URL }} DATABASE_URL: ${{ secrets.CI_DATABASE_URL }} + NODE_OPTIONS: --max-old-space-size=29000 + + ## seed script env variables + SEED_PLATFORM_OAUTH_CLIENT_ID: ${{ secrets.SEED_PLATFORM_OAUTH_CLIENT_ID }} + SEED_PLATFORM_OAUTH_CLIENT_SECRET: ${{ secrets.SEED_PLATFORM_OAUTH_CLIENT_SECRET }} + SEED_PLATFORM_OAUTH_CLIENT_ID_E2E: ${{ secrets.SEED_PLATFORM_OAUTH_CLIENT_ID_E2E }} + SEED_PLATFORM_OAUTH_CLIENT_SECRET_E2E: ${{ secrets.SEED_PLATFORM_OAUTH_CLIENT_SECRET_E2E }} jobs: e2e-atoms: + if: ${{ github.event.pull_request.head.repo.full_name == github.repository }} timeout-minutes: 15 name: E2E Atoms - runs-on: buildjet-4vcpu-ubuntu-2204 + runs-on: buildjet-16vcpu-ubuntu-2204 + 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 + redis: + image: redis:latest + credentials: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + ports: + - 6379:6379 + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 steps: - uses: docker/login-action@v3 with: @@ -31,10 +108,42 @@ jobs: - uses: actions/checkout@v4 - uses: ./.github/actions/dangerous-git-checkout - uses: ./.github/actions/yarn-install + - uses: ./.github/actions/cache-db + with: + SEED_PLATFORM_OAUTH_CLIENT_ID: ${{ secrets.SEED_PLATFORM_OAUTH_CLIENT_ID }} + SEED_PLATFORM_OAUTH_CLIENT_SECRET: ${{ secrets.SEED_PLATFORM_OAUTH_CLIENT_SECRET }} + SEED_PLATFORM_OAUTH_CLIENT_ID_E2E: ${{ secrets.SEED_PLATFORM_OAUTH_CLIENT_ID_E2E }} + SEED_PLATFORM_OAUTH_CLIENT_SECRET_E2E: ${{ secrets.SEED_PLATFORM_OAUTH_CLIENT_SECRET_E2E }} - uses: ./.github/actions/yarn-playwright-install + - name: Start API v2 + working-directory: apps/api/v2 + run: | + yarn dev:no-docker & + API_PID=$! + echo "API_PID=$API_PID" >> $GITHUB_ENV + + # Wait for API to be ready + echo "Waiting for API v2 to be ready on port ${{ vars.CI_API_V2_PORT }}..." + timeout 300 bash -c 'until curl -f http://localhost:${{ vars.CI_API_V2_PORT }}/health > /dev/null 2>&1; do sleep 2; done' + echo "API v2 is ready!" - name: Run E2E Atoms Tests working-directory: packages/platform/examples/base run: yarn test:e2e + - name: Upload Atoms dist (entire folder) + uses: actions/upload-artifact@v4 + if: always() + with: + name: atoms-dist-${{ github.sha }} + path: packages/platform/atoms/dist + retention-days: 7 + if-no-files-found: warn + + - name: Stop API v2 + if: always() + run: | + if [ ! -z "$API_PID" ]; then + kill $API_PID || true + fi - name: Upload Test Results uses: actions/upload-artifact@v4 if: always() diff --git a/apps/api/v2/src/modules/oauth-clients/controllers/oauth-client-users/oauth-client-users.controller.e2e-spec.ts b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-client-users/oauth-client-users.controller.e2e-spec.ts index 85c65d2b3fa163..f2bfa59ce2b35a 100644 --- a/apps/api/v2/src/modules/oauth-clients/controllers/oauth-client-users/oauth-client-users.controller.e2e-spec.ts +++ b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-client-users/oauth-client-users.controller.e2e-spec.ts @@ -824,6 +824,11 @@ describe("OAuth Client Users Endpoints", () => { } catch (e) { console.log(e); } + try { + await userRepositoryFixture.delete(postResponseDataTwo.user.id); + } catch (e) { + // User might have been deleted by the test + } try { await userRepositoryFixture.delete(platformAdmin.id); } catch (e) { diff --git a/packages/features/shell/SideBar.tsx b/packages/features/shell/SideBar.tsx index 586e9405f871dd..f9bf1fd9881eca 100644 --- a/packages/features/shell/SideBar.tsx +++ b/packages/features/shell/SideBar.tsx @@ -43,13 +43,23 @@ export type SideBarProps = { export function SideBarContainer({ bannersHeight, isPlatformUser = false }: SideBarContainerProps) { const { status, data } = useSession(); - // Make sure that Sidebar is rendered optimistically so that a refresh of pages when logged in have SideBar from the beginning. - // This improves the experience of refresh on app store pages(when logged in) which are SSG. - // Though when logged out, app store pages would temporarily show SideBar until session status is confirmed. + // Render nothing once we know the user isn't authenticated. if (status !== "loading" && status !== "authenticated") return null; return ; } +// Build a safe absolute public page URL, or empty string if we can't. +const buildPublicPageUrl = (user?: UserAuth | null): string => { + const base = + getBookerBaseUrlSync(user?.org?.slug ?? null) || + process.env.NEXT_PUBLIC_WEBAPP_URL || + process.env.NEXTAUTH_URL || + ""; + const username = user?.orgAwareUsername; + if (!base || !username) return ""; + return `${String(base).replace(/\/+$/, "")}/${username}`; +}; + export function SideBar({ bannersHeight, user }: SideBarProps) { const session = useSession(); const { t, isLocaleReady } = useLocale(); @@ -57,7 +67,7 @@ export function SideBar({ bannersHeight, user }: SideBarProps) { const isPlatformPages = pathname?.startsWith("/settings/platform"); const isAdmin = session.data?.user.role === UserPermissionRole.ADMIN; - const publicPageUrl = `${getBookerBaseUrlSync(user?.org?.slug ?? null)}/${user?.orgAwareUsername}`; + const publicPageUrl = buildPublicPageUrl(user); const bottomNavItems = useBottomNavItems({ publicPageUrl, @@ -112,19 +122,13 @@ export function SideBar({ bannersHeight, user }: SideBarProps) { color="minimal" onClick={() => window.history.back()} className="todesktop:block hover:text-emphasis text-subtle group hidden text-sm font-medium"> - + {!!user?.org && (
@@ -134,10 +138,12 @@ export function SideBar({ bannersHeight, user }: SideBarProps) {
+ {/* logo icon for tablet */} + @@ -146,21 +152,13 @@ export function SideBar({ bannersHeight, user }: SideBarProps) {
- {bottomNavItems.map((item, index) => ( - - + + {bottomNavItems.map((item, index) => { + const isActionOnly = !item.href; + const isInternal = !!item.href && item.href.startsWith("/"); + + const content = ( + <> {!!item.icon && ( )} - - - ))} + + ); + + const commonClassName = classNames( + "text-left", + "[&[aria-current='page']]:bg-emphasis text-default justify-right group flex items-center rounded-md px-2 py-1.5 text-sm font-medium transition", + "[&[aria-current='page']]:text-emphasis mt-0.5 w-full text-sm", + isLocaleReady ? "hover:bg-emphasis hover:text-emphasis" : "", + index === 0 && "mt-3" + ); + + return ( + + {isActionOnly ? ( + + ) : isInternal ? ( + + {content} + + ) : ( + + {content} + + )} + + ); + })} + {!IS_VISUAL_REGRESSION_TESTING && } )} diff --git a/packages/platform/examples/base/.env.e2e.example b/packages/platform/examples/base/.env.e2e.example new file mode 100644 index 00000000000000..a427c43e08e93f --- /dev/null +++ b/packages/platform/examples/base/.env.e2e.example @@ -0,0 +1,10 @@ +NEXT_PUBLIC_X_CAL_ID= +X_CAL_SECRET_KEY= +NEXT_PUBLIC_CALCOM_API_URL= +VITE_BOOKER_EMBED_OAUTH_CLIENT_ID= +VITE_BOOKER_EMBED_API_URL= +ORGANIZATION_ID= +ATOMS_E2E_APPLE_ID= +ATOMS_E2E_APPLE_CONNECT_APP_SPECIFIC_PASSCODE= +NEXT_PUBLIC_IS_E2E="1" +NODE_ENV="test" diff --git a/packages/platform/examples/base/.gitignore b/packages/platform/examples/base/.gitignore index 9184ed87223ae8..b8065cc21e666c 100644 --- a/packages/platform/examples/base/.gitignore +++ b/packages/platform/examples/base/.gitignore @@ -27,6 +27,7 @@ yarn-error.log* # local env files .env*.local +!.env.e2e.example # vercel .vercel @@ -36,6 +37,7 @@ yarn-error.log* next-env.d.ts .yarn dev.db +test.db # playwright test-results diff --git a/packages/platform/examples/base/global-teardown.ts b/packages/platform/examples/base/global-teardown.ts new file mode 100644 index 00000000000000..53be4c31738d5b --- /dev/null +++ b/packages/platform/examples/base/global-teardown.ts @@ -0,0 +1,115 @@ +import dotenv from "dotenv"; +import fs from "fs"; +import path from "path"; + +if (!process.env.CI) { + dotenv.config({ path: path.resolve(__dirname, ".env.e2e") }); +} +async function globalTeardown() { + console.log("Cleaning up managed users..."); + try { + const oauthClientId = process.env.NEXT_PUBLIC_X_CAL_ID; + const secretKey = process.env.X_CAL_SECRET_KEY; + const apiUrl = process.env.NEXT_PUBLIC_CALCOM_API_URL; + if (!oauthClientId || !secretKey || !apiUrl) { + console.log("Missing environment variables, skipping managed user cleanup"); + return; + } + const getManagedUsersResponse = await fetch(`${apiUrl}/oauth-clients/${oauthClientId}/users`, { + method: "GET", + headers: { + "Content-Type": "application/json", + "x-cal-secret-key": secretKey, + "x-cal-client-id": oauthClientId, + }, + }); + if (getManagedUsersResponse.ok) { + const managedUsersData = await getManagedUsersResponse.json(); + const users = managedUsersData.data || []; + for (const user of users) { + try { + const deleteResponse = await fetch(`${apiUrl}/oauth-clients/${oauthClientId}/users/${user.id}`, { + method: "DELETE", + headers: { + "Content-Type": "application/json", + "x-cal-secret-key": secretKey, + "x-cal-client-id": oauthClientId, + }, + }); + if (deleteResponse.ok) { + console.log(`Deleted managed user: with id = ${user.id}`); + } else { + console.error(`Failed to delete user with id = ${user.id}:`, await deleteResponse.text()); + } + } catch (error) { + console.error(`Error deleting user with id = ${user.id}:`, error); + } + } + } else { + console.error("Failed to fetch managed users:", await getManagedUsersResponse.text()); + } + const getOAuthClientResponse = await fetch(`${apiUrl}/oauth-clients/${oauthClientId}`, { + method: "GET", + headers: { + "Content-Type": "application/json", + "x-cal-secret-key": secretKey, + "x-cal-client-id": oauthClientId, + }, + }); + if (getOAuthClientResponse.ok) { + const oauthClientData = await getOAuthClientResponse.json(); + const organizationId = oauthClientData.data?.organizationId; + if (organizationId) { + console.log(`Found organizationId: ${organizationId}`); + const getTeamsResponse = await fetch(`${apiUrl}/organizations/${organizationId}/teams`, { + method: "GET", + headers: { + "Content-Type": "application/json", + "x-cal-secret-key": secretKey, + "x-cal-client-id": oauthClientId, + }, + }); + if (getTeamsResponse.ok) { + const teamsData = await getTeamsResponse.json(); + const teams = teamsData.data || []; + for (const team of teams) { + try { + const deleteTeamResponse = await fetch( + `${apiUrl}/organizations/${organizationId}/teams/${team.id}`, + { + method: "DELETE", + headers: { + "Content-Type": "application/json", + "x-cal-secret-key": secretKey, + "x-cal-client-id": oauthClientId, + }, + } + ); + if (deleteTeamResponse.ok) { + console.log(`Deleted team: ${team.name}`); + } else { + console.error(`Failed to delete team ${team.name}:`, await deleteTeamResponse.text()); + } + } catch (error) { + console.error(`Error deleting team ${team.name}:`, error); + } + } + } else { + console.error("Failed to fetch teams:", await getTeamsResponse.text()); + } + } else { + console.log("No organizationId found in OAuth client"); + } + } else { + console.error("Failed to fetch OAuth client:", await getOAuthClientResponse.text()); + } + } catch (error) { + console.error("Failed to clean up:", error); + } finally { + console.log("Cleaning up test database..."); + const testDbPath = path.resolve(__dirname, "prisma", "test.db"); + fs.rmSync(testDbPath, { force: true }); + console.log("Test database cleaned up successfully"); + } +} +export default globalTeardown; diff --git a/packages/platform/examples/base/package.json b/packages/platform/examples/base/package.json index 4c4c21541b669b..13c49a3656d4be 100644 --- a/packages/platform/examples/base/package.json +++ b/packages/platform/examples/base/package.json @@ -9,7 +9,10 @@ "start": "next start", "lint": "eslint .", "test:e2e": "playwright test", - "test:e2e:ui": "playwright test --ui" + "test:e2e:ui": "playwright test --ui", + "db:push:test": "prisma db push --schema=prisma/schema.test.prisma", + "db:generate:test": "prisma generate --schema=prisma/schema.test.prisma", + "db:reset:test": "rm -f prisma/test.db && yarn db:push:test" }, "dependencies": { "@calcom/atoms": "workspace:*", diff --git a/packages/platform/examples/base/playwright.config.ts b/packages/platform/examples/base/playwright.config.ts index d77d9e71361d81..8c399d646f2465 100644 --- a/packages/platform/examples/base/playwright.config.ts +++ b/packages/platform/examples/base/playwright.config.ts @@ -2,9 +2,9 @@ import { defineConfig, devices } from "@playwright/test"; import dotenv from "dotenv"; import path from "path"; -const envPath = process.env.CI ? path.resolve(__dirname, ".env") : path.resolve(__dirname, ".env.local"); - -dotenv.config({ path: envPath }); +if (!process.env.CI) { + dotenv.config({ path: path.resolve(__dirname, ".env.e2e") }); +} const DEFAULT_EXPECT_TIMEOUT = process.env.CI ? 30000 : 120000; const DEFAULT_TEST_TIMEOUT = process.env.CI ? 60000 : 240000; @@ -14,9 +14,10 @@ const headless = !!process.env.CI || !!process.env.PLAYWRIGHT_HEADLESS; export default defineConfig({ forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 0, - workers: process.env.CI ? 1 : undefined, + workers: 1, timeout: DEFAULT_TEST_TIMEOUT, fullyParallel: false, + globalTeardown: require.resolve("./global-teardown"), reporter: [ ["list"], ["html", { outputFolder: "./test-results/reports/playwright-html-report", open: "never" }], @@ -44,10 +45,28 @@ export default defineConfig({ ], webServer: { command: process.env.CI - ? `yarn workspace @calcom/atoms dev-on && yarn workspace @calcom/atoms build && rm -f prisma/dev.db && yarn prisma db push && NEXT_PUBLIC_IS_E2E=1 NODE_ENV=test NEXT_PUBLIC_X_CAL_ID="${process.env.ATOMS_E2E_OAUTH_CLIENT_ID}" X_CAL_SECRET_KEY="${process.env.ATOMS_E2E_OAUTH_CLIENT_SECRET}" NEXT_PUBLIC_CALCOM_API_URL="${process.env.ATOMS_E2E_API_URL}" VITE_BOOKER_EMBED_OAUTH_CLIENT_ID="${process.env.ATOMS_E2E_OAUTH_CLIENT_ID_BOOKER_EMBED}" VITE_BOOKER_EMBED_API_URL="${process.env.ATOMS_E2E_API_URL}" ORGANIZATION_ID=${process.env.ATOMS_E2E_ORG_ID} yarn dev:e2e` - : `rm -f prisma/dev.db && yarn prisma db push && yarn dev:e2e`, + ? `yarn workspace @calcom/atoms dev-on && yarn workspace @calcom/atoms build && yarn db:generate:test && yarn db:reset:test && yarn dev:e2e` + : `yarn db:generate:test && yarn db:reset:test && yarn dev:e2e`, url: "http://localhost:4322", timeout: 600_000, reuseExistingServer: !process.env.CI, + ...(process.env.CI + ? { + env: { + NEXT_PUBLIC_IS_E2E: "1", + NODE_ENV: "test", + NEXT_PUBLIC_X_CAL_ID: process.env.ATOMS_E2E_OAUTH_CLIENT_ID_2025_12 ?? "", + X_CAL_SECRET_KEY: process.env.ATOMS_E2E_OAUTH_CLIENT_SECRET_2025_12 ?? "", + NEXT_PUBLIC_CALCOM_API_URL: process.env.ATOMS_E2E_API_URL_2025_12 ?? "", + VITE_BOOKER_EMBED_OAUTH_CLIENT_ID: + process.env.ATOMS_E2E_OAUTH_CLIENT_ID_BOOKER_EMBED_2025_12 ?? "", + VITE_BOOKER_EMBED_API_URL: process.env.ATOMS_E2E_API_URL_2025_12 ?? "", + ORGANIZATION_ID: String(process.env.ATOMS_E2E_ORG_ID_2025_12 ?? ""), + ATOMS_E2E_APPLE_ID: process.env.ATOMS_E2E_APPLE_ID_2025_12 ?? "", + ATOMS_E2E_APPLE_CONNECT_APP_SPECIFIC_PASSCODE: + process.env.ATOMS_E2E_APPLE_CONNECT_APP_SPECIFIC_PASSCODE_2025_12 ?? "", + }, + } + : {}), }, }); diff --git a/packages/platform/examples/base/prisma/schema.test.prisma b/packages/platform/examples/base/prisma/schema.test.prisma new file mode 100644 index 00000000000000..f6fdeb9e8c7896 --- /dev/null +++ b/packages/platform/examples/base/prisma/schema.test.prisma @@ -0,0 +1,19 @@ +generator testClient { + provider = "prisma-client-js" + output = "../node_modules/.prisma/test-client" +} +datasource testDb { + provider = "sqlite" + url = "file:./test.db" +} +model User { + id Int @id @default(autoincrement()) + email String @unique + name String? + calcomUserId Int? @unique + calcomUsername String? @unique + refreshToken String? @unique + accessToken String? @unique + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} \ No newline at end of file diff --git a/packages/platform/examples/base/src/lib/prismaClient.ts b/packages/platform/examples/base/src/lib/prismaClient.ts index 526f0420a369e1..db7e267d1a250e 100644 --- a/packages/platform/examples/base/src/lib/prismaClient.ts +++ b/packages/platform/examples/base/src/lib/prismaClient.ts @@ -1,7 +1,8 @@ -// prisma client of example app -// using local prisma db, not related to the cal.com monorepo prisma client -// eslint-disable-next-line -import { PrismaClient } from "@prisma/client"; +const isE2E = process.env.NEXT_PUBLIC_IS_E2E === "1"; + +const { PrismaClient } = isE2E + ? require("../../node_modules/.prisma/test-client") + : require("@prisma/client"); const prismaClientSingleton = () => { return new PrismaClient(); diff --git a/packages/platform/examples/base/src/pages/api/managed-user.ts b/packages/platform/examples/base/src/pages/api/managed-user.ts index ee6013ac3bcbfc..ac32d86f2e2a84 100644 --- a/packages/platform/examples/base/src/pages/api/managed-user.ts +++ b/packages/platform/examples/base/src/pages/api/managed-user.ts @@ -20,15 +20,13 @@ async function createUserWithDefaultSchedule(email: string, name: string, avatar }); const managedUserResponse = await fetch( - // eslint-disable-next-line turbo/no-undeclared-env-vars `${process.env.NEXT_PUBLIC_CALCOM_API_URL ?? ""}/oauth-clients/${process.env.NEXT_PUBLIC_X_CAL_ID}/users`, { method: "POST", headers: { "Content-Type": "application/json", - // eslint-disable-next-line turbo/no-undeclared-env-vars + [X_CAL_SECRET_KEY]: process.env.X_CAL_SECRET_KEY ?? "", - origin: "http://localhost:4321", }, body: JSON.stringify({ email, @@ -40,6 +38,14 @@ async function createUserWithDefaultSchedule(email: string, name: string, avatar const managedUserResponseBody = await managedUserResponse.json(); + if (!managedUserResponse.ok) { + throw new Error( + `Failed to create managed user: ${managedUserResponse.status} ${ + managedUserResponse.statusText + }\n${JSON.stringify(managedUserResponseBody, null, 2)}` + ); + } + await prisma.user.update({ data: { refreshToken: (managedUserResponseBody.data?.refreshToken as string) ?? "", @@ -142,17 +148,15 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse< async function createTeam(orgId: number, name: string) { const response = await fetch( - // eslint-disable-next-line turbo/no-undeclared-env-vars `${process.env.NEXT_PUBLIC_CALCOM_API_URL ?? ""}/organizations/${orgId}/teams`, { method: "POST", headers: { "Content-Type": "application/json", - // eslint-disable-next-line turbo/no-undeclared-env-vars + [X_CAL_SECRET_KEY]: process.env.X_CAL_SECRET_KEY ?? "", - // eslint-disable-next-line turbo/no-undeclared-env-vars + [X_CAL_CLIENT_ID]: process.env.NEXT_PUBLIC_X_CAL_ID ?? "", - origin: "http://localhost:4321", }, body: JSON.stringify({ name, @@ -167,17 +171,15 @@ async function createTeam(orgId: number, name: string) { async function createOrgTeamMembershipMember(orgId: number, teamId: number, userId: number) { await fetch( - // eslint-disable-next-line turbo/no-undeclared-env-vars `${process.env.NEXT_PUBLIC_CALCOM_API_URL ?? ""}/organizations/${orgId}/teams/${teamId}/memberships`, { method: "POST", headers: { "Content-Type": "application/json", - // eslint-disable-next-line turbo/no-undeclared-env-vars + [X_CAL_SECRET_KEY]: process.env.X_CAL_SECRET_KEY ?? "", - // eslint-disable-next-line turbo/no-undeclared-env-vars + [X_CAL_CLIENT_ID]: process.env.NEXT_PUBLIC_X_CAL_ID ?? "", - origin: "http://localhost:4321", }, body: JSON.stringify({ userId, @@ -189,41 +191,34 @@ async function createOrgTeamMembershipMember(orgId: number, teamId: number, user } async function createOrgMembershipAdmin(orgId: number, userId: number) { - await fetch( - // eslint-disable-next-line turbo/no-undeclared-env-vars - `${process.env.NEXT_PUBLIC_CALCOM_API_URL ?? ""}/organizations/${orgId}/memberships`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - // eslint-disable-next-line turbo/no-undeclared-env-vars - [X_CAL_SECRET_KEY]: process.env.X_CAL_SECRET_KEY ?? "", - // eslint-disable-next-line turbo/no-undeclared-env-vars - [X_CAL_CLIENT_ID]: process.env.NEXT_PUBLIC_X_CAL_ID ?? "", - origin: "http://localhost:4321", - }, - body: JSON.stringify({ - userId, - accepted: true, - role: "ADMIN", - }), - } - ); + await fetch(`${process.env.NEXT_PUBLIC_CALCOM_API_URL ?? ""}/organizations/${orgId}/memberships`, { + method: "POST", + headers: { + "Content-Type": "application/json", + + [X_CAL_SECRET_KEY]: process.env.X_CAL_SECRET_KEY ?? "", + + [X_CAL_CLIENT_ID]: process.env.NEXT_PUBLIC_X_CAL_ID ?? "", + }, + body: JSON.stringify({ + userId, + accepted: true, + role: "ADMIN", + }), + }); } async function createCollectiveEventType(orgId: number, teamId: number, userIds: number[]) { await fetch( - // eslint-disable-next-line turbo/no-undeclared-env-vars `${process.env.NEXT_PUBLIC_CALCOM_API_URL ?? ""}/organizations/${orgId}/teams/${teamId}/event-types`, { method: "POST", headers: { "Content-Type": "application/json", - // eslint-disable-next-line turbo/no-undeclared-env-vars + [X_CAL_SECRET_KEY]: process.env.X_CAL_SECRET_KEY ?? "", - // eslint-disable-next-line turbo/no-undeclared-env-vars + [X_CAL_CLIENT_ID]: process.env.NEXT_PUBLIC_X_CAL_ID ?? "", - origin: "http://localhost:4321", }, body: JSON.stringify({ lengthInMinutes: 60, @@ -238,17 +233,15 @@ async function createCollectiveEventType(orgId: number, teamId: number, userIds: async function createRoundRobinEventType(orgId: number, teamId: number, userIds: number[]) { await fetch( - // eslint-disable-next-line turbo/no-undeclared-env-vars `${process.env.NEXT_PUBLIC_CALCOM_API_URL ?? ""}/organizations/${orgId}/teams/${teamId}/event-types`, { method: "POST", headers: { "Content-Type": "application/json", - // eslint-disable-next-line turbo/no-undeclared-env-vars + [X_CAL_SECRET_KEY]: process.env.X_CAL_SECRET_KEY ?? "", - // eslint-disable-next-line turbo/no-undeclared-env-vars + [X_CAL_CLIENT_ID]: process.env.NEXT_PUBLIC_X_CAL_ID ?? "", - origin: "http://localhost:4321", }, body: JSON.stringify({ lengthInMinutes: 60, @@ -266,23 +259,19 @@ async function createDefaultSchedule(accessToken: string) { const timeZone = "Europe/London"; const isDefault = true; - const response = await fetch( - // eslint-disable-next-line turbo/no-undeclared-env-vars - `${process.env.NEXT_PUBLIC_CALCOM_API_URL ?? ""}/schedules`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - // eslint-disable-next-line turbo/no-undeclared-env-vars - Authorization: `Bearer ${accessToken}`, - }, - body: JSON.stringify({ - name, - timeZone, - isDefault, - }), - } - ); + const response = await fetch(`${process.env.NEXT_PUBLIC_CALCOM_API_URL ?? ""}/schedules`, { + method: "POST", + headers: { + "Content-Type": "application/json", + + Authorization: `Bearer ${accessToken}`, + }, + body: JSON.stringify({ + name, + timeZone, + isDefault, + }), + }); const schedule = await response.json(); return schedule; diff --git a/packages/platform/examples/base/tests/availability-settings-atom/availability-settings-atom.e2e.ts b/packages/platform/examples/base/tests/availability-settings-atom/availability-settings-atom.e2e.ts index 2f741fecd2bb1d..a2287bf82c64c9 100644 --- a/packages/platform/examples/base/tests/availability-settings-atom/availability-settings-atom.e2e.ts +++ b/packages/platform/examples/base/tests/availability-settings-atom/availability-settings-atom.e2e.ts @@ -11,6 +11,8 @@ async function selectOption(page: Page, optionNumber: number) { // eslint-disable-next-line playwright/no-skipped-test test.skip("availability page loads with all components", async ({ page }) => { await page.goto("/availability"); + await page.reload(); + await expect(page).toHaveURL("/availability"); await expect(page.locator('[data-testid="list-schedules-atom"]')).toBeVisible(); diff --git a/packages/platform/examples/base/tests/booker-atom/booker-atom.e2e.ts b/packages/platform/examples/base/tests/booker-atom/booker-atom.e2e.ts index 4a342f4b10a3cc..401fb05e50ff21 100644 --- a/packages/platform/examples/base/tests/booker-atom/booker-atom.e2e.ts +++ b/packages/platform/examples/base/tests/booker-atom/booker-atom.e2e.ts @@ -10,6 +10,7 @@ async function selectOption(page: Page, optionNumber: number) { test("tweak availability using AvailabilitySettings Atom", async ({ page }) => { await page.goto("/booking"); + await page.reload(); await expect(page).toHaveURL("/booking"); diff --git a/packages/platform/examples/base/tests/connect-atoms/apple-connect.e2e.ts b/packages/platform/examples/base/tests/connect-atoms/apple-connect.e2e.ts index eed8380b0330ec..6e9e67e29a23d8 100644 --- a/packages/platform/examples/base/tests/connect-atoms/apple-connect.e2e.ts +++ b/packages/platform/examples/base/tests/connect-atoms/apple-connect.e2e.ts @@ -5,6 +5,7 @@ test("connect calendar using the apple connect atom", async ({ page }) => { const appSpecificPassword = process.env.ATOMS_E2E_APPLE_CONNECT_APP_SPECIFIC_PASSCODE; await page.goto("/"); + await page.reload(); await expect(page.locator("body")).toBeVisible(); diff --git a/packages/platform/examples/base/tests/create-event-type-atom/create-event-type.e2e.ts b/packages/platform/examples/base/tests/create-event-type-atom/create-event-type.e2e.ts index d0094357553fab..9c2fb7632604ce 100644 --- a/packages/platform/examples/base/tests/create-event-type-atom/create-event-type.e2e.ts +++ b/packages/platform/examples/base/tests/create-event-type-atom/create-event-type.e2e.ts @@ -4,6 +4,7 @@ import { generateRandomText } from "../../src/lib/generateRandomText"; test("create event type using CreateEventTypeAtom", async ({ page }) => { await page.goto("/"); + await page.reload(); await page.goto("/event-types"); diff --git a/packages/platform/examples/base/tests/create-team-event-type-atom/create-team-event-type.e2e.ts b/packages/platform/examples/base/tests/create-team-event-type-atom/create-team-event-type.e2e.ts index 6253cbb963e1e0..28c89378b8f3b8 100644 --- a/packages/platform/examples/base/tests/create-team-event-type-atom/create-team-event-type.e2e.ts +++ b/packages/platform/examples/base/tests/create-team-event-type-atom/create-team-event-type.e2e.ts @@ -5,6 +5,7 @@ import { generateRandomText } from "../../src/lib/generateRandomText"; // eslint-disable-next-line playwright/no-skipped-test test.skip("create team event using CreateTeamEventTypeAtom", async ({ page }) => { await page.goto("/"); + await page.reload(); await page.goto("/event-types"); diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 8d815369a8e1ae..f58709102e9d83 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -1985,10 +1985,13 @@ model PlatformOAuthClient { accessTokens AccessToken[] refreshToken RefreshToken[] authorizationTokens PlatformAuthorizationToken[] - webhook Webhook[] - bookingRedirectUri String? - bookingCancelRedirectUri String? + webhook Webhook[] + + bookingRedirectUri String? + + bookingCancelRedirectUri String? + bookingRescheduleRedirectUri String? areEmailsEnabled Boolean @default(false) areDefaultEventTypesEnabled Boolean @default(true) diff --git a/packages/trpc/server/routers/viewer/slots/_router.tsx b/packages/trpc/server/routers/viewer/slots/_router.tsx index c99b7af3b21ae9..96a556e64dd342 100644 --- a/packages/trpc/server/routers/viewer/slots/_router.tsx +++ b/packages/trpc/server/routers/viewer/slots/_router.tsx @@ -7,12 +7,6 @@ import { ZRemoveSelectedSlotInputSchema } from "./removeSelectedSlot.schema"; import { ZReserveSlotInputSchema } from "./reserveSlot.schema"; import { ZGetScheduleInputSchema } from "./types"; -type SlotsRouterHandlerCache = { - getSchedule?: typeof import("./getSchedule.handler").getScheduleHandler; - reserveSlot?: typeof import("./reserveSlot.handler").reserveSlotHandler; - isAvailable?: typeof import("./isAvailable.handler").isAvailableHandler; -}; - /** This should be called getAvailableSlots */ export const slotsRouter = router({ getSchedule: publicProcedure.input(ZGetScheduleInputSchema).query(async ({ input, ctx }) => { diff --git a/scripts/seed.ts b/scripts/seed.ts index 27584c538d29f5..13f7c9b20da891 100644 --- a/scripts/seed.ts +++ b/scripts/seed.ts @@ -184,21 +184,42 @@ async function createPlatformAndSetupUser({ }, }); - const clientId = process.env.SEED_PLATFORM_OAUTH_CLIENT_ID; - const secret = process.env.SEED_PLATFORM_OAUTH_CLIENT_SECRET; - - if (clientId && secret) { + const exampleAppClientId = process.env.SEED_PLATFORM_OAUTH_CLIENT_ID; + const exampleAppClientSecret = process.env.SEED_PLATFORM_OAUTH_CLIENT_SECRET; + if (exampleAppClientId && exampleAppClientSecret) { await prisma.platformOAuthClient.create({ data: { - name: "Acme", + name: "examples-app", redirectUris: ["http://localhost:4321"], permissions: 1023, areEmailsEnabled: true, organizationId: team.id, - id: clientId, - secret, + id: exampleAppClientId, + secret: exampleAppClientSecret, + }, + }); + } else { + console.log("⚠️ No example app client id and secret found, skipping example app client creation"); + } + + const exampleAppClientIdE2e = process.env.SEED_PLATFORM_OAUTH_CLIENT_ID_E2E; + const exampleAppClientSecretE2e = process.env.SEED_PLATFORM_OAUTH_CLIENT_SECRET_E2E; + if (exampleAppClientIdE2e && exampleAppClientSecretE2e) { + await prisma.platformOAuthClient.create({ + data: { + name: "examples-app-e2e", + redirectUris: ["http://localhost:4322"], + permissions: 1023, + areEmailsEnabled: true, + organizationId: team.id, + id: exampleAppClientIdE2e, + secret: exampleAppClientSecretE2e, }, }); + } else { + console.log( + "⚠️ No example app e2e client id and secret found, skipping example app e2e client creation" + ); } console.log(`\t👤 Added '${teamInput.name}' membership for '${username}' with role '${membershipRole}'`); } diff --git a/turbo.json b/turbo.json index fae9ccf3cb1038..8f294e40bc39f4 100644 --- a/turbo.json +++ b/turbo.json @@ -10,6 +10,13 @@ "ATOMS_E2E_OAUTH_CLIENT_ID_BOOKER_EMBED", "ATOMS_E2E_OAUTH_CLIENT_SECRET", "ATOMS_E2E_ORG_ID", + "ATOMS_E2E_API_URL_2025_12", + "ATOMS_E2E_OAUTH_CLIENT_ID_2025_12", + "ATOMS_E2E_OAUTH_CLIENT_ID_BOOKER_EMBED_2025_12", + "ATOMS_E2E_OAUTH_CLIENT_SECRET_2025_12", + "ATOMS_E2E_APPLE_ID_2025_12", + "ATOMS_E2E_APPLE_CONNECT_APP_SPECIFIC_PASSCODE_2025_12", + "ATOMS_E2E_ORG_ID_2025_12", "BASECAMP3_CLIENT_ID", "BASECAMP3_CLIENT_SECRET", "BASECAMP3_USER_AGENT", @@ -277,6 +284,9 @@ "ATOMS_E2E_APPLE_CONNECT_APP_SPECIFIC_PASSCODE", "INTERCOM_API_TOKEN", "NEXT_PUBLIC_INTERCOM_APP_ID", + "NEXT_PUBLIC_X_CAL_ID", + "X_CAL_SECRET_KEY", + "NEXT_PUBLIC_CALCOM_API_URL", "MICROSOFT_WEBHOOK_TOKEN", "MICROSOFT_WEBHOOK_URL", "_CAL_INTERNAL_PAST_BOOKING_RESCHEDULE_CHANGE_TEAM_IDS", @@ -291,7 +301,9 @@ "GOOGLE_ADS_ENABLED", "LINKEDIN_ADS_ENABLED", "SEED_PLATFORM_OAUTH_CLIENT_ID", - "SEED_PLATFORM_OAUTH_CLIENT_SECRET" + "SEED_PLATFORM_OAUTH_CLIENT_SECRET", + "SEED_PLATFORM_OAUTH_CLIENT_ID_E2E", + "SEED_PLATFORM_OAUTH_CLIENT_SECRET_E2E" ], "tasks": { "@calcom/web#copy-app-store-static": {