diff --git a/account-kit/react/.storybook/main.ts b/account-kit/react/.storybook/main.ts index f097eeeafe..2dbdb5d8fd 100644 --- a/account-kit/react/.storybook/main.ts +++ b/account-kit/react/.storybook/main.ts @@ -1,12 +1,16 @@ +import { dirname, join } from "path"; import type { StorybookConfig } from "@storybook/react-vite"; import react from "@vitejs/plugin-react"; import { mergeConfig } from "vite"; const config: StorybookConfig = { stories: ["../src/**/*.@(mdx|stories.@(js|jsx|ts|tsx))"], - addons: ["@storybook/addon-essentials", "@storybook/addon-interactions"], + addons: [ + getAbsolutePath("@storybook/addon-essentials"), + getAbsolutePath("@storybook/addon-interactions"), + ], framework: { - name: "@storybook/react-vite", + name: getAbsolutePath("@storybook/react-vite"), options: {}, }, @@ -18,3 +22,7 @@ const config: StorybookConfig = { }; export default config; + +function getAbsolutePath(value: string): any { + return dirname(require.resolve(join(value, "package.json"))); +} diff --git a/account-kit/react/package.json b/account-kit/react/package.json index c81d52d11b..8f2beebcc6 100644 --- a/account-kit/react/package.json +++ b/account-kit/react/package.json @@ -47,22 +47,21 @@ "test:run": "vitest run --passWithNoTests" }, "devDependencies": { - "@storybook/addon-essentials": "^8.2.8", - "@storybook/addon-interactions": "^8.4.4", - "@storybook/core-server": "^8.2.8", + "@storybook/addon-essentials": "^8.5.3", + "@storybook/addon-interactions": "^8.5.3", + "@storybook/core-server": "^8.5.3", "@storybook/jest": "^0.2.3", - "@storybook/react-vite": "^8.2.8", - "@storybook/test": "^8.4.4", - "@storybook/test-runner": "^0.13.0", + "@storybook/react-vite": "^8.5.3", + "@storybook/test": "^8.5.3", "@storybook/testing-library": "^0.2.2", "@tanstack/react-query": "^5.28.9", "autoprefixer": "^10.4.20", "msw": "^2.4.4", - "msw-storybook-addon": "^2.0.3", + "msw-storybook-addon": "^2.0.4", "postcss": "^8.4.45", "react": "^18.2.0", "react-dom": "^18.2.0", - "storybook": "^8.2.8", + "storybook": "^8.5.3", "typescript": "^5.0.4", "typescript-template": "*", "vitest": "^2.0.4" diff --git a/account-kit/react/src/components/auth/sections/OAuth.tsx b/account-kit/react/src/components/auth/sections/OAuth.tsx index 38c410e969..a829b7ef62 100644 --- a/account-kit/react/src/components/auth/sections/OAuth.tsx +++ b/account-kit/react/src/components/auth/sections/OAuth.tsx @@ -19,19 +19,34 @@ export const OAuth = memo(({ ...config }: Props) => { switch (config.authProviderId) { case "google": return ( - ); case "facebook": return ( - ); case "apple": return ( - ); @@ -55,6 +70,7 @@ export const OAuth = memo(({ ...config }: Props) => { } onClick={authenticate} + aria-label={`${config.displayName} sign in`} > {config.displayName} diff --git a/examples/ui-demo/.gitignore b/examples/ui-demo/.gitignore index fd3dbb571a..3dd5613f59 100644 --- a/examples/ui-demo/.gitignore +++ b/examples/ui-demo/.gitignore @@ -34,3 +34,9 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + +# Playwright +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ diff --git a/examples/ui-demo/e2e/smart-contract-mint.spec.ts b/examples/ui-demo/e2e/smart-contract-mint.spec.ts new file mode 100644 index 0000000000..55b2ae044c --- /dev/null +++ b/examples/ui-demo/e2e/smart-contract-mint.spec.ts @@ -0,0 +1,74 @@ +import { test, expect } from "@playwright/test"; + +const googleEmail = process.env.PLAYWRIGHT_GOOGLE_EMAIL; +const googlePassword = process.env.PLAYWRIGHT_GOOGLE_PASSWORD; + +test.beforeEach(async ({ page, baseURL }) => { + await page.goto(baseURL!); +}); +test("Google sign in", async ({ page }) => { + if (!googleEmail || !googlePassword) { + throw new Error( + "PLAYWRIGHT_GOOGLE_EMAIL and PLAYWRIGHT_GOOGLE_PASSWORD must be set" + ); + } + await expect(page).toHaveTitle(/Account Kit/); + // Enabling and disabling email to ensure config is loaded + // TODO: find a better way to determine init complete. + await page.getByRole("switch", { name: "Email" }).click(); + await page.getByRole("switch", { name: "Email" }).click(); + await page.locator("button[aria-label='Google sign in']").click(); + const pagePromise = page.waitForEvent("popup"); + const popup = await pagePromise; + await popup.waitForLoadState("networkidle"); + const emailInput = await popup.getByRole("textbox"); + + await emailInput.fill(googleEmail); + await popup.getByRole("button", { name: /Next/i }).click(); + await expect(popup.getByText(/Enter your password/i)).toBeVisible(); + const passwordInput = await popup.locator("input[type='password']:visible"); + await passwordInput.fill(googlePassword); + await popup.getByRole("button", { name: /Next/i }).click(); + + // Wait for the page to load after sign in + await expect(page.getByText(/One-click checkout/i).first()).toBeVisible(); + const avatar = await page.getByRole("button", { + name: `Hello, ${googleEmail}`, + }); + expect(avatar).toBeVisible(); + await page.locator("img[alt='An NFT']"); + + // Collect NFT + await page.getByRole("button", { name: "Collect NFT" }).click(); + await expect(await page.getByText("Success", { exact: true })).toBeVisible({ + timeout: 30000, + }); + + // Check external links + await expect( + page.getByRole("link", { name: "View transaction" }) + ).toBeVisible(); + await expect( + await page.getByRole("link", { name: "Build with Account kit" }) + ).toHaveAttribute( + "href", + "https://dashboard.alchemy.com/accounts?utm_source=demo_alchemy_com&utm_medium=referral&utm_campaign=demo_to_dashboard" + ); + await expect( + await page.getByRole("link", { name: "Learn how." }) + ).toHaveAttribute("href", "https://accountkit.alchemy.com/react/sponsor-gas"); + await expect( + await page.getByRole("link", { name: "View docs" }) + ).toHaveAttribute("href", "https://accountkit.alchemy.com/react/quickstart"); + await expect( + await page.getByRole("link", { name: "Quickstart" }) + ).toHaveAttribute("href", "https://accountkit.alchemy.com/react/quickstart"); + await expect(await page.locator("a[aria-label='GitHub']")).toHaveAttribute( + "href", + "https://github.com/alchemyplatform/aa-sdk/tree/v4.x.x" + ); + await expect(await page.getByRole("link", { name: "CSS" })).toHaveAttribute( + "href", + "https://github.com/alchemyplatform/aa-sdk/blob/v4.x.x/account-kit/react/src/tailwind/types.ts#L6" + ); +}); diff --git a/examples/ui-demo/e2e/ui-demo-config.spec.ts b/examples/ui-demo/e2e/ui-demo-config.spec.ts new file mode 100644 index 0000000000..9658863ccc --- /dev/null +++ b/examples/ui-demo/e2e/ui-demo-config.spec.ts @@ -0,0 +1,174 @@ +import { test, expect } from "@playwright/test"; +import path from "path"; +test.beforeEach(async ({ page, baseURL }) => { + await page.goto(baseURL!); +}); +test("Toggle auth methods", async ({ page }) => { + // EMAIL + const emailInput = page.getByPlaceholder("Email", { exact: true }); + const emailToggle = page.getByRole("switch", { name: /email/i }); + await expect(emailInput).toBeVisible(); + await expect(emailToggle).toBeChecked(); + await emailToggle.click(); + await expect(emailToggle).not.toBeChecked(); + await expect(emailInput).not.toBeVisible(); + + // PASSKEY + const passkeyToggle = page.getByRole("switch", { name: /passkeys/i }); + const passkeyButton = page.getByRole("button", { name: "I have a passkey" }); + await expect(passkeyToggle).toBeChecked(); + await expect(passkeyButton).toBeVisible(); + await passkeyToggle.click(); + await expect(passkeyToggle).not.toBeChecked(); + await expect(passkeyButton).not.toBeVisible(); + + // SOCIAL + const socialAuthToggle = page.getByRole("switch", { name: /social/i }); + + const googleButton = page.locator("button[aria-label='Google sign in']"); + const googleAuthToggle = page.locator( + "button[aria-label='Google social authentication toggle']" + ); + + const facebookButton = page.locator("button[aria-label='Facebook sign in']"); + const facebookAuthToggle = page.locator( + "button[aria-label='Facebook social authentication toggle']" + ); + + const discordButton = page.locator("button[aria-label='Discord sign in']"); + const discordAuthToggle = page.locator( + "button[aria-label='Discord social authentication toggle']" + ); + + const twitterButton = page.locator("button[aria-label='Twitter sign in']"); + const twitterAuthToggle = page.locator( + "button[aria-label='Twitter social authentication toggle']" + ); + + await expect(socialAuthToggle).toBeChecked(); + await expect(googleButton).toBeVisible(); + await expect(facebookButton).toBeVisible(); + await expect(discordButton).toBeVisible(); + await expect(twitterButton).toBeVisible(); + + await socialAuthToggle.click(); + await expect(socialAuthToggle).not.toBeChecked(); + await expect(googleButton).not.toBeVisible(); + await expect(facebookButton).not.toBeVisible(); + await expect(discordButton).not.toBeVisible(); + await expect(twitterButton).not.toBeVisible(); + + await socialAuthToggle.click(); + + await expect(googleButton).toBeVisible(); + await expect(facebookButton).toBeVisible(); + await expect(discordButton).toBeVisible(); + await expect(twitterButton).toBeVisible(); + + await googleAuthToggle.click(); + await expect(googleButton).not.toBeVisible(); + + await facebookAuthToggle.click(); + await expect(facebookButton).not.toBeVisible(); + + await discordAuthToggle.click(); + await expect(discordButton).not.toBeVisible(); + + await twitterAuthToggle.click(); + await expect(twitterButton).not.toBeVisible(); + + // EXTERNAL WALLET + const externalWalletToggle = page.getByRole("switch", { + name: /external wallets/i, + }); + const externalWalletButton = page.getByRole("button", { + name: "Continue with a wallet", + }); + await expect(externalWalletToggle).toBeChecked(); + await expect(externalWalletButton).toBeVisible(); + + await externalWalletToggle.click(); + await expect(externalWalletToggle).not.toBeChecked(); + await expect(externalWalletButton).not.toBeVisible(); + + await externalWalletToggle.click(); + await expect(externalWalletToggle).toBeChecked(); + await expect(externalWalletButton).toBeVisible(); +}); + +test("Branding config", async ({ page }) => { + // Dark mode + const darkModeToggle = page.locator("button[id='theme-switch']"); + await expect(darkModeToggle).not.toBeChecked(); + await expect(page.locator(".bg-bg-surface-default")).toHaveCSS( + "background-color", + "rgb(255, 255, 255)" + ); + darkModeToggle.click(); + await expect(darkModeToggle).toBeChecked(); + await expect(page.locator("html")).toHaveClass("dark"); + await expect(page.locator(".bg-bg-surface-default")).toHaveCSS( + "background-color", + "rgb(2, 6, 23)" + ); + // Brand color + await expect(page.locator(".akui-btn-primary").first()).toHaveCSS( + "background-color", + "rgb(255, 102, 204)" + ); + const brandColorButton = page.locator("button[id='color-picker']"); + await brandColorButton.click(); + const colorPicker = page + .locator("div") + .filter({ hasText: /^Hex$/ }) + .getByRole("textbox"); + await colorPicker.fill("#000000"); + await expect(page.locator(".akui-btn-primary").first()).toHaveCSS( + "background-color", + "rgb(0, 0, 0)" + ); + // Logo + const logoInput = page.locator('input[type="file"]'); + // TODO: validate this in CI/CD + const logoPath = path.join(__dirname, "../public/next.svg"); + await logoInput.setInputFiles(logoPath); + await expect(page.getByRole("img", { name: "next.svg" })).toBeVisible(); + await page.locator("button[id='logo-remove']").click(); + await expect(page.getByRole("img", { name: "next.svg" })).not.toBeVisible(); + // Border radius + await expect(page.locator(".radius-2").first()).toHaveCSS( + "border-radius", + "16px" + ); + await page.getByRole("button", { name: "Medium" }).click(); + await expect(page.locator(".radius-2").first()).toHaveCSS( + "border-radius", + "32px" + ); + await page.getByRole("button", { name: "Large" }).click(); + await expect(page.locator(".radius-2").first()).toHaveCSS( + "border-radius", + "48px" + ); + await page.getByRole("button", { name: "None" }).click(); + await expect(page.locator(".radius-2").first()).toHaveCSS( + "border-radius", + "0px" + ); +}); + +test("code preview", async ({ page }) => { + const codePreviewSwitch = page.getByRole("switch", { name: "Code preview" }); + await expect(codePreviewSwitch).not.toBeChecked(); + await codePreviewSwitch.click(); + await expect(codePreviewSwitch).toBeChecked(); + await expect(page.getByText("Export configuration")).toBeVisible(); + await expect( + page.getByRole("link", { name: "Fully customize styling here." }) + ).toHaveAttribute( + "href", + "https://accountkit.alchemy.com/react/customization/theme" + ); + await codePreviewSwitch.click(); + await expect(codePreviewSwitch).not.toBeChecked(); +}); diff --git a/examples/ui-demo/package.json b/examples/ui-demo/package.json index 19af52f773..4013bad594 100644 --- a/examples/ui-demo/package.json +++ b/examples/ui-demo/package.json @@ -7,7 +7,9 @@ "build": "next build", "start": "next start", "lint": "next lint", - "lint:write": "prettier --no-ignore --write ./src" + "lint:write": "prettier --no-ignore --write ./src", + "test:e2e": "playwright test", + "playwright": "playwright test --ui" }, "dependencies": { "@account-kit/core": "^4.2.0", @@ -40,6 +42,7 @@ "zustand": "^5.0.0-rc.2" }, "devDependencies": { + "@playwright/test": "^1.50.1", "@types/node": "^20", "@types/react": "^18", "@types/react-dom": "^18", diff --git a/examples/ui-demo/playwright.config.ts b/examples/ui-demo/playwright.config.ts new file mode 100644 index 0000000000..8077e6fc4e --- /dev/null +++ b/examples/ui-demo/playwright.config.ts @@ -0,0 +1,81 @@ +import { defineConfig, devices } from "@playwright/test"; + +import dotenv from "dotenv"; +import path from "path"; +dotenv.config({ path: path.resolve(__dirname, ".env") }); + +/** + * See https://playwright.dev/docs/test-configuration. + */ + +export default defineConfig({ + testDir: "./e2e", + /* Run tests in files in parallel */ + fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: process.env.CI ? 2 : 0, + /* Opt out of parallel tests on CI. */ + workers: process.env.CI ? 1 : undefined, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: "html", + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Base URL to use in actions like `await page.goto('/')`. */ + baseURL: process.env.PLAYWRIGHT_BASE_URL, + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: "on-first-retry", + }, + + /* Configure projects for major browsers */ + projects: [ + // { name: "setup", testMatch: /.*\.setup\.ts/ }, + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + // dependencies: ["setup"], + }, + + // { + // name: "firefox", + // use: { ...devices["Desktop Firefox"] }, + // }, + + // { + // name: "webkit", + // use: { ...devices["Desktop Safari"] }, + // }, + + /* Test against mobile viewports. */ + // { + // name: 'Mobile Chrome', + // use: { ...devices['Pixel 5'] }, + // }, + // { + // name: 'Mobile Safari', + // use: { ...devices['iPhone 12'] }, + // }, + + /* Test against branded browsers. */ + // { + // name: 'Microsoft Edge', + // use: { ...devices['Desktop Edge'], channel: 'msedge' }, + // }, + // { + // name: 'Google Chrome', + // use: { ...devices['Desktop Chrome'], channel: 'chrome' }, + // }, + ], + + /* Run your local dev server before starting the tests */ + // webServer: { + // command: 'npm run start', + // url: 'http://127.0.0.1:3000', + // reuseExistingServer: !process.env.CI, + // }, +}); + +// alchemy.test.rc@gmail.com +// TestTest diff --git a/examples/ui-demo/src/components/configuration/Authentication.tsx b/examples/ui-demo/src/components/configuration/Authentication.tsx index 80dc9e621a..df299d861c 100644 --- a/examples/ui-demo/src/components/configuration/Authentication.tsx +++ b/examples/ui-demo/src/components/configuration/Authentication.tsx @@ -157,21 +157,25 @@ export const Authentication = ({ className }: { className?: string }) => { active={auth.oAuthMethods.google} icon={} onClick={setAddGoogleAuth} + name="Google" /> } onClick={setAddFacebookAuth} + name="Facebook" /> } onClick={setAddDiscordAuth} + name="Discord" /> } onClick={setAddTwitterAuth} + name="Twitter" />
@@ -316,15 +320,18 @@ const OAuthMethod = ({ icon, onClick, active, + name, }: { icon: React.ReactNode; onClick: () => void; className?: string; active: boolean; + name: string; }) => { return (