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"],