diff --git a/apps/core/src/pages/auth.tsx b/apps/core/src/pages/auth.tsx index 9c9ac07e5..784bda85c 100644 --- a/apps/core/src/pages/auth.tsx +++ b/apps/core/src/pages/auth.tsx @@ -1,12 +1,20 @@ import { ChangeEmailPage, ChangePasswordPage, + ConfirmChangeEmail, SetPasswordPage, SignInSuccessPage, SignUpPage, } from '@affine/component/auth-components'; -import { changeEmailMutation, changePasswordMutation } from '@affine/graphql'; -import { useMutation } from '@affine/workspace/affine/gql'; +import { pushNotificationAtom } from '@affine/component/notification-center'; +import { + changeEmailMutation, + changePasswordMutation, + sendVerifyChangeEmailMutation, +} from '@affine/graphql'; +import { useAFFiNEI18N } from '@affine/i18n/hooks'; +import { fetcher, useMutation } from '@affine/workspace/affine/gql'; +import { useSetAtom } from 'jotai/react'; import type { ReactElement } from 'react'; import { useCallback } from 'react'; import { @@ -27,30 +35,46 @@ const authTypeSchema = z.enum([ 'changePassword', 'signUp', 'changeEmail', + 'confirm-change-email', ]); export const AuthPage = (): ReactElement | null => { const user = useCurrentUser(); + const t = useAFFiNEI18N(); + const { authType } = useParams(); const [searchParams] = useSearchParams(); + const pushNotification = useSetAtom(pushNotificationAtom); + const { trigger: changePassword } = useMutation({ mutation: changePasswordMutation, }); - const { trigger: changeEmail } = useMutation({ - mutation: changeEmailMutation, + const { trigger: sendVerifyChangeEmail } = useMutation({ + mutation: sendVerifyChangeEmailMutation, }); + const { jumpToIndex } = useNavigateHelper(); - const onChangeEmail = useCallback( + const onSendVerifyChangeEmail = useCallback( async (email: string) => { - const res = await changeEmail({ + const res = await sendVerifyChangeEmail({ token: searchParams.get('token') || '', - newEmail: email, - }); - return !!res?.changeEmail; + email, + callbackUrl: `/auth/confirm-change-email`, + }).catch(console.error); + + // FIXME: There is not notification + if (res?.sendVerifyChangeEmail) { + pushNotification({ + title: t['com.affine.auth.sent.change.email.hint'](), + type: 'success', + }); + } + + return !!res?.sendVerifyChangeEmail; }, - [changeEmail, searchParams] + [pushNotification, searchParams, sendVerifyChangeEmail, t] ); const onSetPassword = useCallback( @@ -100,12 +124,14 @@ export const AuthPage = (): ReactElement | null => { case 'changeEmail': { return ( ); } + case 'confirm-change-email': { + return ; + } } return null; }; @@ -117,6 +143,22 @@ export const loader: LoaderFunction = async args => { if (!authTypeSchema.safeParse(args.params.authType).success) { return redirect('/404'); } + + if (args.params.authType === 'confirm-change-email') { + const url = new URL(args.request.url); + const searchParams = url.searchParams; + const token = searchParams.get('token'); + const res = await fetcher({ + query: changeEmailMutation, + variables: { + token: token || '', + }, + }).catch(console.error); + // TODO: Add error handling + if (!res?.changeEmail) { + return redirect('/expired'); + } + } return null; }; export const Component = () => { @@ -130,5 +172,6 @@ export const Component = () => { if (loginStatus === 'authenticated') { return ; } + return null; }; diff --git a/apps/server/src/modules/auth/mailer/mail.service.ts b/apps/server/src/modules/auth/mailer/mail.service.ts index a2b84a599..da8832bc7 100644 --- a/apps/server/src/modules/auth/mailer/mail.service.ts +++ b/apps/server/src/modules/auth/mailer/mail.service.ts @@ -160,6 +160,33 @@ export class MailService { html, }); } + async sendVerifyChangeEmail(to: string, url: string) { + const html = emailTemplate({ + title: 'Verify your new email address', + content: + 'You recently requested to change the email address associated with your AFFiNE account. To complete this process, please click on the verification link below. This magic link will expire in 30 minutes.', + buttonContent: 'Verify your new email address', + buttonUrl: url, + }); + return this.sendMail({ + from: this.config.auth.email.sender, + to, + subject: `Verify your new email for AFFiNE`, + html, + }); + } + async sendNotificationChangeEmail(to: string) { + const html = emailTemplate({ + title: 'Email change successful', + content: `As per your request, we have changed your email. Please make sure you're using ${to} when you log in the next time. `, + }); + return this.sendMail({ + from: this.config.auth.email.sender, + to, + subject: `Your email has been changed`, + html, + }); + } async sendAcceptedEmail( to: string, { diff --git a/apps/server/src/modules/auth/resolver.ts b/apps/server/src/modules/auth/resolver.ts index dfd8e8a06..58fad4cf0 100644 --- a/apps/server/src/modules/auth/resolver.ts +++ b/apps/server/src/modules/auth/resolver.ts @@ -131,17 +131,19 @@ export class AuthResolver { @Auth() async changeEmail( @CurrentUser() user: UserType, - @Args('token') token: string, - @Args('email') email: string + @Args('token') token: string ) { - const id = await this.session.get(token); - if (!id || id !== user.id) { + // email has set token in `sendVerifyChangeEmail` + const [id, email] = (await this.session.get(token)).split(','); + if (!id || id !== user.id || !email) { throw new ForbiddenException('Invalid token'); } await this.auth.changeEmail(id, email); await this.session.delete(token); + await this.auth.sendNotificationChangeEmail(email); + return user; } @@ -181,6 +183,13 @@ export class AuthResolver { return !res.rejected.length; } + // The change email step is: + // 1. send email to primitive email `sendChangeEmail` + // 2. user open change email page from email + // 3. send verify email to new email `sendVerifyChangeEmail` + // 4. user open confirm email page from new email + // 5. user click confirm button + // 6. send notification email @Throttle(5, 60) @Mutation(() => Boolean) @Auth() @@ -198,4 +207,37 @@ export class AuthResolver { const res = await this.auth.sendChangeEmail(email, url.toString()); return !res.rejected.length; } + + @Throttle(5, 60) + @Mutation(() => Boolean) + @Auth() + async sendVerifyChangeEmail( + @CurrentUser() user: UserType, + @Args('token') token: string, + @Args('email') email: string, + @Args('callbackUrl') callbackUrl: string + ) { + const id = await this.session.get(token); + if (!id || id !== user.id) { + throw new ForbiddenException('Invalid token'); + } + + const hasRegistered = await this.auth.getUserByEmail(email); + + if (hasRegistered) { + throw new BadRequestException(`Invalid user email`); + } + + const withEmailToken = nanoid(); + await this.session.set(withEmailToken, `${user.id},${email}`); + + const url = new URL(callbackUrl, this.config.baseUrl); + url.searchParams.set('token', withEmailToken); + + const res = await this.auth.sendVerifyChangeEmail(email, url.toString()); + + await this.session.delete(token); + + return !res.rejected.length; + } } diff --git a/apps/server/src/modules/auth/service.ts b/apps/server/src/modules/auth/service.ts index 7ac7828dd..d872cdfad 100644 --- a/apps/server/src/modules/auth/service.ts +++ b/apps/server/src/modules/auth/service.ts @@ -251,4 +251,10 @@ export class AuthService { async sendChangeEmail(email: string, callbackUrl: string) { return this.mailer.sendChangeEmail(email, callbackUrl); } + async sendVerifyChangeEmail(email: string, callbackUrl: string) { + return this.mailer.sendVerifyChangeEmail(email, callbackUrl); + } + async sendNotificationChangeEmail(email: string) { + return this.mailer.sendNotificationChangeEmail(email); + } } diff --git a/apps/server/src/schema.gql b/apps/server/src/schema.gql index 2fd95a285..c4e73edc8 100644 --- a/apps/server/src/schema.gql +++ b/apps/server/src/schema.gql @@ -192,10 +192,11 @@ type Mutation { signUp(name: String!, email: String!, password: String!): UserType! signIn(email: String!, password: String!): UserType! changePassword(token: String!, newPassword: String!): UserType! - changeEmail(token: String!, email: String!): UserType! + changeEmail(token: String!): UserType! sendChangePasswordEmail(email: String!, callbackUrl: String!): Boolean! sendSetPasswordEmail(email: String!, callbackUrl: String!): Boolean! sendChangeEmail(email: String!, callbackUrl: String!): Boolean! + sendVerifyChangeEmail(token: String!, email: String!, callbackUrl: String!): Boolean! } """The `Upload` scalar type represents a file upload.""" diff --git a/apps/server/src/tests/auth.e2e.ts b/apps/server/src/tests/auth.e2e.ts new file mode 100644 index 000000000..4c2517e96 --- /dev/null +++ b/apps/server/src/tests/auth.e2e.ts @@ -0,0 +1,133 @@ +import type { INestApplication } from '@nestjs/common'; +import { Test } from '@nestjs/testing'; +import { PrismaClient } from '@prisma/client'; +import ava, { TestFn } from 'ava'; +// @ts-expect-error graphql-upload is not typed +import graphqlUploadExpress from 'graphql-upload/graphqlUploadExpress.mjs'; + +import { AppModule } from '../app'; +import { MailService } from '../modules/auth/mailer'; +import { AuthService } from '../modules/auth/service'; +import { + changeEmail, + createWorkspace, + getCurrentMailMessageCount, + getLatestMailMessage, + sendChangeEmail, + sendVerifyChangeEmail, + signUp, +} from './utils'; + +const test = ava as TestFn<{ + app: INestApplication; + client: PrismaClient; + auth: AuthService; + mail: MailService; +}>; + +test.beforeEach(async t => { + const client = new PrismaClient(); + t.context.client = client; + await client.$connect(); + await client.user.deleteMany({}); + await client.snapshot.deleteMany({}); + await client.update.deleteMany({}); + await client.workspace.deleteMany({}); + await client.$disconnect(); + const module = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + const app = module.createNestApplication(); + app.use( + graphqlUploadExpress({ + maxFileSize: 10 * 1024 * 1024, + maxFiles: 5, + }) + ); + await app.init(); + + const auth = module.get(AuthService); + const mail = module.get(MailService); + t.context.app = app; + t.context.auth = auth; + t.context.mail = mail; +}); + +test.afterEach(async t => { + await t.context.app.close(); +}); + +test('change email', async t => { + const { mail, app } = t.context; + if (mail.hasConfigured()) { + const u1Email = 'u1@affine.pro'; + const u2Email = 'u2@affine.pro'; + const tokenRegex = /token=3D([^"&\s]+)/; + + const u1 = await signUp(app, 'u1', u1Email, '1'); + + await createWorkspace(app, u1.token.token); + + const primitiveMailCount = await getCurrentMailMessageCount(); + + await sendChangeEmail(app, u1.token.token, u1Email, 'affine.pro'); + + const afterSendChangeMailCount = await getCurrentMailMessageCount(); + t.is( + primitiveMailCount + 1, + afterSendChangeMailCount, + 'failed to send change email' + ); + const changeEmailContent = await getLatestMailMessage(); + + const changeTokenMatch = changeEmailContent.Content.Body.match(tokenRegex); + const changeEmailToken = changeTokenMatch + ? decodeURIComponent(changeTokenMatch[1].replace(/=3D/g, '=')) + : null; + + t.not( + changeEmailToken, + null, + 'fail to get change email token from email content' + ); + + await sendVerifyChangeEmail( + app, + u1.token.token, + changeEmailToken as string, + u2Email, + 'affine.pro' + ); + + const afterSendVerifyMailCount = await getCurrentMailMessageCount(); + + t.is( + afterSendChangeMailCount + 1, + afterSendVerifyMailCount, + 'failed to send verify email' + ); + const verifyEmailContent = await getLatestMailMessage(); + + const verifyTokenMatch = verifyEmailContent.Content.Body.match(tokenRegex); + const verifyEmailToken = verifyTokenMatch + ? decodeURIComponent(verifyTokenMatch[1].replace(/=3D/g, '=')) + : null; + + t.not( + verifyEmailToken, + null, + 'fail to get verify change email token from email content' + ); + + await changeEmail(app, u1.token.token, verifyEmailToken as string); + + const afterNotificationMailCount = await getCurrentMailMessageCount(); + + t.is( + afterSendVerifyMailCount + 1, + afterNotificationMailCount, + 'failed to send notification email' + ); + } + t.pass(); +}); diff --git a/apps/server/src/tests/mailer.e2e.ts b/apps/server/src/tests/mailer.e2e.ts index fe10cc28f..bdb1a501f 100644 --- a/apps/server/src/tests/mailer.e2e.ts +++ b/apps/server/src/tests/mailer.e2e.ts @@ -81,6 +81,7 @@ test('should include callbackUrl in sending email', async t => { 'sendSetPasswordEmail', 'sendChangeEmail', 'sendChangePasswordEmail', + 'sendVerifyChangeEmail', ] as const) { const prev = await getCurrentMailMessageCount(); await auth[fn]('alexyang@example.org', 'https://test.com/callback'); diff --git a/apps/server/src/tests/utils.ts b/apps/server/src/tests/utils.ts index f7526ba6f..827c10f00 100644 --- a/apps/server/src/tests/utils.ts +++ b/apps/server/src/tests/utils.ts @@ -473,6 +473,76 @@ async function getInviteInfo( return res.body.data.getInviteInfo; } +async function sendChangeEmail( + app: INestApplication, + userToken: string, + email: string, + callbackUrl: string +): Promise { + const res = await request(app.getHttpServer()) + .post(gql) + .auth(userToken, { type: 'bearer' }) + .set({ 'x-request-id': 'test', 'x-operation-name': 'test' }) + .send({ + query: ` + mutation { + sendChangeEmail(email: "${email}", callbackUrl: "${callbackUrl}") + } + `, + }) + .expect(200); + + return res.body.data.sendChangeEmail; +} + +async function sendVerifyChangeEmail( + app: INestApplication, + userToken: string, + token: string, + email: string, + callbackUrl: string +): Promise { + const res = await request(app.getHttpServer()) + .post(gql) + .auth(userToken, { type: 'bearer' }) + .set({ 'x-request-id': 'test', 'x-operation-name': 'test' }) + .send({ + query: ` + mutation { + sendVerifyChangeEmail(token:"${token}", email: "${email}", callbackUrl: "${callbackUrl}") + } + `, + }) + .expect(200); + + return res.body.data.sendVerifyChangeEmail; +} + +async function changeEmail( + app: INestApplication, + userToken: string, + token: string +): Promise { + const res = await request(app.getHttpServer()) + .post(gql) + .auth(userToken, { type: 'bearer' }) + .set({ 'x-request-id': 'test', 'x-operation-name': 'test' }) + .send({ + query: ` + mutation { + changeEmail(token: "${token}") { + id + name + avatarUrl + email + } + } + `, + }) + .expect(200); + return res.body.data.changeEmail; +} + export class FakePrisma { fakeUser: User = { id: randomUUID(), @@ -504,6 +574,7 @@ export class FakePrisma { export { acceptInvite, acceptInviteById, + changeEmail, checkBlobSize, collectAllBlobSizes, collectBlobSizes, @@ -518,6 +589,8 @@ export { listBlobs, revokePage, revokeUser, + sendChangeEmail, + sendVerifyChangeEmail, setBlob, sharePage, signUp, diff --git a/packages/cli/src/bin/dev-core.ts b/packages/cli/src/bin/dev-core.ts index 03d8e6791..50a06db8b 100644 --- a/packages/cli/src/bin/dev-core.ts +++ b/packages/cli/src/bin/dev-core.ts @@ -30,6 +30,8 @@ if (process.argv.includes('--static')) { 'serve', '--mode', 'development', + '--no-client-overlay', + '--no-live-reload', '--env', 'flags=' + Buffer.from(JSON.stringify(flags), 'utf-8').toString('hex'), ].filter((v): v is string => !!v), diff --git a/packages/component/src/components/auth-components/change-email-page.tsx b/packages/component/src/components/auth-components/change-email-page.tsx index f48778a4e..c39ccb7b3 100644 --- a/packages/component/src/components/auth-components/change-email-page.tsx +++ b/packages/component/src/components/auth-components/change-email-page.tsx @@ -1,23 +1,17 @@ import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { Button } from '@toeverything/components/button'; -import type { FC } from 'react'; import { useCallback, useState } from 'react'; import { AuthInput } from './auth-input'; import { AuthPageContainer } from './auth-page-container'; import { emailRegex } from './utils'; -type User = { - id: string; - name: string; - email: string; - image: string; -}; -export const ChangeEmailPage: FC<{ - user: User; +export const ChangeEmailPage = ({ + onChangeEmail: propsOnChangeEmail, +}: { onChangeEmail: (email: string) => Promise; onOpenAffine: () => void; -}> = ({ onChangeEmail: propsOnChangeEmail, onOpenAffine }) => { +}) => { const t = useAFFiNEI18N(); const [hasSetUp, setHasSetUp] = useState(false); const [email, setEmail] = useState(''); @@ -45,45 +39,33 @@ export const ChangeEmailPage: FC<{ }, []); return ( - {hasSetUp ? ( - - ) : ( - <> - - - - )} + ); }; diff --git a/packages/component/src/components/auth-components/confirm-change-email.tsx b/packages/component/src/components/auth-components/confirm-change-email.tsx new file mode 100644 index 000000000..baafb0c95 --- /dev/null +++ b/packages/component/src/components/auth-components/confirm-change-email.tsx @@ -0,0 +1,22 @@ +import { useAFFiNEI18N } from '@affine/i18n/hooks'; +import { Button } from '@toeverything/components/button'; +import type { FC } from 'react'; + +import { AuthPageContainer } from './auth-page-container'; + +export const ConfirmChangeEmail: FC<{ + onOpenAffine: () => void; +}> = ({ onOpenAffine }) => { + const t = useAFFiNEI18N(); + + return ( + + + + ); +}; diff --git a/packages/component/src/components/auth-components/index.tsx b/packages/component/src/components/auth-components/index.tsx index b24a4f5e8..ae57041a1 100644 --- a/packages/component/src/components/auth-components/index.tsx +++ b/packages/component/src/components/auth-components/index.tsx @@ -4,6 +4,7 @@ export * from './auth-page-container'; export * from './back-button'; export * from './change-email-page'; export * from './change-password-page'; +export * from './confirm-change-email'; export * from './count-down-render'; export * from './modal'; export * from './modal-header'; diff --git a/packages/graphql/src/graphql/change-email.gql b/packages/graphql/src/graphql/change-email.gql index e2efdf343..77746962c 100644 --- a/packages/graphql/src/graphql/change-email.gql +++ b/packages/graphql/src/graphql/change-email.gql @@ -1,5 +1,5 @@ -mutation changeEmail($token: String!, $newEmail: String!) { - changeEmail(token: $token, email: $newEmail) { +mutation changeEmail($token: String!) { + changeEmail(token: $token) { id name avatarUrl diff --git a/packages/graphql/src/graphql/index.ts b/packages/graphql/src/graphql/index.ts index 8e60bb7ff..4f1d11930 100644 --- a/packages/graphql/src/graphql/index.ts +++ b/packages/graphql/src/graphql/index.ts @@ -85,8 +85,8 @@ export const changeEmailMutation = { definitionName: 'changeEmail', containsFile: false, query: ` -mutation changeEmail($token: String!, $newEmail: String!) { - changeEmail(token: $token, email: $newEmail) { +mutation changeEmail($token: String!) { + changeEmail(token: $token) { id name avatarUrl @@ -391,6 +391,17 @@ mutation sendSetPasswordEmail($email: String!, $callbackUrl: String!) { }`, }; +export const sendVerifyChangeEmailMutation = { + id: 'sendVerifyChangeEmailMutation' as const, + operationName: 'sendVerifyChangeEmail', + definitionName: 'sendVerifyChangeEmail', + containsFile: false, + query: ` +mutation sendVerifyChangeEmail($token: String!, $email: String!, $callbackUrl: String!) { + sendVerifyChangeEmail(token: $token, email: $email, callbackUrl: $callbackUrl) +}`, +}; + export const setRevokePageMutation = { id: 'setRevokePageMutation' as const, operationName: 'setRevokePage', diff --git a/packages/graphql/src/graphql/send-verify-change-email.gql b/packages/graphql/src/graphql/send-verify-change-email.gql new file mode 100644 index 000000000..9b64009eb --- /dev/null +++ b/packages/graphql/src/graphql/send-verify-change-email.gql @@ -0,0 +1,7 @@ +mutation sendVerifyChangeEmail( + $token: String! + $email: String! + $callbackUrl: String! +) { + sendVerifyChangeEmail(token: $token, email: $email, callbackUrl: $callbackUrl) +} diff --git a/packages/graphql/src/schema.ts b/packages/graphql/src/schema.ts index 51e941e2e..4987331f5 100644 --- a/packages/graphql/src/schema.ts +++ b/packages/graphql/src/schema.ts @@ -101,7 +101,6 @@ export type AllBlobSizesQuery = { export type ChangeEmailMutationVariables = Exact<{ token: Scalars['String']['input']; - newEmail: Scalars['String']['input']; }>; export type ChangeEmailMutation = { @@ -359,6 +358,17 @@ export type SendSetPasswordEmailMutation = { sendSetPasswordEmail: boolean; }; +export type SendVerifyChangeEmailMutationVariables = Exact<{ + token: Scalars['String']['input']; + email: Scalars['String']['input']; + callbackUrl: Scalars['String']['input']; +}>; + +export type SendVerifyChangeEmailMutation = { + __typename?: 'Mutation'; + sendVerifyChangeEmail: boolean; +}; + export type SetRevokePageMutationVariables = Exact<{ workspaceId: Scalars['String']['input']; pageId: Scalars['String']['input']; @@ -611,6 +621,11 @@ export type Mutations = variables: SendSetPasswordEmailMutationVariables; response: SendSetPasswordEmailMutation; } + | { + name: 'sendVerifyChangeEmailMutation'; + variables: SendVerifyChangeEmailMutationVariables; + response: SendVerifyChangeEmailMutation; + } | { name: 'setRevokePageMutation'; variables: SetRevokePageMutationVariables; diff --git a/tests/affine-cloud/playwright.config.ts b/tests/affine-cloud/playwright.config.ts index 7a63e657e..f3733f99d 100644 --- a/tests/affine-cloud/playwright.config.ts +++ b/tests/affine-cloud/playwright.config.ts @@ -6,7 +6,7 @@ import type { const config: PlaywrightTestConfig = { testDir: './e2e', fullyParallel: !process.env.CI, - timeout: process.env.CI ? 50_000 : 30_000, + timeout: process.env.CI ? 120_000 : 30_000, use: { baseURL: 'http://localhost:8081/', browserName: @@ -14,10 +14,10 @@ const config: PlaywrightTestConfig = { 'chromium', permissions: ['clipboard-read', 'clipboard-write'], viewport: { width: 1440, height: 800 }, - actionTimeout: 5 * 1000, + actionTimeout: 10 * 1000, locale: 'en-US', - trace: 'on-first-retry', - video: 'on-first-retry', + trace: 'on', + video: 'on', }, forbidOnly: !!process.env.CI, workers: process.env.CI ? 1 : 4, @@ -44,7 +44,7 @@ const config: PlaywrightTestConfig = { env: { DATABASE_URL: process.env.DATABASE_URL ?? - 'postgresql://affine@localhost:5432/affine', + 'postgresql://affine:affine@localhost:5432/affine', NODE_ENV: 'development', AFFINE_ENV: process.env.AFFINE_ENV ?? 'dev', DEBUG: 'affine:*', diff --git a/tests/kit/utils/cloud.ts b/tests/kit/utils/cloud.ts index 0b8a3f498..d90290795 100644 --- a/tests/kit/utils/cloud.ts +++ b/tests/kit/utils/cloud.ts @@ -144,18 +144,14 @@ export async function loginUser( await page.getByTestId('cloud-signin-button').click({ delay: 200, }); - await page.getByPlaceholder('Enter your email address').type(userEmail, { - delay: 50, - }); + await page.getByPlaceholder('Enter your email address').fill(userEmail); await page.getByTestId('continue-login-button').click({ delay: 200, }); await page.getByTestId('sign-in-with-password').click({ delay: 200, }); - await page.getByTestId('password-input').type('123456', { - delay: 50, - }); + await page.getByTestId('password-input').fill('123456'); if (config?.beforeLogin) { await config.beforeLogin(); } diff --git a/tests/kit/utils/page-logic.ts b/tests/kit/utils/page-logic.ts index 62f56f309..0ca791b5d 100644 --- a/tests/kit/utils/page-logic.ts +++ b/tests/kit/utils/page-logic.ts @@ -3,7 +3,7 @@ import { expect } from '@playwright/test'; export async function waitForEditorLoad(page: Page) { await page.waitForSelector('v-line', { - timeout: 10000, + timeout: 20000, }); }