diff --git a/.github/workflows/ui_tests.yml b/.github/workflows/ui_tests.yml index 933a904237..e09a532a8a 100644 --- a/.github/workflows/ui_tests.yml +++ b/.github/workflows/ui_tests.yml @@ -55,6 +55,7 @@ jobs: env: NEXT_PUBLIC_PROJECT_ID: ${{ secrets.NEXT_PUBLIC_PROJECT_ID }} NEXTAUTH_SECRET: ${{ secrets.TESTS_NEXTAUTH_SECRET }} + MAILSAC_API_KEY: ${{ secrets.TESTS_MAILSEC_API_KEY }} CI: true working-directory: ./apps/laboratory/ run: npm run playwright:test diff --git a/apps/laboratory/.env.example b/apps/laboratory/.env.example index 3a79f1b520..a899974f52 100644 --- a/apps/laboratory/.env.example +++ b/apps/laboratory/.env.example @@ -1,3 +1,4 @@ # Obtain a project ID from https://cloud.walletconnect.com NEXT_PUBLIC_PROJECT_ID="" NEXTAUTH_SECRET="" +MAILSAC_API_KEY="" diff --git a/apps/laboratory/package.json b/apps/laboratory/package.json index e21161edb6..e877714719 100644 --- a/apps/laboratory/package.json +++ b/apps/laboratory/package.json @@ -30,6 +30,7 @@ "devDependencies": { "@playwright/test": "1.40.1", "dotenv": "16.3.1", - "ethers": "6.9.0" + "ethers": "6.9.0", + "@mailsac/api": "1.0.5" } } diff --git a/apps/laboratory/playwright.config.ts b/apps/laboratory/playwright.config.ts index 6231eb6133..f2542e3cbc 100644 --- a/apps/laboratory/playwright.config.ts +++ b/apps/laboratory/playwright.config.ts @@ -3,14 +3,14 @@ import { BASE_URL } from './tests/shared/constants' import { config } from 'dotenv' import type { ModalFixture } from './tests/shared/fixtures/w3m-fixture' -config({ path: './.env.local' }) +config({ path: './.env' }) export default defineConfig({ testDir: './tests', fullyParallel: true, - retries: process.env['CI'] ? 2 : 0, - workers: process.env['CI'] ? 1 : undefined, + retries: 0, + workers: 1, reporter: [['list'], ['html']], expect: { diff --git a/apps/laboratory/tests/email.spec.ts b/apps/laboratory/tests/email.spec.ts new file mode 100644 index 0000000000..7b8ab13f6a --- /dev/null +++ b/apps/laboratory/tests/email.spec.ts @@ -0,0 +1,57 @@ +import { testMEmail } from './shared/fixtures/w3m-fixture' +import { DeviceRegistrationPage } from './shared/pages/DeviceRegistrationPage' +import { Email } from './shared/utils/email' + +// Prevent collissions by using a semi-random reserved Mailsac email +const AVAILABLE_MAILSAC_ADDRESSES = 10 + +testMEmail.beforeEach(async ({ modalPage, context, modalValidator }) => { + // Skip wagmi as it's not working + if (modalPage.library === 'wagmi') { + return + } + // This is prone to collissions and will be improved later + const tempEmail = `web3modal${Math.floor( + Math.random() * AVAILABLE_MAILSAC_ADDRESSES + )}@mailsac.com` + const mailsacApiKey = process.env['MAILSAC_API_KEY'] + if (!mailsacApiKey) { + throw new Error('MAILSAC_API_KEY is not set') + } + const email = new Email(mailsacApiKey) + await email.deleteAllMessages(tempEmail) + await modalPage.loginWithEmail(tempEmail) + + let latestMessage = await email.getNewMessage(tempEmail) + let messageId = latestMessage._id + + if (!messageId) { + throw new Error('No messageId found') + } + + let otp = await email.getCodeFromEmail(tempEmail, messageId) + + if (otp.length !== 6) { + // We got a device registration link so let's register first + const drp = new DeviceRegistrationPage(await context.newPage(), otp) + drp.load() + await drp.approveDevice() + + latestMessage = await email.getNewMessage(tempEmail) + messageId = latestMessage._id + if (!messageId) { + throw new Error('No messageId found') + } + otp = await email.getCodeFromEmail(tempEmail, messageId) + } + + await modalPage.enterOTP(otp) + await modalValidator.expectConnected() +}) + +testMEmail('it should sign', async ({ modalPage, modalValidator }) => { + testMEmail.skip(modalPage.library === 'wagmi', 'Tests are flaky on wagmi') + await modalPage.sign() + await modalPage.approveSign() + await modalValidator.expectAcceptedSign() +}) diff --git a/apps/laboratory/tests/shared/fixtures/w3m-fixture.ts b/apps/laboratory/tests/shared/fixtures/w3m-fixture.ts index 68e49bb8a2..0604268021 100644 --- a/apps/laboratory/tests/shared/fixtures/w3m-fixture.ts +++ b/apps/laboratory/tests/shared/fixtures/w3m-fixture.ts @@ -34,4 +34,16 @@ export const testMSiwe = base.extend({ await use(modalValidator) } }) +export const testMEmail = base.extend({ + library: ['wagmi', { option: true }], + modalPage: async ({ page, library }, use) => { + const modalPage = new ModalPage(page, library, 'email') + await modalPage.load() + await use(modalPage) + }, + modalValidator: async ({ modalPage }, use) => { + const modalValidator = new ModalValidator(modalPage.page) + await use(modalValidator) + } +}) export { expect } from '@playwright/test' diff --git a/apps/laboratory/tests/shared/pages/DeviceRegistrationPage.ts b/apps/laboratory/tests/shared/pages/DeviceRegistrationPage.ts new file mode 100644 index 0000000000..6afd0b48c0 --- /dev/null +++ b/apps/laboratory/tests/shared/pages/DeviceRegistrationPage.ts @@ -0,0 +1,16 @@ +import type { Page } from '@playwright/test' + +export class DeviceRegistrationPage { + constructor( + public readonly page: Page, + public readonly url: string + ) {} + + async load() { + await this.page.goto(this.url) + } + + async approveDevice() { + await this.page.getByRole('button', { name: 'Approve' }).click() + } +} diff --git a/apps/laboratory/tests/shared/pages/ModalPage.ts b/apps/laboratory/tests/shared/pages/ModalPage.ts index 10a94e3f03..59d6e47382 100644 --- a/apps/laboratory/tests/shared/pages/ModalPage.ts +++ b/apps/laboratory/tests/shared/pages/ModalPage.ts @@ -1,7 +1,8 @@ import type { Locator, Page } from '@playwright/test' +import { expect } from '@playwright/test' import { BASE_URL } from '../constants' -export type ModalFlavor = 'default' | 'siwe' +export type ModalFlavor = 'default' | 'siwe' | 'email' export class ModalPage { private readonly baseURL = BASE_URL @@ -16,9 +17,9 @@ export class ModalPage { ) { this.connectButton = this.page.getByTestId('connect-button') this.url = - flavor === 'siwe' - ? `${this.baseURL}library/${this.library}-siwe/` - : `${this.baseURL}library/${this.library}/` + flavor === 'default' + ? `${this.baseURL}library/${this.library}/` + : `${this.baseURL}library/${this.library}-${this.flavor}/` } async load() { @@ -33,6 +34,35 @@ export class ModalPage { await this.page.getByTestId('copy-wc2-uri').click() } + async loginWithEmail(email: string) { + await this.page.goto(this.url) + // Connect Button doesn't have a proper `disabled` attribute so we need to wait for the button to change the text + await this.page + .getByTestId('connect-button') + .getByRole('button', { name: 'Connect Wallet' }) + .click() + await this.page.getByTestId('wui-email-input').locator('input').focus() + await this.page.getByTestId('wui-email-input').locator('input').fill(email) + await this.page.getByTestId('wui-email-input').locator('input').press('Enter') + } + + async enterOTP(otp: string) { + const splitted = otp.split('') + // eslint-disable-next-line no-plusplus + for (let i = 0; i < splitted.length; i++) { + const digit = splitted[i] + if (!digit) { + throw new Error('Invalid OTP') + } + /* eslint-disable no-await-in-loop */ + await this.page.getByTestId('wui-otp-input').locator('input').nth(i).focus() + /* eslint-disable no-await-in-loop */ + await this.page.getByTestId('wui-otp-input').locator('input').nth(i).fill(digit) + } + + await expect(this.page.getByText('Confirm Email')).not.toBeVisible() + } + async disconnect() { await this.page.getByTestId('account-button').click() await this.page.getByTestId('disconnect-button').click() @@ -42,6 +72,17 @@ export class ModalPage { await this.page.getByTestId('sign-message-button').click() } + async approveSign() { + await expect( + this.page.frameLocator('#w3m-iframe').getByText('requests a signature') + ).toBeVisible() + await this.page.waitForTimeout(2000) + await this.page + .frameLocator('#w3m-iframe') + .getByRole('button', { name: 'Sign', exact: true }) + .click() + } + async promptSiwe() { await this.page.getByTestId('w3m-connecting-siwe-sign').click() } diff --git a/apps/laboratory/tests/shared/utils/email.ts b/apps/laboratory/tests/shared/utils/email.ts new file mode 100644 index 0000000000..565bff58e4 --- /dev/null +++ b/apps/laboratory/tests/shared/utils/email.ts @@ -0,0 +1,67 @@ +import { Mailsac, type EmailMessage } from '@mailsac/api' + +export class Email { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private readonly mailsac: Mailsac + private messageCount: number + constructor(public readonly apiKey: string) { + this.mailsac = new Mailsac({ headers: { 'Mailsac-Key': apiKey } }) + this.messageCount = 0 + } + + async deleteAllMessages(email: string) { + this.messageCount = 0 + + return await this.mailsac.messages.deleteAllMessages(email) + } + + async getNewMessage(email: string) { + const timeout = new Promise((_, reject) => { + setTimeout(() => reject(new Error('Timeout waiting for email')), 15000) + }) + + const messagePoll = new Promise(resolve => { + const interval = setInterval(async () => { + const messages = await this.mailsac.messages.listMessages(email) + if (messages.data.length > 0 && messages.data.length > this.messageCount) { + clearInterval(interval) + this.messageCount = messages.data.length + const message = messages.data[0] + + if (!message) { + throw new Error('No message found') + } + + return resolve(message) + } + + return undefined + }, 500) + }) + + return Promise.any([timeout, messagePoll]) + } + + async getCodeFromEmail(email: string, messageId: string) { + const result = await this.mailsac.messages.getBodyPlainText(email, messageId) + + if (result.data.includes('Approve this login')) { + // Get the register.web3modal.com device registration URL + const regex = /https:\/\/register.*/u + const match = result.data.match(regex) + if (match) { + return match[0] + } + + throw new Error(`No url found in email: ${result.data}`) + } + + const otpRegex = /\d{3}\s?\d{3}/u + const match = result.data.match(otpRegex) + if (match) { + return match[0].replace(/\s/gu, '') + } + + throw new Error(`No code found in email: ${result.data}`) + } +} diff --git a/package-lock.json b/package-lock.json index 48494a638e..954d37ff0b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5834,6 +5834,14 @@ "@lit-labs/ssr-dom-shim": "^1.1.2" } }, + "node_modules/@mailsac/api": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@mailsac/api/-/api-1.0.5.tgz", + "integrity": "sha512-EbqJun6pMCMlDpEY5VmsAlhMJ/ZjofQBUM2TX4p6IKFGaJ3oMBXSbFURu07gXCRRGE97CDUULixww7MoREpx9A==", + "dependencies": { + "axios": "^1.6.0" + } + }, "node_modules/@mdx-js/react": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/@mdx-js/react/-/react-2.3.0.tgz", @@ -13074,7 +13082,6 @@ "version": "1.6.3", "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.3.tgz", "integrity": "sha512-fWyNdeawGam70jXSVlKl+SUNVcL6j6W79CuSIPfi6HnDUmSCH6gyUys/HrqHeA/wU0Az41rRgean494d0Jb+ww==", - "dev": true, "dependencies": { "follow-redirects": "^1.15.0", "form-data": "^4.0.0", diff --git a/packages/ui/src/composites/wui-email-input/index.ts b/packages/ui/src/composites/wui-email-input/index.ts index ed22f99875..6254ed05eb 100644 --- a/packages/ui/src/composites/wui-email-input/index.ts +++ b/packages/ui/src/composites/wui-email-input/index.ts @@ -27,6 +27,7 @@ export class WuiEmailInput extends LitElement { size="md" .disabled=${this.disabled} .value=${this.value} + data-testid="wui-email-input" > ${this.templateError()} ` diff --git a/packages/ui/src/composites/wui-otp/index.ts b/packages/ui/src/composites/wui-otp/index.ts index 396457163a..92479b2fef 100644 --- a/packages/ui/src/composites/wui-otp/index.ts +++ b/packages/ui/src/composites/wui-otp/index.ts @@ -30,7 +30,7 @@ export class WuiOtp extends LitElement { // -- Render -------------------------------------------- // public override render() { return html` - + ${Array.from({ length: this.length }).map( (_, index: number) => html`