From db625157d16b8714a032a083d188ec37e91aac75 Mon Sep 17 00:00:00 2001 From: DmytroHryshyn <125881252+DmytroHryshyn@users.noreply.github.com> Date: Thu, 7 Dec 2023 22:43:41 +0200 Subject: [PATCH] chore: [app-router-migration-2] migrate trpc, ssgInit, ssrInit (#12593) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: migrate trpc, ssgInit, ssrInit * manual: fix emails * manual: fix ts issues * Update packages/emails/README.md * remove unneeded use client statements * fix flaky locale tests, fix flaky login test --------- Co-authored-by: Omar López --- apps/web/app/_trpc/createTRPCNextLayout.ts | 212 +++++++++++++++++++++ apps/web/app/_trpc/ssgInit.ts | 34 ++++ apps/web/app/_trpc/ssrInit.ts | 57 ++++++ apps/web/next.config.js | 3 + apps/web/playwright/fixtures/users.ts | 4 +- apps/web/playwright/locale.e2e.ts | 126 ++++++------ packages/emails/README.md | 2 +- 7 files changed, 375 insertions(+), 63 deletions(-) create mode 100644 apps/web/app/_trpc/createTRPCNextLayout.ts create mode 100644 apps/web/app/_trpc/ssgInit.ts create mode 100644 apps/web/app/_trpc/ssrInit.ts diff --git a/apps/web/app/_trpc/createTRPCNextLayout.ts b/apps/web/app/_trpc/createTRPCNextLayout.ts new file mode 100644 index 0000000000..c65b4fe941 --- /dev/null +++ b/apps/web/app/_trpc/createTRPCNextLayout.ts @@ -0,0 +1,212 @@ +// originally from in the "experimental playground for tRPC + next.js 13" repo owned by trpc team +// file link: https://github.com/trpc/next-13/blob/main/%40trpc/next-layout/createTRPCNextLayout.ts +// repo link: https://github.com/trpc/next-13 +// code is / will continue to be adapted for our usage +import { dehydrate, QueryClient } from "@tanstack/query-core"; +import type { DehydratedState, QueryKey } from "@tanstack/react-query"; + +import type { Maybe, TRPCClientError, TRPCClientErrorLike } from "@calcom/trpc"; +import { + callProcedure, + type AnyProcedure, + type AnyQueryProcedure, + type AnyRouter, + type DataTransformer, + type inferProcedureInput, + type inferProcedureOutput, + type inferRouterContext, + type MaybePromise, + type ProcedureRouterRecord, +} from "@calcom/trpc/server"; + +import { createRecursiveProxy, createFlatProxy } from "@trpc/server/shared"; + +export function getArrayQueryKey( + queryKey: string | [string] | [string, ...unknown[]] | unknown[], + type: string +): QueryKey { + const queryKeyArrayed = Array.isArray(queryKey) ? queryKey : [queryKey]; + const [arrayPath, input] = queryKeyArrayed; + + if (!input && (!type || type === "any")) { + return Array.isArray(arrayPath) && arrayPath.length !== 0 ? [arrayPath] : ([] as unknown as QueryKey); + } + + return [ + arrayPath, + { + ...(typeof input !== "undefined" && { input: input }), + ...(type && type !== "any" && { type: type }), + }, + ]; +} + +// copy starts +// copied from trpc/trpc repo +// ref: https://github.com/trpc/trpc/blob/main/packages/next/src/withTRPC.tsx#L37-#L58 +function transformQueryOrMutationCacheErrors< + TState extends DehydratedState["queries"][0] | DehydratedState["mutations"][0] +>(result: TState): TState { + const error = result.state.error as Maybe>; + if (error instanceof Error && error.name === "TRPCClientError") { + const newError: TRPCClientErrorLike = { + message: error.message, + data: error.data, + shape: error.shape, + }; + return { + ...result, + state: { + ...result.state, + error: newError, + }, + }; + } + return result; +} +// copy ends + +interface CreateTRPCNextLayoutOptions { + router: TRouter; + createContext: () => MaybePromise>; + transformer?: DataTransformer; +} + +/** + * @internal + */ +export type DecorateProcedure = TProcedure extends AnyQueryProcedure + ? { + fetch(input: inferProcedureInput): Promise>; + fetchInfinite(input: inferProcedureInput): Promise>; + prefetch(input: inferProcedureInput): Promise>; + prefetchInfinite(input: inferProcedureInput): Promise>; + } + : never; + +type OmitNever = Pick< + TType, + { + [K in keyof TType]: TType[K] extends never ? never : K; + }[keyof TType] +>; +/** + * @internal + */ +export type DecoratedProcedureRecord< + TProcedures extends ProcedureRouterRecord, + TPath extends string = "" +> = OmitNever<{ + [TKey in keyof TProcedures]: TProcedures[TKey] extends AnyRouter + ? DecoratedProcedureRecord + : TProcedures[TKey] extends AnyQueryProcedure + ? DecorateProcedure + : never; +}>; + +type CreateTRPCNextLayout = DecoratedProcedureRecord & { + dehydrate(): Promise; + queryClient: QueryClient; +}; + +const getStateContainer = (opts: CreateTRPCNextLayoutOptions) => { + let _trpc: { + queryClient: QueryClient; + context: inferRouterContext; + } | null = null; + + return () => { + if (_trpc === null) { + _trpc = { + context: opts.createContext(), + queryClient: new QueryClient(), + }; + } + + return _trpc; + }; +}; + +export function createTRPCNextLayout( + opts: CreateTRPCNextLayoutOptions +): CreateTRPCNextLayout { + const getState = getStateContainer(opts); + + const transformer = opts.transformer ?? { + serialize: (v) => v, + deserialize: (v) => v, + }; + + return createFlatProxy((key) => { + const state = getState(); + const { queryClient } = state; + if (key === "queryClient") { + return queryClient; + } + + if (key === "dehydrate") { + // copy starts + // copied from trpc/trpc repo + // ref: https://github.com/trpc/trpc/blob/main/packages/next/src/withTRPC.tsx#L214-#L229 + const dehydratedCache = dehydrate(queryClient, { + shouldDehydrateQuery() { + // makes sure errors are also dehydrated + return true; + }, + }); + + // since error instances can't be serialized, let's make them into `TRPCClientErrorLike`-objects + const dehydratedCacheWithErrors = { + ...dehydratedCache, + queries: dehydratedCache.queries.map(transformQueryOrMutationCacheErrors), + mutations: dehydratedCache.mutations.map(transformQueryOrMutationCacheErrors), + }; + + return () => transformer.serialize(dehydratedCacheWithErrors); + } + // copy ends + + return createRecursiveProxy(async (callOpts) => { + const path = [key, ...callOpts.path]; + const utilName = path.pop(); + const ctx = await state.context; + + const caller = opts.router.createCaller(ctx); + + const pathStr = path.join("."); + const input = callOpts.args[0]; + + if (utilName === "fetchInfinite") { + return queryClient.fetchInfiniteQuery(getArrayQueryKey([path, input], "infinite"), () => + caller.query(pathStr, input) + ); + } + + if (utilName === "prefetch") { + return queryClient.prefetchQuery({ + queryKey: getArrayQueryKey([path, input], "query"), + queryFn: async () => { + const res = await callProcedure({ + procedures: opts.router._def.procedures, + path: pathStr, + rawInput: input, + ctx, + type: "query", + }); + return res; + }, + }); + } + + if (utilName === "prefetchInfinite") { + return queryClient.prefetchInfiniteQuery(getArrayQueryKey([path, input], "infinite"), () => + caller.query(pathStr, input) + ); + } + + return queryClient.fetchQuery(getArrayQueryKey([path, input], "query"), () => + caller.query(pathStr, input) + ); + }) as CreateTRPCNextLayout; + }); +} diff --git a/apps/web/app/_trpc/ssgInit.ts b/apps/web/app/_trpc/ssgInit.ts new file mode 100644 index 0000000000..45e38c519d --- /dev/null +++ b/apps/web/app/_trpc/ssgInit.ts @@ -0,0 +1,34 @@ +import { serverSideTranslations } from "next-i18next/serverSideTranslations"; +import { headers } from "next/headers"; +import superjson from "superjson"; + +import { CALCOM_VERSION } from "@calcom/lib/constants"; +import prisma, { readonlyPrisma } from "@calcom/prisma"; +import { appRouter } from "@calcom/trpc/server/routers/_app"; + +import { createTRPCNextLayout } from "./createTRPCNextLayout"; + +export async function ssgInit() { + const locale = headers().get("x-locale") ?? "en"; + + const i18n = (await serverSideTranslations(locale, ["common"])) || "en"; + + const ssg = createTRPCNextLayout({ + router: appRouter, + transformer: superjson, + createContext() { + return { prisma, insightsDb: readonlyPrisma, session: null, locale, i18n }; + }, + }); + + // i18n translations are already retrieved from serverSideTranslations call, there is no need to run a i18n.fetch + // we can set query data directly to the queryClient + const queryKey = [ + ["viewer", "public", "i18n"], + { input: { locale, CalComVersion: CALCOM_VERSION }, type: "query" }, + ]; + + ssg.queryClient.setQueryData(queryKey, { i18n }); + + return ssg; +} diff --git a/apps/web/app/_trpc/ssrInit.ts b/apps/web/app/_trpc/ssrInit.ts new file mode 100644 index 0000000000..ab56003578 --- /dev/null +++ b/apps/web/app/_trpc/ssrInit.ts @@ -0,0 +1,57 @@ +import { type GetServerSidePropsContext } from "next"; +import { serverSideTranslations } from "next-i18next/serverSideTranslations"; +import { headers, cookies } from "next/headers"; +import superjson from "superjson"; + +import { getLocale } from "@calcom/features/auth/lib/getLocale"; +import { CALCOM_VERSION } from "@calcom/lib/constants"; +import prisma, { readonlyPrisma } from "@calcom/prisma"; +import { appRouter } from "@calcom/trpc/server/routers/_app"; + +import { createTRPCNextLayout } from "./createTRPCNextLayout"; + +export async function ssrInit(options?: { noI18nPreload: boolean }) { + const req = { + headers: headers(), + cookies: cookies(), + }; + + const locale = await getLocale(req); + + const i18n = (await serverSideTranslations(locale, ["common", "vital"])) || "en"; + + const ssr = createTRPCNextLayout({ + router: appRouter, + transformer: superjson, + createContext() { + return { + prisma, + insightsDb: readonlyPrisma, + session: null, + locale, + i18n, + req: req as unknown as GetServerSidePropsContext["req"], + }; + }, + }); + + // i18n translations are already retrieved from serverSideTranslations call, there is no need to run a i18n.fetch + // we can set query data directly to the queryClient + const queryKey = [ + ["viewer", "public", "i18n"], + { input: { locale, CalComVersion: CALCOM_VERSION }, type: "query" }, + ]; + if (!options?.noI18nPreload) { + ssr.queryClient.setQueryData(queryKey, { i18n }); + } + + await Promise.allSettled([ + // So feature flags are available on first render + ssr.viewer.features.map.prefetch(), + // Provides a better UX to the users who have already upgraded. + ssr.viewer.teams.hasTeamPlan.prefetch(), + ssr.viewer.public.session.prefetch(), + ]); + + return ssr; +} diff --git a/apps/web/next.config.js b/apps/web/next.config.js index 1f3935bf5f..452838e30d 100644 --- a/apps/web/next.config.js +++ b/apps/web/next.config.js @@ -154,6 +154,9 @@ const matcherConfigUserTypeEmbedRoute = { /** @type {import("next").NextConfig} */ const nextConfig = { + experimental: { + serverComponentsExternalPackages: ["next-i18next"], + }, i18n: { ...i18n, localeDetection: false, diff --git a/apps/web/playwright/fixtures/users.ts b/apps/web/playwright/fixtures/users.ts index 16b362c2fa..35884d7a5d 100644 --- a/apps/web/playwright/fixtures/users.ts +++ b/apps/web/playwright/fixtures/users.ts @@ -673,8 +673,8 @@ export async function login( await passwordLocator.fill(user.password ?? user.username!); await signInLocator.click(); - // Moving away from waiting 2 seconds, as it is not a reliable way to expect session to be started - await page.waitForLoadState("networkidle"); + // waiting for specific login request to resolve + await page.waitForResponse(/\/api\/auth\/callback\/credentials/); } export async function apiLogin( diff --git a/apps/web/playwright/locale.e2e.ts b/apps/web/playwright/locale.e2e.ts index f84a8ab85e..067f8a3d46 100644 --- a/apps/web/playwright/locale.e2e.ts +++ b/apps/web/playwright/locale.e2e.ts @@ -11,7 +11,8 @@ test.describe("unauthorized user sees correct translations (de)", async () => { test("should use correct translations and html attributes", async ({ page }) => { await page.goto("/"); - await page.waitForLoadState("load"); + // we dont need to wait for styles and images, only for dom + await page.waitForLoadState("domcontentloaded"); await page.locator("html[lang=de]").waitFor({ state: "attached" }); await page.locator("html[dir=ltr]").waitFor({ state: "attached" }); @@ -35,7 +36,7 @@ test.describe("unauthorized user sees correct translations (ar)", async () => { test("should use correct translations and html attributes", async ({ page }) => { await page.goto("/"); - await page.waitForLoadState("load"); + await page.waitForLoadState("domcontentloaded"); await page.locator("html[lang=ar]").waitFor({ state: "attached" }); await page.locator("html[dir=rtl]").waitFor({ state: "attached" }); @@ -59,7 +60,7 @@ test.describe("unauthorized user sees correct translations (zh)", async () => { test("should use correct translations and html attributes", async ({ page }) => { await page.goto("/"); - await page.waitForLoadState("load"); + await page.waitForLoadState("domcontentloaded"); await page.locator("html[lang=zh]").waitFor({ state: "attached" }); await page.locator("html[dir=ltr]").waitFor({ state: "attached" }); @@ -83,7 +84,7 @@ test.describe("unauthorized user sees correct translations (zh-CN)", async () => test("should use correct translations and html attributes", async ({ page }) => { await page.goto("/"); - await page.waitForLoadState("load"); + await page.waitForLoadState("domcontentloaded"); await page.locator("html[lang=zh-CN]").waitFor({ state: "attached" }); await page.locator("html[dir=ltr]").waitFor({ state: "attached" }); @@ -107,7 +108,7 @@ test.describe("unauthorized user sees correct translations (zh-TW)", async () => test("should use correct translations and html attributes", async ({ page }) => { await page.goto("/"); - await page.waitForLoadState("load"); + await page.waitForLoadState("domcontentloaded"); await page.locator("html[lang=zh-TW]").waitFor({ state: "attached" }); await page.locator("html[dir=ltr]").waitFor({ state: "attached" }); @@ -131,7 +132,7 @@ test.describe("unauthorized user sees correct translations (pt)", async () => { test("should use correct translations and html attributes", async ({ page }) => { await page.goto("/"); - await page.waitForLoadState("load"); + await page.waitForLoadState("domcontentloaded"); await page.locator("html[lang=pt]").waitFor({ state: "attached" }); await page.locator("html[dir=ltr]").waitFor({ state: "attached" }); @@ -155,7 +156,7 @@ test.describe("unauthorized user sees correct translations (pt-br)", async () => test("should use correct translations and html attributes", async ({ page }) => { await page.goto("/"); - await page.waitForLoadState("load"); + await page.waitForLoadState("domcontentloaded"); await page.locator("html[lang=pt-BR]").waitFor({ state: "attached" }); await page.locator("html[dir=ltr]").waitFor({ state: "attached" }); @@ -179,7 +180,7 @@ test.describe("unauthorized user sees correct translations (es-419)", async () = test("should use correct translations and html attributes", async ({ page }) => { await page.goto("/"); - await page.waitForLoadState("load"); + await page.waitForLoadState("domcontentloaded"); // es-419 is disabled in i18n config, so es should be used as fallback await page.locator("html[lang=es]").waitFor({ state: "attached" }); @@ -213,57 +214,61 @@ test.describe("authorized user sees correct translations (de)", async () => { await test.step("should navigate to /event-types and show German translations", async () => { await page.goto("/event-types"); - await page.waitForLoadState("networkidle"); + await page.waitForLoadState("domcontentloaded"); await page.locator("html[lang=de]").waitFor({ state: "attached" }); await page.locator("html[dir=ltr]").waitFor({ state: "attached" }); { - const locator = page.getByText("Ereignistypen", { exact: true }); - expect(await locator.count()).toBeGreaterThanOrEqual(1); + const locator = page.getByRole("heading", { name: "Ereignistypen", exact: true }); + // locator.count() does not wait for elements + // but event-types page is client side, so it takes some time to render html + // thats why we need to use method that awaits for the element + // https://github.com/microsoft/playwright/issues/14278#issuecomment-1131754679 + await expect(locator).toHaveCount(1); } { const locator = page.getByText("Event Types", { exact: true }); - expect(await locator.count()).toEqual(0); + await expect(locator).toHaveCount(0); } }); await test.step("should navigate to /bookings and show German translations", async () => { await page.goto("/bookings"); - await page.waitForLoadState("networkidle"); + await page.waitForLoadState("domcontentloaded"); await page.locator("html[lang=de]").waitFor({ state: "attached" }); await page.locator("html[dir=ltr]").waitFor({ state: "attached" }); { - const locator = page.getByText("Buchungen", { exact: true }); - expect(await locator.count()).toBeGreaterThanOrEqual(1); + const locator = page.getByRole("heading", { name: "Buchungen", exact: true }); + await expect(locator).toHaveCount(1); } { const locator = page.getByText("Bookings", { exact: true }); - expect(await locator.count()).toEqual(0); + await expect(locator).toHaveCount(0); } }); await test.step("should reload the /bookings and show German translations", async () => { await page.reload(); - await page.waitForLoadState("networkidle"); + await page.waitForLoadState("domcontentloaded"); await page.locator("html[lang=de]").waitFor({ state: "attached" }); await page.locator("html[dir=ltr]").waitFor({ state: "attached" }); { - const locator = page.getByText("Buchungen", { exact: true }); - expect(await locator.count()).toBeGreaterThanOrEqual(1); + const locator = page.getByRole("heading", { name: "Buchungen", exact: true }); + await expect(locator).toHaveCount(1); } { const locator = page.getByText("Bookings", { exact: true }); - expect(await locator.count()).toEqual(0); + await expect(locator).toHaveCount(0); } }); }); @@ -285,57 +290,57 @@ test.describe("authorized user sees correct translations (pt-br)", async () => { await test.step("should navigate to /event-types and show Brazil-Portuguese translations", async () => { await page.goto("/event-types"); - await page.waitForLoadState("networkidle"); + await page.waitForLoadState("domcontentloaded"); await page.locator("html[lang=pt-br]").waitFor({ state: "attached" }); await page.locator("html[dir=ltr]").waitFor({ state: "attached" }); { - const locator = page.getByText("Tipos de Eventos", { exact: true }); - expect(await locator.count()).toBeGreaterThanOrEqual(1); + const locator = page.getByRole("heading", { name: "Tipos de Eventos", exact: true }); + await expect(locator).toHaveCount(1); } { const locator = page.getByText("Event Types", { exact: true }); - expect(await locator.count()).toEqual(0); + await expect(locator).toHaveCount(0); } }); await test.step("should navigate to /bookings and show Brazil-Portuguese translations", async () => { await page.goto("/bookings"); - await page.waitForLoadState("networkidle"); + await page.waitForLoadState("domcontentloaded"); await page.locator("html[lang=pt-br]").waitFor({ state: "attached" }); await page.locator("html[dir=ltr]").waitFor({ state: "attached" }); { - const locator = page.getByText("Reservas", { exact: true }); - expect(await locator.count()).toBeGreaterThanOrEqual(1); + const locator = page.getByRole("heading", { name: "Reservas", exact: true }); + await expect(locator).toHaveCount(1); } { const locator = page.getByText("Bookings", { exact: true }); - expect(await locator.count()).toEqual(0); + await expect(locator).toHaveCount(0); } }); await test.step("should reload the /bookings and show Brazil-Portuguese translations", async () => { await page.reload(); - await page.waitForLoadState("networkidle"); + await page.waitForLoadState("domcontentloaded"); await page.locator("html[lang=pt-br]").waitFor({ state: "attached" }); await page.locator("html[dir=ltr]").waitFor({ state: "attached" }); { - const locator = page.getByText("Reservas", { exact: true }); - expect(await locator.count()).toBeGreaterThanOrEqual(1); + const locator = page.getByRole("heading", { name: "Reservas", exact: true }); + await expect(locator).toHaveCount(1); } { const locator = page.getByText("Bookings", { exact: true }); - expect(await locator.count()).toEqual(0); + await expect(locator).toHaveCount(0); } }); }); @@ -357,57 +362,57 @@ test.describe("authorized user sees correct translations (ar)", async () => { await test.step("should navigate to /event-types and show Arabic translations", async () => { await page.goto("/event-types"); - await page.waitForLoadState("networkidle"); + await page.waitForLoadState("domcontentloaded"); await page.locator("html[lang=ar]").waitFor({ state: "attached" }); await page.locator("html[dir=rtl]").waitFor({ state: "attached" }); { - const locator = page.getByText("أنواع الحدث", { exact: true }); - expect(await locator.count()).toBeGreaterThanOrEqual(1); + const locator = page.getByRole("heading", { name: "أنواع الحدث", exact: true }); + await expect(locator).toHaveCount(1); } { const locator = page.getByText("Event Types", { exact: true }); - expect(await locator.count()).toEqual(0); + await expect(locator).toHaveCount(0); } }); await test.step("should navigate to /bookings and show Arabic translations", async () => { await page.goto("/bookings"); - await page.waitForLoadState("networkidle"); + await page.waitForLoadState("domcontentloaded"); await page.locator("html[lang=ar]").waitFor({ state: "attached" }); await page.locator("html[dir=rtl]").waitFor({ state: "attached" }); { - const locator = page.getByText("عمليات الحجز", { exact: true }); - expect(await locator.count()).toBeGreaterThanOrEqual(1); + const locator = page.getByRole("heading", { name: "عمليات الحجز", exact: true }); + await expect(locator).toHaveCount(1); } { const locator = page.getByText("Bookings", { exact: true }); - expect(await locator.count()).toEqual(0); + await expect(locator).toHaveCount(0); } }); await test.step("should reload the /bookings and show Arabic translations", async () => { await page.reload(); - await page.waitForLoadState("networkidle"); + await page.waitForLoadState("domcontentloaded"); await page.locator("html[lang=ar]").waitFor({ state: "attached" }); await page.locator("html[dir=rtl]").waitFor({ state: "attached" }); { - const locator = page.getByText("عمليات الحجز", { exact: true }); - expect(await locator.count()).toBeGreaterThanOrEqual(1); + const locator = page.getByRole("heading", { name: "عمليات الحجز", exact: true }); + await expect(locator).toHaveCount(1); } { const locator = page.getByText("Bookings", { exact: true }); - expect(await locator.count()).toEqual(0); + await expect(locator).toHaveCount(0); } }); }); @@ -429,7 +434,7 @@ test.describe("authorized user sees changed translations (de->ar)", async () => await test.step("should change the language and show Arabic translations", async () => { await page.goto("/settings/my-account/general"); - await page.waitForLoadState("networkidle"); + await page.waitForLoadState("domcontentloaded"); await page.locator(".bg-default > div > div:nth-child(2)").first().click(); await page.locator("#react-select-2-option-0").click(); @@ -444,32 +449,33 @@ test.describe("authorized user sees changed translations (de->ar)", async () => await page.locator("html[dir=rtl]").waitFor({ state: "attached" }); { - const locator = page.getByText("عام", { exact: true }); // "general" - expect(await locator.count()).toBeGreaterThanOrEqual(1); + // at least one is visible + const locator = page.getByText("عام", { exact: true }).last(); // "general" + await expect(locator).toBeVisible(); } { const locator = page.getByText("Allgemein", { exact: true }); // "general" - expect(await locator.count()).toEqual(0); + await expect(locator).toHaveCount(0); } }); await test.step("should reload and show Arabic translations", async () => { await page.reload(); - await page.waitForLoadState("networkidle"); + await page.waitForLoadState("domcontentloaded"); await page.locator("html[lang=ar]").waitFor({ state: "attached" }); await page.locator("html[dir=rtl]").waitFor({ state: "attached" }); { - const locator = page.getByText("عام", { exact: true }); // "general" - expect(await locator.count()).toBeGreaterThanOrEqual(1); + const locator = page.getByText("عام", { exact: true }).last(); // "general" + await expect(locator).toBeVisible(); } { const locator = page.getByText("Allgemein", { exact: true }); // "general" - expect(await locator.count()).toEqual(0); + await expect(locator).toHaveCount(0); } }); }); @@ -491,7 +497,7 @@ test.describe("authorized user sees changed translations (de->pt-BR) [locale1]", await test.step("should change the language and show Brazil-Portuguese translations", async () => { await page.goto("/settings/my-account/general"); - await page.waitForLoadState("networkidle"); + await page.waitForLoadState("domcontentloaded"); await page.locator(".bg-default > div > div:nth-child(2)").first().click(); await page.locator("#react-select-2-option-14").click(); @@ -506,32 +512,32 @@ test.describe("authorized user sees changed translations (de->pt-BR) [locale1]", await page.locator("html[dir=ltr]").waitFor({ state: "attached" }); { - const locator = page.getByText("Geral", { exact: true }); // "general" - expect(await locator.count()).toBeGreaterThanOrEqual(1); + const locator = page.getByText("Geral", { exact: true }).last(); // "general" + await expect(locator).toBeVisible(); } { const locator = page.getByText("Allgemein", { exact: true }); // "general" - expect(await locator.count()).toEqual(0); + await expect(locator).toHaveCount(0); } }); await test.step("should reload and show Brazil-Portuguese translations", async () => { await page.reload(); - await page.waitForLoadState("networkidle"); + await page.waitForLoadState("domcontentloaded"); await page.locator("html[lang=pt-BR]").waitFor({ state: "attached" }); await page.locator("html[dir=ltr]").waitFor({ state: "attached" }); { - const locator = page.getByText("Geral", { exact: true }); // "general" - expect(await locator.count()).toBeGreaterThanOrEqual(1); + const locator = page.getByText("Geral", { exact: true }).last(); // "general" + await expect(locator).toBeVisible(); } { const locator = page.getByText("Allgemein", { exact: true }); // "general" - expect(await locator.count()).toEqual(0); + await expect(locator).toHaveCount(0); } }); }); diff --git a/packages/emails/README.md b/packages/emails/README.md index 98a6920974..bd5439926a 100644 --- a/packages/emails/README.md +++ b/packages/emails/README.md @@ -8,7 +8,7 @@ ```ts import { renderEmail } from "@calcom/emails"; -renderEmail("TeamInviteEmail", { +await renderEmail("TeamInviteEmail", { language: t, from: "teampro@example.com", to: "pro@example.com",