diff --git a/.changeset/electron-e2e-tests.md b/.changeset/electron-e2e-tests.md
new file mode 100644
index 00000000000..a845151cc84
--- /dev/null
+++ b/.changeset/electron-e2e-tests.md
@@ -0,0 +1,2 @@
+---
+---
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 32478c15868..58eaa8bc4bc 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -347,6 +347,7 @@ jobs:
"custom",
"hono",
"chrome-extension",
+ "electron",
]
test-project: ["chrome"]
include:
@@ -486,10 +487,23 @@ jobs:
working-directory: ./integration/certs
run: ls -la && pwd
+ - name: Ensure Xvfb is installed
+ if: ${{ matrix.test-name == 'electron' }}
+ run: |
+ if ! command -v xvfb-run &> /dev/null; then
+ sudo apt-get update
+ sudo apt-get install -y xvfb
+ fi
+
- name: Run Integration Tests
id: integration-tests
timeout-minutes: 25
- run: pnpm turbo test:integration:${{ matrix.test-name }} $TURBO_ARGS
+ run: |
+ if [ "${{ matrix.test-name }}" = "electron" ]; then
+ xvfb-run -a pnpm turbo test:integration:${{ matrix.test-name }} $TURBO_ARGS
+ else
+ pnpm turbo test:integration:${{ matrix.test-name }} $TURBO_ARGS
+ fi
env:
E2E_DEBUG: "1"
E2E_APP_CLERK_JS_DIR: ${{runner.temp}}
diff --git a/integration/presets/electron.ts b/integration/presets/electron.ts
new file mode 100644
index 00000000000..5276e4ee521
--- /dev/null
+++ b/integration/presets/electron.ts
@@ -0,0 +1,17 @@
+import { applicationConfig } from '../models/applicationConfig';
+import { templates } from '../templates';
+import { PKGLAB } from './utils';
+
+const vite = applicationConfig()
+ .setName('electron-vite')
+ .useTemplate(templates['electron-vite'])
+ .setEnvFormatter('public', key => `VITE_${key}`)
+ .addScript('setup', 'pnpm install')
+ .addScript('dev', 'echo noop')
+ .addScript('build', 'pnpm build')
+ .addScript('serve', 'echo noop')
+ .addDependency('@clerk/electron', PKGLAB);
+
+export const electron = {
+ vite,
+} as const;
diff --git a/integration/presets/index.ts b/integration/presets/index.ts
index f67f3b36385..de6cacfcb03 100644
--- a/integration/presets/index.ts
+++ b/integration/presets/index.ts
@@ -1,6 +1,7 @@
import { astro } from './astro';
import { chromeExtension } from './chrome-extension';
import { customFlows } from './custom-flows';
+import { electron } from './electron';
import { envs, instanceKeys } from './envs';
import { expo } from './expo';
import { express } from './express';
@@ -17,6 +18,7 @@ import { vue } from './vue';
export const appConfigs = {
chromeExtension,
customFlows,
+ electron,
envs,
express,
fastify,
diff --git a/integration/templates/electron-vite/index.html b/integration/templates/electron-vite/index.html
new file mode 100644
index 00000000000..bf21ab8969d
--- /dev/null
+++ b/integration/templates/electron-vite/index.html
@@ -0,0 +1,18 @@
+
+
+
+
+
+ Clerk Electron E2E
+
+
+
+
+
+
diff --git a/integration/templates/electron-vite/main.mjs b/integration/templates/electron-vite/main.mjs
new file mode 100644
index 00000000000..7763211349d
--- /dev/null
+++ b/integration/templates/electron-vite/main.mjs
@@ -0,0 +1,77 @@
+import path from 'node:path';
+import { fileURLToPath, pathToFileURL } from 'node:url';
+
+import { createClerkBridge } from '@clerk/electron';
+import { storage } from '@clerk/electron/storage';
+import { app, BrowserWindow, net, protocol } from 'electron';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const RENDERER_SCHEME = 'clerk';
+const RENDERER_HOST = 'app';
+const rendererRoot = path.resolve(__dirname, 'dist');
+
+const clerk = createClerkBridge({
+ storage: storage({ unencryptedFallback: true }),
+ renderer: {
+ scheme: RENDERER_SCHEME,
+ host: RENDERER_HOST,
+ },
+});
+
+async function createWindow() {
+ const win = new BrowserWindow({
+ width: 1000,
+ height: 800,
+ webPreferences: {
+ contextIsolation: true,
+ nodeIntegration: false,
+ preload: path.resolve(__dirname, 'preload.mjs'),
+ sandbox: false,
+ },
+ });
+
+ await win.loadURL(`${RENDERER_SCHEME}://${RENDERER_HOST}/`);
+}
+
+function registerClerkAppProtocol() {
+ protocol.handle(RENDERER_SCHEME, async request => {
+ const url = new URL(request.url);
+
+ if (url.host !== RENDERER_HOST) {
+ return new Response('Not found', { status: 404 });
+ }
+
+ let requestedPath;
+ try {
+ requestedPath = decodeURIComponent(url.pathname);
+ } catch {
+ return new Response('Bad request', { status: 400 });
+ }
+
+ const resolvedPath = path.resolve(rendererRoot, `.${requestedPath}`);
+ const relativePath = path.relative(rendererRoot, resolvedPath);
+ const isSafe = relativePath === '' || (!relativePath.startsWith('..') && !path.isAbsolute(relativePath));
+
+ if (!isSafe) {
+ return new Response('Forbidden', { status: 403 });
+ }
+
+ const hasExtension = /\.[^/]+$/.test(url.pathname);
+ const filePath = hasExtension ? resolvedPath : path.join(rendererRoot, 'index.html');
+
+ return net.fetch(pathToFileURL(filePath).toString());
+ });
+}
+
+app.whenReady().then(async () => {
+ registerClerkAppProtocol();
+ await createWindow();
+});
+
+app.on('window-all-closed', () => {
+ app.quit();
+});
+
+app.on('before-quit', () => {
+ clerk.cleanup();
+});
diff --git a/integration/templates/electron-vite/package.json b/integration/templates/electron-vite/package.json
new file mode 100644
index 00000000000..a1491475520
--- /dev/null
+++ b/integration/templates/electron-vite/package.json
@@ -0,0 +1,26 @@
+{
+ "name": "electron-vite",
+ "version": "0.0.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "build": "vite build"
+ },
+ "dependencies": {
+ "electron-store": "^8.2.0",
+ "react": "18.3.1",
+ "react-dom": "18.3.1"
+ },
+ "devDependencies": {
+ "@types/node": "^22.12.0",
+ "@types/react": "18.3.12",
+ "@types/react-dom": "18.3.1",
+ "@vitejs/plugin-react": "^4.3.4",
+ "electron": "^39.2.6",
+ "typescript": "^5.7.3",
+ "vite": "^7.3.3"
+ },
+ "engines": {
+ "node": ">=24.15.0"
+ }
+}
diff --git a/integration/templates/electron-vite/preload.mjs b/integration/templates/electron-vite/preload.mjs
new file mode 100644
index 00000000000..e95e3ee4061
--- /dev/null
+++ b/integration/templates/electron-vite/preload.mjs
@@ -0,0 +1,3 @@
+import { exposeClerkBridge } from '@clerk/electron/preload';
+
+exposeClerkBridge();
diff --git a/integration/templates/electron-vite/src/main.tsx b/integration/templates/electron-vite/src/main.tsx
new file mode 100644
index 00000000000..fdb63d41444
--- /dev/null
+++ b/integration/templates/electron-vite/src/main.tsx
@@ -0,0 +1,46 @@
+import { ClerkProvider, Show, SignIn, UserButton, useAuth } from '@clerk/electron/react';
+import React from 'react';
+import ReactDOM from 'react-dom/client';
+
+import './style.css';
+
+const PUBLISHABLE_KEY = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY as string;
+const CLERK_UI_URL = import.meta.env.VITE_CLERK_UI_URL as string;
+
+function App() {
+ return (
+ {}}
+ routerReplace={() => {}}
+ >
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function AuthInfo() {
+ const { sessionId, userId } = useAuth();
+
+ return (
+
+
{userId}
+
{sessionId}
+
+ );
+}
+
+ReactDOM.createRoot(document.getElementById('root')!).render(
+
+
+ ,
+);
diff --git a/integration/templates/electron-vite/src/style.css b/integration/templates/electron-vite/src/style.css
new file mode 100644
index 00000000000..305cccba2aa
--- /dev/null
+++ b/integration/templates/electron-vite/src/style.css
@@ -0,0 +1,25 @@
+* {
+ box-sizing: border-box;
+}
+
+body {
+ min-width: 320px;
+ min-height: 100vh;
+ margin: 0;
+ font-family:
+ Inter,
+ ui-sans-serif,
+ system-ui,
+ -apple-system,
+ BlinkMacSystemFont,
+ 'Segoe UI',
+ sans-serif;
+ background: #f8fafc;
+}
+
+main {
+ display: grid;
+ min-height: 100vh;
+ place-items: center;
+ padding: 24px;
+}
diff --git a/integration/templates/electron-vite/src/vite-env.d.ts b/integration/templates/electron-vite/src/vite-env.d.ts
new file mode 100644
index 00000000000..11f02fe2a00
--- /dev/null
+++ b/integration/templates/electron-vite/src/vite-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/integration/templates/electron-vite/tsconfig.json b/integration/templates/electron-vite/tsconfig.json
new file mode 100644
index 00000000000..df0556f33d7
--- /dev/null
+++ b/integration/templates/electron-vite/tsconfig.json
@@ -0,0 +1,20 @@
+{
+ "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,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true
+ },
+ "include": ["src", "vite.config.ts"]
+}
diff --git a/integration/templates/electron-vite/vite.config.ts b/integration/templates/electron-vite/vite.config.ts
new file mode 100644
index 00000000000..fabde1a8f5e
--- /dev/null
+++ b/integration/templates/electron-vite/vite.config.ts
@@ -0,0 +1,6 @@
+import react from '@vitejs/plugin-react';
+import { defineConfig } from 'vite';
+
+export default defineConfig({
+ plugins: [react()],
+});
diff --git a/integration/templates/index.ts b/integration/templates/index.ts
index 5588158e1f5..2fd05daa6ae 100644
--- a/integration/templates/index.ts
+++ b/integration/templates/index.ts
@@ -24,6 +24,7 @@ export const templates = {
'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'),
+ 'electron-vite': resolve(__dirname, './electron-vite'),
} as const;
if (new Set([...Object.values(templates)]).size !== Object.values(templates).length) {
diff --git a/integration/tests/electron/basic.test.ts b/integration/tests/electron/basic.test.ts
new file mode 100644
index 00000000000..e793dab95e5
--- /dev/null
+++ b/integration/tests/electron/basic.test.ts
@@ -0,0 +1,77 @@
+import { clerk } from '@clerk/testing/playwright';
+import { createPageObjects } from '@clerk/testing/playwright/unstable';
+
+import type { FakeUser } from '../../testUtils';
+import { createTestUtils } from '../../testUtils';
+import { expect, test } from './fixtures';
+
+type ElectronWindow = Window & {
+ __clerk_internal_electron?: {
+ tokenCache?: Partial>;
+ oauthTransport?: Partial>;
+ };
+};
+
+test.describe('electron basic auth @electron', () => {
+ test.describe.configure({ mode: 'serial' });
+
+ let fakeUser: FakeUser;
+
+ test.beforeAll(async ({ electronTestApp }) => {
+ const u = createTestUtils({ app: electronTestApp });
+ fakeUser = u.services.users.createFakeUser();
+ await u.services.users.createBapiUser(fakeUser);
+ });
+
+ test.afterAll(async () => {
+ await fakeUser?.deleteIfExists();
+ });
+
+ test('exposes the preload bridge to the renderer', async ({ electronPage }) => {
+ await expect(electronPage.locator('[data-testid="electron-app"]')).toBeVisible();
+
+ await expect(
+ electronPage.evaluate(() => {
+ const bridge = (window as ElectronWindow).__clerk_internal_electron;
+
+ return (
+ typeof bridge?.tokenCache?.clearToken === 'function' &&
+ typeof bridge?.tokenCache?.getToken === 'function' &&
+ typeof bridge?.tokenCache?.saveToken === 'function' &&
+ typeof bridge?.oauthTransport?.getRedirectUrl === 'function' &&
+ typeof bridge?.oauthTransport?.open === 'function'
+ );
+ }),
+ ).resolves.toBe(true);
+ });
+
+ test('signs in with email and password', async ({ electronPage }) => {
+ const { signIn } = createPageObjects({ page: electronPage, useTestingToken: false });
+
+ await signIn.waitForMounted();
+ await expect(electronPage.locator('.cl-signIn-root')).toBeVisible();
+
+ await signIn.setIdentifier(fakeUser.email!);
+ await signIn.continue();
+ await signIn.setPassword(fakeUser.password);
+ await signIn.continue();
+
+ await expect(electronPage.locator('[data-testid="user-id"]')).toHaveText(/^user_/, { timeout: 30_000 });
+ });
+
+ test('persists the signed-in session after relaunch', async ({ electronPage }) => {
+ await expect(electronPage.locator('[data-testid="user-id"]')).toHaveText(/^user_/, { timeout: 30_000 });
+ await expect(electronPage.locator('[data-testid="session-id"]')).toHaveText(/^sess_/, { timeout: 30_000 });
+ });
+
+ test('signs out and clears the session', async ({ electronPage }) => {
+ await expect(electronPage.locator('.cl-userButtonTrigger')).toBeVisible({ timeout: 30_000 });
+ await clerk.signOut({ page: electronPage });
+
+ await expect(electronPage.locator('.cl-signIn-root')).toBeVisible({ timeout: 30_000 });
+ });
+
+ test('keeps the signed-out state after relaunch', async ({ electronPage }) => {
+ await expect(electronPage.locator('.cl-signIn-root')).toBeVisible({ timeout: 30_000 });
+ });
+});
diff --git a/integration/tests/electron/fixtures.ts b/integration/tests/electron/fixtures.ts
new file mode 100644
index 00000000000..4b88d655f4d
--- /dev/null
+++ b/integration/tests/electron/fixtures.ts
@@ -0,0 +1,87 @@
+import * as path from 'node:path';
+
+import { parsePublishableKey } from '@clerk/shared/keys';
+import { clerkSetup, setupClerkTestingToken } from '@clerk/testing/playwright';
+import type { ElectronApplication, Page } from '@playwright/test';
+import { _electron as electron, test as base } from '@playwright/test';
+
+import type { Application } from '../../models/application';
+import type { EnvironmentConfig } from '../../models/environment';
+import { appConfigs } from '../../presets';
+import { run } from '../../scripts';
+
+type WorkerFixtures = {
+ electronTestApp: Application;
+};
+
+type TestFixtures = {
+ electronApplication: ElectronApplication;
+ electronPage: Page;
+};
+
+const electronExecutable = (app: Application) =>
+ path.resolve(app.appDir, 'node_modules', '.bin', process.platform === 'win32' ? 'electron.cmd' : 'electron');
+
+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) {
+ throw new Error('Missing Clerk integration keys for Electron E2E tests');
+ }
+
+ const { frontendApi: frontendApiUrl, instanceType } = parsePublishableKey(publishableKey, { fatal: true });
+
+ if (instanceType !== 'development') {
+ return;
+ }
+
+ await clerkSetup({
+ publishableKey,
+ frontendApiUrl,
+ secretKey,
+ // @ts-expect-error apiUrl is accepted at runtime.
+ apiUrl,
+ dotenv: false,
+ });
+}
+
+export const test = base.extend({
+ electronTestApp: [
+ async ({}, provide) => {
+ const env = appConfigs.envs.withEmailCodes;
+ const app = await appConfigs.electron.vite.commit();
+
+ await app.withEnv(env);
+ await app.setup();
+ await run('node node_modules/electron/install.js', { cwd: app.appDir });
+ await app.build();
+ await setupClerkTestingEnv(env);
+
+ await provide(app);
+ await app.teardown();
+ },
+ { scope: 'worker', timeout: 180_000 },
+ ],
+
+ electronApplication: async ({ electronTestApp }, provide) => {
+ const application = await electron.launch({
+ args: [path.resolve(electronTestApp.appDir, 'main.mjs')],
+ cwd: electronTestApp.appDir,
+ executablePath: electronExecutable(electronTestApp),
+ });
+
+ await provide(application);
+ await application.close();
+ },
+
+ electronPage: async ({ electronApplication }, provide) => {
+ const page = await electronApplication.firstWindow();
+ await setupClerkTestingToken({ context: page.context() });
+ await page.reload();
+ await provide(page);
+ },
+});
+
+export { expect } from '@playwright/test';
diff --git a/package.json b/package.json
index 92768bddf09..b38fa016633 100644
--- a/package.json
+++ b/package.json
@@ -44,6 +44,7 @@
"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",
+ "test:integration:electron": "pnpm test:integration:base --grep @electron",
"test:integration:expo-web:disabled": "E2E_APP_ID=expo.expo-web pnpm test:integration:base --grep @expo-web",
"test:integration:express": "E2E_APP_ID=express.* pnpm test:integration:base --grep @express",
"test:integration:fastify": "E2E_APP_ID=fastify.* pnpm test:integration:base --grep @fastify",
diff --git a/turbo.json b/turbo.json
index 3b062a3445a..75b370c9338 100644
--- a/turbo.json
+++ b/turbo.json
@@ -368,6 +368,13 @@
"outputs": ["integration/playwright-report/**"],
"outputLogs": "new-only"
},
+ "//#test:integration:electron": {
+ "dependsOn": ["@clerk/electron#build"],
+ "env": ["CLEANUP", "DEBUG", "E2E_*", "INTEGRATION_INSTANCE_KEYS", "INTEGRATION_STAGING_INSTANCE_KEYS"],
+ "inputs": ["integration/**", "packages/electron/**"],
+ "outputs": ["integration/playwright-report/**"],
+ "outputLogs": "new-only"
+ },
"//#typedoc:generate": {
"dependsOn": ["@clerk/nextjs#build", "@clerk/react#build", "@clerk/shared#build"],
"inputs": ["tsconfig.typedoc.json", "typedoc.config.mjs", ".typedoc/**/*.mjs"],