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 (
- } onClick={authenticate}>
+ }
+ onClick={authenticate}
+ aria-label="Google sign in"
+ >
Google
);
case "facebook":
return (
- } onClick={authenticate}>
+ }
+ onClick={authenticate}
+ aria-label="Facebook sign in"
+ >
Facebook
);
case "apple":
return (
- } onClick={authenticate}>
+ }
+ onClick={authenticate}
+ aria-label="Apple sign in"
+ >
Apple
);
@@ -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 (