diff --git a/.changeset/yummy-hoops-drum.md b/.changeset/yummy-hoops-drum.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/yummy-hoops-drum.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index da6b80dcfed..a3e508eefca 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -306,6 +306,7 @@ jobs: "react-router", "custom", "hono", + "chrome-extension", ] test-project: ["chrome"] include: diff --git a/integration/playwright.chrome-extension.config.ts b/integration/playwright.chrome-extension.config.ts new file mode 100644 index 00000000000..e79c33ef17d --- /dev/null +++ b/integration/playwright.chrome-extension.config.ts @@ -0,0 +1,21 @@ +import { defineConfig } from '@playwright/test'; +import { config } from 'dotenv'; +import * as path from 'path'; + +import { common } from './playwright.config'; + +config({ path: path.resolve(__dirname, '.env.local') }); + +export default defineConfig({ + ...common, + testDir: './tests/chrome-extension', + // No global setup/teardown — extension build is handled by worker-scoped fixtures + projects: [ + { + name: 'chrome-extension', + // Extension loading uses chromium.launchPersistentContext in fixtures + // with --load-extension flags. No channel override needed — Playwright's + // bundled Chromium supports extensions when launched this way. + }, + ], +}); diff --git a/integration/presets/chrome-extension.ts b/integration/presets/chrome-extension.ts new file mode 100644 index 00000000000..9e56c32d398 --- /dev/null +++ b/integration/presets/chrome-extension.ts @@ -0,0 +1,19 @@ +import { applicationConfig } from '../models/applicationConfig'; +import { templates } from '../templates'; +import { PKGLAB } from './utils'; + +const vite = applicationConfig() + .setName('chrome-extension-vite') + .useTemplate(templates['chrome-extension-vite']) + .setEnvFormatter('public', key => `VITE_${key}`) + .addScript('setup', 'pnpm install') + .addScript('dev', 'pnpm build') + .addScript('build', 'pnpm build') + .addScript('serve', 'echo noop') + .addDependency('@clerk/chrome-extension', PKGLAB) + .addDependency('@clerk/clerk-js', PKGLAB) + .addDependency('@clerk/ui', PKGLAB); + +export const chromeExtension = { + vite, +} as const; diff --git a/integration/presets/index.ts b/integration/presets/index.ts index 83c27057a82..f67f3b36385 100644 --- a/integration/presets/index.ts +++ b/integration/presets/index.ts @@ -1,4 +1,5 @@ import { astro } from './astro'; +import { chromeExtension } from './chrome-extension'; import { customFlows } from './custom-flows'; import { envs, instanceKeys } from './envs'; import { expo } from './expo'; @@ -14,6 +15,7 @@ import { tanstack } from './tanstack'; import { vue } from './vue'; export const appConfigs = { + chromeExtension, customFlows, envs, express, diff --git a/integration/templates/chrome-extension-vite/manifest.json b/integration/templates/chrome-extension-vite/manifest.json new file mode 100644 index 00000000000..5ec734c4e29 --- /dev/null +++ b/integration/templates/chrome-extension-vite/manifest.json @@ -0,0 +1,17 @@ +{ + "manifest_version": 3, + "name": "Clerk Test Extension", + "version": "1.0.0", + "action": { + "default_popup": "popup.html" + }, + "permissions": ["storage", "cookies"], + "host_permissions": ["http://localhost/*"], + "background": { + "service_worker": "background.js", + "type": "module" + }, + "content_security_policy": { + "extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'" + } +} diff --git a/integration/templates/chrome-extension-vite/package.json b/integration/templates/chrome-extension-vite/package.json new file mode 100644 index 00000000000..634e4322417 --- /dev/null +++ b/integration/templates/chrome-extension-vite/package.json @@ -0,0 +1,24 @@ +{ + "name": "chrome-extension-vite", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "build": "vite build && vite build --config vite.background.config.ts && cp manifest.json dist/manifest.json" + }, + "dependencies": { + "react": "18.3.1", + "react-dom": "18.3.1" + }, + "devDependencies": { + "@types/chrome": "^0.0.268", + "@types/react": "18.3.12", + "@types/react-dom": "18.3.1", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "^5.7.3", + "vite": "^4.3.9" + }, + "engines": { + "node": ">=20.9.0" + } +} diff --git a/integration/templates/chrome-extension-vite/popup.html b/integration/templates/chrome-extension-vite/popup.html new file mode 100644 index 00000000000..c1766aa54c9 --- /dev/null +++ b/integration/templates/chrome-extension-vite/popup.html @@ -0,0 +1,12 @@ + + + + + + Clerk Test Extension + + +
+ + + diff --git a/integration/templates/chrome-extension-vite/src/background.ts b/integration/templates/chrome-extension-vite/src/background.ts new file mode 100644 index 00000000000..b0b879cff5c --- /dev/null +++ b/integration/templates/chrome-extension-vite/src/background.ts @@ -0,0 +1,31 @@ +import { createClerkClient } from '@clerk/chrome-extension/client'; + +const PUBLISHABLE_KEY = (globalThis as any).__CLERK_PUBLISHABLE_KEY__ as string; + +let clerkPromise: Promise | null = null; + +function getClerk() { + if (!clerkPromise) { + clerkPromise = createClerkClient({ + publishableKey: PUBLISHABLE_KEY, + background: true, + }); + } + return clerkPromise; +} + +chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { + if (message.type === 'GET_AUTH') { + getClerk() + .then(clerk => { + sendResponse({ + userId: clerk.user?.id ?? null, + sessionId: clerk.session?.id ?? null, + }); + }) + .catch(err => { + sendResponse({ error: err.message }); + }); + return true; // Keep message channel open for async response + } +}); diff --git a/integration/templates/chrome-extension-vite/src/popup.tsx b/integration/templates/chrome-extension-vite/src/popup.tsx new file mode 100644 index 00000000000..f3385a6d299 --- /dev/null +++ b/integration/templates/chrome-extension-vite/src/popup.tsx @@ -0,0 +1,41 @@ +import { ClerkProvider, Show, SignIn, UserButton, useAuth } from '@clerk/chrome-extension'; +import React from 'react'; +import ReactDOM from 'react-dom/client'; + +const PUBLISHABLE_KEY = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY as string; + +function App() { + return ( + {}} + routerReplace={() => {}} + > +
+ + + + + + + +
+
+ ); +} + +function AuthInfo() { + const { userId, sessionId } = useAuth(); + return ( +
+

{userId}

+

{sessionId}

+
+ ); +} + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + , +); diff --git a/integration/templates/chrome-extension-vite/src/vite-env.d.ts b/integration/templates/chrome-extension-vite/src/vite-env.d.ts new file mode 100644 index 00000000000..11f02fe2a00 --- /dev/null +++ b/integration/templates/chrome-extension-vite/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/integration/templates/chrome-extension-vite/tsconfig.json b/integration/templates/chrome-extension-vite/tsconfig.json new file mode 100644 index 00000000000..42e05216900 --- /dev/null +++ b/integration/templates/chrome-extension-vite/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true + }, + "include": ["src"] +} diff --git a/integration/templates/chrome-extension-vite/vite.background.config.ts b/integration/templates/chrome-extension-vite/vite.background.config.ts new file mode 100644 index 00000000000..828f7c38981 --- /dev/null +++ b/integration/templates/chrome-extension-vite/vite.background.config.ts @@ -0,0 +1,25 @@ +import { defineConfig, loadEnv } from 'vite'; +import { resolve } from 'node:path'; + +export default defineConfig(({ mode }) => { + const env = loadEnv(mode, process.cwd(), ''); + + return { + build: { + rollupOptions: { + input: resolve(__dirname, 'src/background.ts'), + output: { + entryFileNames: 'background.js', + format: 'es', + // Prevent code splitting — background must be a single file + manualChunks: undefined, + }, + }, + outDir: 'dist', + emptyOutDir: false, + }, + define: { + 'globalThis.__CLERK_PUBLISHABLE_KEY__': JSON.stringify(env.VITE_CLERK_PUBLISHABLE_KEY || ''), + }, + }; +}); diff --git a/integration/templates/chrome-extension-vite/vite.config.ts b/integration/templates/chrome-extension-vite/vite.config.ts new file mode 100644 index 00000000000..b43ced2bc3d --- /dev/null +++ b/integration/templates/chrome-extension-vite/vite.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import { resolve } from 'node:path'; + +export default defineConfig({ + plugins: [react()], + define: { + // Chrome extensions don't have `global` — alias it to globalThis + global: 'globalThis', + }, + build: { + rollupOptions: { + input: { + popup: resolve(__dirname, 'popup.html'), + }, + }, + outDir: 'dist', + }, +}); diff --git a/integration/templates/index.ts b/integration/templates/index.ts index d073d7fa58b..aa5ede85b08 100644 --- a/integration/templates/index.ts +++ b/integration/templates/index.ts @@ -24,6 +24,7 @@ export const templates = { 'react-router-node': resolve(__dirname, './react-router-node'), 'react-router-library': resolve(__dirname, './react-router-library'), 'custom-flows-react-vite': resolve(__dirname, './custom-flows-react-vite'), + 'chrome-extension-vite': resolve(__dirname, './chrome-extension-vite'), } as const; if (new Set([...Object.values(templates)]).size !== Object.values(templates).length) { diff --git a/integration/tests/chrome-extension/background.test.ts b/integration/tests/chrome-extension/background.test.ts new file mode 100644 index 00000000000..c29639576f1 --- /dev/null +++ b/integration/tests/chrome-extension/background.test.ts @@ -0,0 +1,45 @@ +import { clerk } from '@clerk/testing/playwright'; + +import { appConfigs } from '../../presets'; +import type { FakeUser } from '../../testUtils/usersService'; +import { test, expect } from './fixtures'; +import { createTestUser, getAuthFromBackground } from './helpers'; + +test.describe('chrome extension background service worker @chrome-extension', () => { + test.describe.configure({ mode: 'serial' }); + + const env = appConfigs.envs.withEmailCodes; + let fakeUser: FakeUser; + + test.beforeAll(async () => { + fakeUser = await createTestUser(env); + }); + + test.afterAll(async () => { + await fakeUser.deleteIfExists(); + }); + + test('background service worker receives auth state after sign in', async ({ extensionPage }) => { + await clerk.signIn({ + page: extensionPage, + signInParams: { strategy: 'password', identifier: fakeUser.email, password: fakeUser.password }, + }); + + const authState = await getAuthFromBackground(extensionPage); + + expect(authState.userId).toBeTruthy(); + expect(authState.userId).toMatch(/^user_/); + expect(authState.sessionId).toBeTruthy(); + expect(authState.sessionId).toMatch(/^sess_/); + }); + + test('background service worker returns null auth when signed out', async ({ extensionPage }) => { + // The extension page starts in a fresh context (signed out) + await clerk.loaded({ page: extensionPage }); + + const authState = await getAuthFromBackground(extensionPage); + + expect(authState.userId).toBeNull(); + expect(authState.sessionId).toBeNull(); + }); +}); diff --git a/integration/tests/chrome-extension/basic.test.ts b/integration/tests/chrome-extension/basic.test.ts new file mode 100644 index 00000000000..3f8d3b5d37d --- /dev/null +++ b/integration/tests/chrome-extension/basic.test.ts @@ -0,0 +1,64 @@ +import { clerk } from '@clerk/testing/playwright'; +import { createPageObjects } from '@clerk/testing/playwright/unstable'; + +import { appConfigs } from '../../presets'; +import type { FakeUser } from '../../testUtils/usersService'; +import { expect, test } from './fixtures'; +import { createTestUser } from './helpers'; + +test.describe('chrome extension basic auth @chrome-extension', () => { + test.describe.configure({ mode: 'serial' }); + + const env = appConfigs.envs.withEmailCodes; + let fakeUser: FakeUser; + + test.beforeAll(async () => { + fakeUser = await createTestUser(env); + }); + + test.afterAll(async () => { + await fakeUser.deleteIfExists(); + }); + + test('signs in with email and password', async ({ extensionPage }) => { + const { signIn } = createPageObjects({ page: extensionPage, useTestingToken: false }); + await signIn.waitForMounted(); + await expect(extensionPage.locator('.cl-signIn-root')).toBeVisible(); + + await signIn.setIdentifier(fakeUser.email); + await signIn.continue(); + const passField = signIn.getPasswordInput(); + await passField.waitFor({ state: 'visible' }); + await passField.fill(fakeUser.password); + await signIn.continue(); + + // Wait for signed-in state + await extensionPage.waitForSelector('[data-testid="user-id"]', { timeout: 30_000 }); + + const userId = extensionPage.locator('[data-testid="user-id"]'); + await expect(userId).toHaveText(/^user_/); + }); + + test('shows UserButton when signed in and can sign out', async ({ extensionPage }) => { + const { signIn, userButton } = createPageObjects({ page: extensionPage, useTestingToken: false }); + + await signIn.waitForMounted(); + await signIn.setIdentifier(fakeUser.email); + await signIn.continue(); + const passField = signIn.getPasswordInput(); + await passField.waitFor({ state: 'visible' }); + await passField.fill(fakeUser.password); + await signIn.continue(); + + // Wait for UserButton + await userButton.waitForMounted(); + await expect(extensionPage.locator('.cl-userButtonTrigger')).toBeVisible(); + + // Sign out via Clerk + await clerk.signOut({ page: extensionPage }); + + // Verify we're back to SignIn + await signIn.waitForMounted(); + await expect(extensionPage.locator('.cl-signIn-root')).toBeVisible(); + }); +}); diff --git a/integration/tests/chrome-extension/fixtures.ts b/integration/tests/chrome-extension/fixtures.ts new file mode 100644 index 00000000000..8e8a5a11ce8 --- /dev/null +++ b/integration/tests/chrome-extension/fixtures.ts @@ -0,0 +1,75 @@ +import * as path from 'node:path'; + +import { test as base } from '@playwright/test'; +import type { BrowserContext, Page } from '@playwright/test'; + +import { appConfigs } from '../../presets'; +import type { Application } from '../../models/application'; +import { getExtensionId, launchExtensionContext, setupClerkTestingEnv } from './helpers'; + +type WorkerFixtures = { + extensionDistPath: string; + extensionApp: Application; +}; + +type TestFixtures = { + context: BrowserContext; + extensionId: string; + extensionPage: Page; +}; + +/** + * Custom Playwright test with fixtures for Chrome extension testing. + * + * Worker-scoped fixtures build the extension once per worker. + * Test-scoped fixtures create a fresh persistent context per test. + */ +export const test = base.extend({ + // Worker-scoped: build the extension once and set up testing tokens + extensionApp: [ + async ({}, use) => { + const env = appConfigs.envs.withEmailCodes; + const config = appConfigs.chromeExtension.vite; + + const app = await config.commit(); + await app.withEnv(env); + await app.setup(); + await app.build(); + + await setupClerkTestingEnv(env); + + await use(app); + await app.teardown(); + }, + { scope: 'worker', timeout: 120_000 }, + ], + + extensionDistPath: [ + async ({ extensionApp }, use) => { + const distPath = path.resolve(extensionApp.appDir, 'dist'); + await use(distPath); + }, + { scope: 'worker' }, + ], + + // Test-scoped: fresh persistent context per test with the extension loaded + context: async ({ extensionDistPath }, use) => { + const context = await launchExtensionContext(extensionDistPath, { bypassCSP: true }); + await use(context); + await context.close(); + }, + + extensionId: async ({ context }, use) => { + const extensionId = await getExtensionId(context); + await use(extensionId); + }, + + extensionPage: async ({ context, extensionId }, use) => { + const page = await context.newPage(); + await page.goto(`chrome-extension://${extensionId}/popup.html`); + await use(page); + await page.close(); + }, +}); + +export { expect } from '@playwright/test'; diff --git a/integration/tests/chrome-extension/helpers.ts b/integration/tests/chrome-extension/helpers.ts new file mode 100644 index 00000000000..102f7809f98 --- /dev/null +++ b/integration/tests/chrome-extension/helpers.ts @@ -0,0 +1,91 @@ +import { createClerkClient as backendCreateClerkClient } from '@clerk/backend'; +import { parsePublishableKey } from '@clerk/shared/keys'; +import { clerkSetup, setupClerkTestingToken } from '@clerk/testing/playwright'; +import { chromium } from '@playwright/test'; +import type { BrowserContext } from '@playwright/test'; + +import type { EnvironmentConfig } from '../../models/environment'; +import { createUserService } from '../../testUtils/usersService'; +import type { FakeUser } from '../../testUtils/usersService'; + +/** + * Query the background service worker for auth state via chrome.runtime.sendMessage. + */ +export function getAuthFromBackground( + page: import('@playwright/test').Page, +): Promise<{ userId: string | null; sessionId: string | null }> { + return page.evaluate(() => { + return new Promise(resolve => { + chrome.runtime.sendMessage({ type: 'GET_AUTH' }, (response: any) => { + resolve(response); + }); + }); + }); +} + +/** + * Set up Clerk testing environment (clerkSetup) for extension tests that use build() instead of dev(). + */ +export async function setupClerkTestingEnv(env: EnvironmentConfig) { + const publishableKey = env.publicVariables.get('CLERK_PUBLISHABLE_KEY'); + const secretKey = env.privateVariables.get('CLERK_SECRET_KEY'); + const apiUrl = env.privateVariables.get('CLERK_API_URL'); + + if (publishableKey && secretKey) { + const parsed = parsePublishableKey(publishableKey); + const frontendApiUrl = parsed?.frontendApi; + await clerkSetup({ + publishableKey, + frontendApiUrl, + secretKey, + // @ts-expect-error apiUrl is accepted at runtime + apiUrl, + dotenv: false, + }); + } +} + +/** + * Launch a persistent Chromium context with a Chrome extension loaded. + */ +export async function launchExtensionContext(extensionDistPath: string, opts?: { bypassCSP?: boolean }) { + const context = await chromium.launchPersistentContext('', { + headless: false, + bypassCSP: opts?.bypassCSP, + args: [ + '--headless=new', + `--disable-extensions-except=${extensionDistPath}`, + `--load-extension=${extensionDistPath}`, + ], + }); + + await setupClerkTestingToken({ context }); + return context; +} + +/** + * Extract the extension ID from the service worker registered in the browser context. + */ +export async function getExtensionId(context: BrowserContext) { + let [background] = context.serviceWorkers(); + if (!background) { + background = await context.waitForEvent('serviceworker'); + } + // Service worker URL: chrome-extension:///background.js + return background.url().split('/')[2]; +} + +/** + * Create a fake user from an env config and register it via the Backend API. + */ +export async function createTestUser(env: EnvironmentConfig): Promise { + const clerkClient = backendCreateClerkClient({ + apiUrl: env.privateVariables.get('CLERK_API_URL'), + secretKey: env.privateVariables.get('CLERK_SECRET_KEY'), + publishableKey: env.publicVariables.get('CLERK_PUBLISHABLE_KEY'), + }); + const users = createUserService(clerkClient); + const fakeUser = users.createFakeUser(); + await users.createBapiUser(fakeUser); + return fakeUser; +} diff --git a/integration/tests/chrome-extension/sync-host.test.ts b/integration/tests/chrome-extension/sync-host.test.ts new file mode 100644 index 00000000000..a2c6d5bba60 --- /dev/null +++ b/integration/tests/chrome-extension/sync-host.test.ts @@ -0,0 +1,170 @@ +import * as path from 'node:path'; + +import { clerk } from '@clerk/testing/playwright'; +import type { BrowserContext, Page } from '@playwright/test'; +import { test as base, expect } from '@playwright/test'; + +import { appConfigs } from '../../presets'; +import type { FakeUser } from '../../testUtils/usersService'; +import { createTestUser, getExtensionId, launchExtensionContext, setupClerkTestingEnv } from './helpers'; + +const env = appConfigs.envs.withEmailCodes; + +type SyncHostWorkerFixtures = { + syncHostSetup: { extensionDistPath: string; hostServerUrl: string }; +}; + +type SyncHostFixtures = { + context: BrowserContext; + extensionId: string; + extensionPage: Page; + hostPage: Page; +}; + +/** + * Sync-host test: verifies the extension can sync auth state from a host web app. + * Requires both a host web app running + the extension built with syncHost configured. + */ +const test = base.extend({ + // Worker-scoped: start host app, build extension with syncHost, set up testing tokens + syncHostSetup: [ + async ({}, use) => { + // 1. Start the host web app (react-vite) + // Use env without pkglab JS/UI URLs so the host app loads Clerk from CDN + const hostEnv = env + .clone() + .setEnvVariable('public', 'CLERK_JS_URL', '') + .setEnvVariable('public', 'CLERK_UI_URL', ''); + const hostConfig = appConfigs.react.vite; + const hostApp = await hostConfig.commit(); + await hostApp.withEnv(hostEnv); + await hostApp.setup(); + const { serverUrl: hostServerUrl } = await hostApp.dev(); + + // 2. Build the extension with syncHost pointing to the host app + const extConfig = appConfigs.chromeExtension.vite + .clone() + .setName('chrome-extension-vite-sync') + .addFile( + 'src/popup.tsx', + () => ` +import { ClerkProvider, Show, SignIn, UserButton, useAuth } from '@clerk/chrome-extension'; +import React from 'react'; +import ReactDOM from 'react-dom/client'; + +const PUBLISHABLE_KEY = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY as string; +const SYNC_HOST = import.meta.env.VITE_CLERK_SYNC_HOST as string; + +function App() { + return ( + {}} + routerReplace={() => {}} + > +
+ + + + + + + +
+
+ ); +} + +function AuthInfo() { + const { userId, sessionId } = useAuth(); + return ( +
+

{userId}

+

{sessionId}

+
+ ); +} + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + , +); +`, + ); + + const syncEnv = env.clone().setEnvVariable('public', 'CLERK_SYNC_HOST', hostServerUrl); + const extApp = await extConfig.commit(); + await extApp.withEnv(syncEnv); + await extApp.setup(); + await extApp.build(); + + const extensionDistPath = path.resolve(extApp.appDir, 'dist'); + + await setupClerkTestingEnv(env); + + await use({ extensionDistPath, hostServerUrl }); + + await Promise.all([hostApp.teardown(), extApp.teardown()]); + }, + { scope: 'worker', timeout: 180_000 }, + ], + + context: async ({ syncHostSetup }, use) => { + const context = await launchExtensionContext(syncHostSetup.extensionDistPath); + await use(context); + await context.close(); + }, + + extensionId: async ({ context }, use) => { + const extensionId = await getExtensionId(context); + await use(extensionId); + }, + + extensionPage: async ({ context, extensionId }, use) => { + const page = await context.newPage(); + await page.goto(`chrome-extension://${extensionId}/popup.html`); + await use(page); + await page.close(); + }, + + hostPage: async ({ context, syncHostSetup }, use) => { + const page = await context.newPage(); + await page.goto(`${syncHostSetup.hostServerUrl}/sign-in`); + await use(page); + await page.close(); + }, +}); + +test.describe('chrome extension sync-host @chrome-extension', () => { + test.describe.configure({ mode: 'serial' }); + + let fakeUser: FakeUser; + + test.beforeAll(async () => { + fakeUser = await createTestUser(env); + }); + + test.afterAll(async () => { + await fakeUser.deleteIfExists(); + }); + + test('extension picks up session from host web app via syncHost', async ({ hostPage, extensionPage }) => { + // Sign in on the host web app via SDK + await clerk.signIn({ + page: hostPage, + signInParams: { strategy: 'password', identifier: fakeUser.email, password: fakeUser.password }, + }); + + // Reload the extension popup to pick up the synced session from the host + await extensionPage.reload(); + + // The extension should detect the session from the host and show signed-in state + await extensionPage.waitForSelector('[data-testid="user-id"]', { timeout: 30_000 }); + + const userId = await extensionPage.locator('[data-testid="user-id"]').textContent(); + expect(userId).toBeTruthy(); + expect(userId).toMatch(/^user_/); + }); +}); diff --git a/package.json b/package.json index 920a4b4eae3..a348e2e3336 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "test:integration:base": "pnpm playwright test --config integration/playwright.config.ts", "test:integration:billing": "E2E_APP_ID=withBillingJwtV2.* pnpm test:integration:base --grep @billing", "test:integration:cache-components": "E2E_APP_ID=next.cacheComponents pnpm test:integration:base --grep @cache-components", + "test:integration:chrome-extension": "pnpm playwright test --config integration/playwright.chrome-extension.config.ts", "test:integration:cleanup": "pnpm playwright test --config integration/playwright.cleanup.config.ts", "test:integration:custom": "pnpm test:integration:base --grep @custom", "test:integration:deployment:nextjs": "pnpm playwright test --config integration/playwright.deployments.config.ts", diff --git a/turbo.json b/turbo.json index 9378cc8b73b..5e77fe35605 100644 --- a/turbo.json +++ b/turbo.json @@ -312,6 +312,12 @@ "inputs": ["integration/**"], "outputLogs": "new-only" }, + "//#test:integration:chrome-extension": { + "dependsOn": ["@clerk/nextjs#build"], + "env": ["CLEANUP", "DEBUG", "E2E_*", "INTEGRATION_INSTANCE_KEYS"], + "inputs": ["integration/**"], + "outputLogs": "new-only" + }, "//#typedoc:generate": { "dependsOn": ["@clerk/nextjs#build", "@clerk/react#build", "@clerk/shared#build"], "inputs": ["tsconfig.typedoc.json", "typedoc.config.mjs"],