diff --git a/apps/web/playwright/embed-code-generator.e2e.ts b/apps/web/playwright/embed-code-generator.e2e.ts index f49b853259..dec2200db9 100644 --- a/apps/web/playwright/embed-code-generator.e2e.ts +++ b/apps/web/playwright/embed-code-generator.e2e.ts @@ -1,19 +1,337 @@ import type { Page } from "@playwright/test"; import { expect } from "@playwright/test"; +import { Linter } from "eslint"; +import { parse } from "node-html-parser"; +import { getOrgFullOrigin } from "@calcom/features/ee/organizations/lib/orgDomains"; import { EMBED_LIB_URL, WEBAPP_URL } from "@calcom/lib/constants"; +import { MembershipRole } from "@calcom/prisma/client"; import { test } from "./lib/fixtures"; -function chooseEmbedType(page: Page, embedType: string) { +const linter = new Linter(); +const eslintRules = { + "no-undef": "error", + "no-unused-vars": "off", +} as const; +test.describe.configure({ mode: "parallel" }); + +test.afterEach(({ users }) => users.deleteAll()); + +test.describe("Embed Code Generator Tests", () => { + test.describe("Non-Organization", () => { + test.beforeEach(async ({ users }) => { + const pro = await users.create(); + await pro.apiLogin(); + }); + + test.describe("Event Types Page", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/event-types"); + }); + + test("open Embed Dialog and choose Inline for First Event Type", async ({ page, users }) => { + const [pro] = users.get(); + const embedUrl = await clickFirstEventTypeEmbedButton(page); + await expectToBeNavigatingToEmbedTypesDialog(page, { + embedUrl, + basePage: "/event-types", + }); + + chooseEmbedType(page, "inline"); + + await expectToBeNavigatingToEmbedCodeAndPreviewDialog(page, { + embedUrl, + embedType: "inline", + basePage: "/event-types", + }); + + await expectToContainValidCode(page, { + language: "html", + embedType: "inline", + orgSlug: null, + }); + + await goToReactCodeTab(page); + await expectToContainValidCode(page, { + language: "react", + embedType: "inline", + orgSlug: null, + }); + + await goToPreviewTab(page); + + await expectToContainValidPreviewIframe(page, { + embedType: "inline", + calLink: `${pro.username}/30-min`, + }); + }); + + test("open Embed Dialog and choose floating-popup for First Event Type", async ({ page, users }) => { + const [pro] = users.get(); + const embedUrl = await clickFirstEventTypeEmbedButton(page); + + await expectToBeNavigatingToEmbedTypesDialog(page, { + embedUrl, + basePage: "/event-types", + }); + + chooseEmbedType(page, "floating-popup"); + + await expectToBeNavigatingToEmbedCodeAndPreviewDialog(page, { + embedUrl, + embedType: "floating-popup", + basePage: "/event-types", + }); + await expectToContainValidCode(page, { + language: "html", + embedType: "floating-popup", + orgSlug: null, + }); + + await goToReactCodeTab(page); + await expectToContainValidCode(page, { + language: "react", + embedType: "floating-popup", + orgSlug: null, + }); + + await goToPreviewTab(page); + await expectToContainValidPreviewIframe(page, { + embedType: "floating-popup", + calLink: `${pro.username}/30-min`, + }); + }); + + test("open Embed Dialog and choose element-click for First Event Type", async ({ page, users }) => { + const [pro] = users.get(); + const embedUrl = await clickFirstEventTypeEmbedButton(page); + + await expectToBeNavigatingToEmbedTypesDialog(page, { + embedUrl, + basePage: "/event-types", + }); + + chooseEmbedType(page, "element-click"); + + await expectToBeNavigatingToEmbedCodeAndPreviewDialog(page, { + embedUrl, + embedType: "element-click", + basePage: "/event-types", + }); + await expectToContainValidCode(page, { + language: "html", + embedType: "element-click", + orgSlug: null, + }); + + await goToReactCodeTab(page); + await expectToContainValidCode(page, { + language: "react", + embedType: "element-click", + orgSlug: null, + }); + + await goToPreviewTab(page); + await expectToContainValidPreviewIframe(page, { + embedType: "element-click", + calLink: `${pro.username}/30-min`, + }); + }); + }); + test.describe("Event Type Edit Page", () => { + test.beforeEach(async ({ page }) => { + await page.goto(`/event-types`); + await Promise.all([ + page.locator('a[href*="/event-types/"]').first().click(), + page.waitForURL((url) => url.pathname.startsWith("/event-types/")), + ]); + }); + + test("open Embed Dialog for the Event Type", async ({ page }) => { + const basePage = new URL(page.url()).pathname; + const embedUrl = await clickEmbedButton(page); + await expectToBeNavigatingToEmbedTypesDialog(page, { + embedUrl, + basePage, + }); + + chooseEmbedType(page, "inline"); + + await expectToBeNavigatingToEmbedCodeAndPreviewDialog(page, { + embedUrl, + basePage, + embedType: "inline", + }); + + await expectToContainValidCode(page, { + language: "html", + embedType: "inline", + orgSlug: null, + }); + + await goToPreviewTab(page); + + await expectToContainValidPreviewIframe(page, { + embedType: "inline", + calLink: decodeURIComponent(embedUrl), + }); + }); + }); + }); + + test.describe("Organization", () => { + test.beforeEach(async ({ users, orgs }) => { + const org = await orgs.create({ + name: "TestOrg", + }); + const user = await users.create({ + organizationId: org.id, + roleInOrganization: MembershipRole.MEMBER, + }); + await user.apiLogin(); + }); + test.describe("Event Types Page", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/event-types"); + }); + + test("open Embed Dialog and choose Inline for First Event Type", async ({ page, users }) => { + const [user] = users.get(); + const { team: org } = await user.getOrgMembership(); + const embedUrl = await clickFirstEventTypeEmbedButton(page); + await expectToBeNavigatingToEmbedTypesDialog(page, { + embedUrl, + basePage: "/event-types", + }); + + chooseEmbedType(page, "inline"); + + await expectToBeNavigatingToEmbedCodeAndPreviewDialog(page, { + embedUrl, + embedType: "inline", + basePage: "/event-types", + }); + + // Default tab is HTML code tab + await expectToContainValidCode(page, { + language: "html", + embedType: "inline", + orgSlug: org.slug, + }); + + await goToReactCodeTab(page); + await expectToContainValidCode(page, { + language: "react", + embedType: "inline", + orgSlug: org.slug, + }); + + await goToPreviewTab(page); + await expectToContainValidPreviewIframe(page, { + embedType: "inline", + calLink: `${user.username}/30-min`, + bookerUrl: getOrgFullOrigin(org?.slug ?? ""), + }); + }); + + test("open Embed Dialog and choose floating-popup for First Event Type", async ({ page, users }) => { + const [user] = users.get(); + const { team: org } = await user.getOrgMembership(); + + const embedUrl = await clickFirstEventTypeEmbedButton(page); + + await expectToBeNavigatingToEmbedTypesDialog(page, { + embedUrl, + basePage: "/event-types", + }); + + chooseEmbedType(page, "floating-popup"); + + await expectToBeNavigatingToEmbedCodeAndPreviewDialog(page, { + embedUrl, + embedType: "floating-popup", + basePage: "/event-types", + }); + await expectToContainValidCode(page, { + language: "html", + embedType: "floating-popup", + orgSlug: org.slug, + }); + + await goToReactCodeTab(page); + await expectToContainValidCode(page, { + language: "react", + embedType: "floating-popup", + orgSlug: org.slug, + }); + + await goToPreviewTab(page); + await expectToContainValidPreviewIframe(page, { + embedType: "floating-popup", + calLink: `${user.username}/30-min`, + bookerUrl: getOrgFullOrigin(org?.slug ?? ""), + }); + }); + + test("open Embed Dialog and choose element-click for First Event Type", async ({ page, users }) => { + const [user] = users.get(); + const embedUrl = await clickFirstEventTypeEmbedButton(page); + const { team: org } = await user.getOrgMembership(); + + await expectToBeNavigatingToEmbedTypesDialog(page, { + embedUrl, + basePage: "/event-types", + }); + + chooseEmbedType(page, "element-click"); + + await expectToBeNavigatingToEmbedCodeAndPreviewDialog(page, { + embedUrl, + embedType: "element-click", + basePage: "/event-types", + }); + await expectToContainValidCode(page, { + language: "html", + embedType: "element-click", + orgSlug: org.slug, + }); + + await goToReactCodeTab(page); + await expectToContainValidCode(page, { + language: "react", + embedType: "element-click", + orgSlug: org.slug, + }); + + await goToPreviewTab(page); + await expectToContainValidPreviewIframe(page, { + embedType: "element-click", + calLink: `${user.username}/30-min`, + bookerUrl: getOrgFullOrigin(org?.slug ?? ""), + }); + }); + }); + }); +}); + +type EmbedType = "inline" | "floating-popup" | "element-click"; +function chooseEmbedType(page: Page, embedType: EmbedType) { page.locator(`[data-testid=${embedType}]`).click(); } -async function gotToPreviewTab(page: Page) { +async function goToPreviewTab(page: Page) { // To prevent early timeouts // eslint-disable-next-line playwright/no-wait-for-timeout await page.waitForTimeout(1000); - await page.locator("[data-testid=embed-tabs]").locator("text=Preview").click(); + await page.locator("[data-testid=horizontal-tab-Preview]").click(); +} + +async function goToReactCodeTab(page: Page) { + // To prevent early timeouts + // eslint-disable-next-line playwright/no-wait-for-timeout + await page.waitForTimeout(1000); + await page.locator("[data-testid=horizontal-tab-React]").click(); } async function clickEmbedButton(page: Page) { @@ -55,7 +373,7 @@ async function expectToBeNavigatingToEmbedCodeAndPreviewDialog( basePage, }: { embedUrl: string | null; - embedType: string; + embedType: EmbedType; basePage: string; } ) { @@ -73,10 +391,108 @@ async function expectToBeNavigatingToEmbedCodeAndPreviewDialog( }); } -async function expectToContainValidCode(page: Page, { embedType }: { embedType: string }) { +async function expectToContainValidCode( + page: Page, + { + embedType, + language, + orgSlug, + }: { embedType: EmbedType; language: "html" | "react"; orgSlug: string | null } +) { + if (language === "react") { + return expectValidReactEmbedSnippet(page, { embedType, orgSlug }); + } + if (language === "html") { + return expectValidHtmlEmbedSnippet(page, { embedType, orgSlug }); + } + throw new Error("Unknown language"); +} + +async function expectValidHtmlEmbedSnippet( + page: Page, + { embedType, orgSlug }: { embedType: EmbedType; orgSlug: string | null } +) { const embedCode = await page.locator("[data-testid=embed-code]").inputValue(); - expect(embedCode.includes("(function (C, A, L)")).toBe(true); - expect(embedCode.includes(`Cal ${embedType} embed code begins`)).toBe(true); + expect(embedCode).toContain("function (C, A, L)"); + expect(embedCode).toContain(`Cal ${embedType} embed code begins`); + if (orgSlug) { + expect(embedCode).toContain(orgSlug); + } + + const dom = parse(embedCode); + const scripts = dom.getElementsByTagName("script"); + assertThatCodeIsValidVanillaJsCode(scripts[0].innerText); + + return { + message: () => `passed`, + pass: true, + }; +} + +function assertThatCodeIsValidVanillaJsCode(code: string) { + const lintResult = linter.verify(code, { + env: { + browser: true, + }, + parserOptions: { + ecmaVersion: 2021, + }, + globals: { + Cal: "readonly", + }, + rules: eslintRules, + }); + if (lintResult.length) { + console.log( + JSON.stringify({ + lintResult, + code, + }) + ); + } + expect(lintResult.length).toBe(0); +} + +function assertThatCodeIsValidReactCode(code: string) { + const lintResult = linter.verify(code, { + env: { + browser: true, + }, + parserOptions: { + ecmaVersion: 2021, + ecmaFeatures: { + jsx: true, + }, + sourceType: "module", + }, + rules: eslintRules, + }); + if (lintResult.length) { + console.log( + JSON.stringify({ + lintResult, + code, + }) + ); + } + expect(lintResult.length).toBe(0); +} + +async function expectValidReactEmbedSnippet( + page: Page, + { embedType, orgSlug }: { embedType: EmbedType; orgSlug: string | null } +) { + const embedCode = await page.locator("[data-testid=embed-react]").inputValue(); + expect(embedCode).toContain("export default function MyApp("); + expect(embedCode).toContain( + embedType === "floating-popup" ? "floatingButton" : embedType === "inline" ? ` `passed`, pass: true, @@ -88,141 +504,10 @@ async function expectToContainValidCode(page: Page, { embedType }: { embedType: */ async function expectToContainValidPreviewIframe( page: Page, - { embedType, calLink }: { embedType: string; calLink: string } + { embedType, calLink, bookerUrl }: { embedType: EmbedType; calLink: string; bookerUrl?: string } ) { - const bookerUrl = `${WEBAPP_URL}`; + bookerUrl = bookerUrl || `${WEBAPP_URL}`; expect(await page.locator("[data-testid=embed-preview]").getAttribute("src")).toContain( `/preview.html?embedType=${embedType}&calLink=${calLink}&embedLibUrl=${EMBED_LIB_URL}&bookerUrl=${bookerUrl}` ); } - -test.describe.configure({ mode: "parallel" }); - -test.afterEach(({ users }) => users.deleteAll()); - -test.describe("Embed Code Generator Tests", () => { - test.beforeEach(async ({ users }) => { - const pro = await users.create(); - await pro.apiLogin(); - }); - - test.describe("Event Types Page", () => { - test.beforeEach(async ({ page }) => { - await page.goto("/event-types"); - }); - - test("open Embed Dialog and choose Inline for First Event Type", async ({ page, users }) => { - const [pro] = users.get(); - const embedUrl = await clickFirstEventTypeEmbedButton(page); - await expectToBeNavigatingToEmbedTypesDialog(page, { - embedUrl, - basePage: "/event-types", - }); - - chooseEmbedType(page, "inline"); - - await expectToBeNavigatingToEmbedCodeAndPreviewDialog(page, { - embedUrl, - embedType: "inline", - basePage: "/event-types", - }); - - await expectToContainValidCode(page, { embedType: "inline" }); - - await gotToPreviewTab(page); - - await expectToContainValidPreviewIframe(page, { - embedType: "inline", - calLink: `${pro.username}/30-min`, - }); - }); - - test("open Embed Dialog and choose floating-popup for First Event Type", async ({ page, users }) => { - const [pro] = users.get(); - const embedUrl = await clickFirstEventTypeEmbedButton(page); - - await expectToBeNavigatingToEmbedTypesDialog(page, { - embedUrl, - basePage: "/event-types", - }); - - chooseEmbedType(page, "floating-popup"); - - await expectToBeNavigatingToEmbedCodeAndPreviewDialog(page, { - embedUrl, - embedType: "floating-popup", - basePage: "/event-types", - }); - await expectToContainValidCode(page, { embedType: "floating-popup" }); - - await gotToPreviewTab(page); - await expectToContainValidPreviewIframe(page, { - embedType: "floating-popup", - calLink: `${pro.username}/30-min`, - }); - }); - - test("open Embed Dialog and choose element-click for First Event Type", async ({ page, users }) => { - const [pro] = users.get(); - const embedUrl = await clickFirstEventTypeEmbedButton(page); - - await expectToBeNavigatingToEmbedTypesDialog(page, { - embedUrl, - basePage: "/event-types", - }); - - chooseEmbedType(page, "element-click"); - - await expectToBeNavigatingToEmbedCodeAndPreviewDialog(page, { - embedUrl, - embedType: "element-click", - basePage: "/event-types", - }); - await expectToContainValidCode(page, { embedType: "element-click" }); - - await gotToPreviewTab(page); - await expectToContainValidPreviewIframe(page, { - embedType: "element-click", - calLink: `${pro.username}/30-min`, - }); - }); - }); - - test.describe("Event Type Edit Page", () => { - test.beforeEach(async ({ page }) => { - await page.goto(`/event-types`); - await Promise.all([ - page.locator('a[href*="/event-types/"]').first().click(), - page.waitForURL((url) => url.pathname.startsWith("/event-types/")), - ]); - }); - - test("open Embed Dialog for the Event Type", async ({ page }) => { - const basePage = new URL(page.url()).pathname; - const embedUrl = await clickEmbedButton(page); - await expectToBeNavigatingToEmbedTypesDialog(page, { - embedUrl, - basePage, - }); - - chooseEmbedType(page, "inline"); - - await expectToBeNavigatingToEmbedCodeAndPreviewDialog(page, { - embedUrl, - basePage, - embedType: "inline", - }); - - await expectToContainValidCode(page, { - embedType: "inline", - }); - - await gotToPreviewTab(page); - - await expectToContainValidPreviewIframe(page, { - embedType: "inline", - calLink: decodeURIComponent(embedUrl), - }); - }); - }); -}); diff --git a/apps/web/playwright/fixtures/users.ts b/apps/web/playwright/fixtures/users.ts index b0d0a48c65..f779f9a773 100644 --- a/apps/web/playwright/fixtures/users.ts +++ b/apps/web/playwright/fixtures/users.ts @@ -460,7 +460,7 @@ const createUserFixture = (user: UserWithIncludes, page: Page) => { } return membership; }, - getOrg: async () => { + getOrgMembership: async () => { return prisma.membership.findFirstOrThrow({ where: { userId: user.id, @@ -471,7 +471,13 @@ const createUserFixture = (user: UserWithIncludes, page: Page) => { }, }, }, - include: { team: { select: { children: true, metadata: true, name: true } } }, + include: { + team: { + include: { + children: true, + }, + }, + }, }); }, getFirstEventAsOwner: async () => diff --git a/apps/web/playwright/teams.e2e.ts b/apps/web/playwright/teams.e2e.ts index ae0c11d821..ba9f2960d6 100644 --- a/apps/web/playwright/teams.e2e.ts +++ b/apps/web/playwright/teams.e2e.ts @@ -168,7 +168,7 @@ test.describe("Teams - NonOrg", () => { await expect(page.locator("[data-testid=alert]")).toBeVisible(); // cleanup - const org = await owner.getOrg(); + const org = await owner.getOrgMembership(); await prisma.team.delete({ where: { id: org.teamId } }); } }); diff --git a/apps/web/playwright/unpublished.e2e.ts b/apps/web/playwright/unpublished.e2e.ts index 5f5a66b9eb..8ae45b8038 100644 --- a/apps/web/playwright/unpublished.e2e.ts +++ b/apps/web/playwright/unpublished.e2e.ts @@ -45,7 +45,7 @@ test.describe("Unpublished", () => { test("Organization profile", async ({ users, page }) => { const owner = await users.create(undefined, { hasTeam: true, isUnpublished: true, isOrg: true }); - const { team: org } = await owner.getOrg(); + const { team: org } = await owner.getOrgMembership(); const { requestedSlug } = org.metadata as { requestedSlug: string }; await page.goto(`/org/${requestedSlug}`); await page.waitForLoadState("networkidle"); @@ -62,7 +62,7 @@ test.describe("Unpublished", () => { isOrg: true, hasSubteam: true, }); - const { team: org } = await owner.getOrg(); + const { team: org } = await owner.getOrgMembership(); const { requestedSlug } = org.metadata as { requestedSlug: string }; const [{ slug: subteamSlug }] = org.children as { slug: string }[]; await page.goto(`/org/${requestedSlug}/team/${subteamSlug}`); @@ -80,7 +80,7 @@ test.describe("Unpublished", () => { isOrg: true, hasSubteam: true, }); - const { team: org } = await owner.getOrg(); + const { team: org } = await owner.getOrgMembership(); const { requestedSlug } = org.metadata as { requestedSlug: string }; const [{ slug: subteamSlug, id: subteamId }] = org.children as { slug: string; id: number }[]; const { slug: subteamEventSlug } = await owner.getFirstTeamEvent(subteamId); @@ -95,7 +95,7 @@ test.describe("Unpublished", () => { test("Organization user", async ({ users, page }) => { const owner = await users.create(undefined, { hasTeam: true, isUnpublished: true, isOrg: true }); - const { team: org } = await owner.getOrg(); + const { team: org } = await owner.getOrgMembership(); const { requestedSlug } = org.metadata as { requestedSlug: string }; await page.goto(`/org/${requestedSlug}/${owner.username}`); await page.waitForLoadState("networkidle"); @@ -107,7 +107,7 @@ test.describe("Unpublished", () => { test("Organization user event-type", async ({ users, page }) => { const owner = await users.create(undefined, { hasTeam: true, isUnpublished: true, isOrg: true }); - const { team: org } = await owner.getOrg(); + const { team: org } = await owner.getOrgMembership(); const { requestedSlug } = org.metadata as { requestedSlug: string }; const [{ slug: ownerEventType }] = owner.eventTypes; await page.goto(`/org/${requestedSlug}/${owner.username}/${ownerEventType}`); diff --git a/packages/app-store-cli/src/build.ts b/packages/app-store-cli/src/build.ts index 900ee25b56..68702b7710 100644 --- a/packages/app-store-cli/src/build.ts +++ b/packages/app-store-cli/src/build.ts @@ -104,10 +104,9 @@ function generateFiles() { * If a file has index.ts or index.tsx, it can be imported after removing the index.ts* part */ function getModulePath(path: string, moduleName: string) { - return ( - `./${path.replace(/\\/g, "/")}/` + - moduleName.replace(/\/index\.ts|\/index\.tsx/, "").replace(/\.tsx$|\.ts$/, "") - ); + return `./${path.replace(/\\/g, "/")}/${moduleName + .replace(/\/index\.ts|\/index\.tsx/, "") + .replace(/\.tsx$|\.ts$/, "")}`; } type ImportConfig = diff --git a/packages/features/embed/lib/EmbedCodes.tsx b/packages/features/embed/lib/EmbedCodes.tsx index f8b1c83bc5..53ca519e94 100644 --- a/packages/features/embed/lib/EmbedCodes.tsx +++ b/packages/features/embed/lib/EmbedCodes.tsx @@ -1,9 +1,15 @@ -import { IS_SELF_HOSTED } from "@calcom/lib/constants"; +import { CAL_URL, IS_SELF_HOSTED, WEBAPP_URL } from "@calcom/lib/constants"; import type { PreviewState } from "../types"; import { embedLibUrl } from "./constants"; import { getDimension } from "./getDimension"; +export const doWeNeedCalOriginProp = (embedCalOrigin: string) => { + // If we are self hosted, calOrigin won't be app.cal.com so we need to pass it + // If we are not self hosted but it's still different from WEBAPP_URL and CAL_URL, we need to pass it -> It happens for organization booking URL at the moment + return IS_SELF_HOSTED || (embedCalOrigin !== WEBAPP_URL && embedCalOrigin !== CAL_URL); +}; + // eslint-disable-next-line @typescript-eslint/no-explicit-any export const Codes = { react: { @@ -33,13 +39,9 @@ export const Codes = { return ; };`; }, @@ -53,7 +55,7 @@ export const Codes = { return code` import { getCalApi } from "@calcom/embed-react"; import { useEffect } from "react"; - export default function App() { + export default function MyApp() { useEffect(()=>{ (async function () { const cal = await getCalApi(${IS_SELF_HOSTED ? `"${embedLibUrl}"` : ""}); @@ -77,7 +79,7 @@ export const Codes = { return code` import { getCalApi } from "@calcom/embed-react"; import { useEffect } from "react"; - export default function App() { + export default function MyApp() { useEffect(()=>{ (async function () { const cal = await getCalApi(${IS_SELF_HOSTED ? `"${embedLibUrl}"` : ""}); @@ -85,7 +87,8 @@ export const Codes = { })(); }, []) return