diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b6e639c157e..630ed662984 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,24 @@ 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 + xvfb-run --help > /dev/null + - 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..78bbe9a5132 --- /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', 'pnpm build') + .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..bdb9021af6b --- /dev/null +++ b/integration/templates/electron-vite/index.html @@ -0,0 +1,18 @@ + + + + + + Clerk Electron Integration + + +
+ + + diff --git a/integration/templates/electron-vite/main.mjs b/integration/templates/electron-vite/main.mjs new file mode 100644 index 00000000000..360b380f292 --- /dev/null +++ b/integration/templates/electron-vite/main.mjs @@ -0,0 +1,98 @@ +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +import { setupMain } 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'; +// electron-vite sets this in dev. Its presence is how we pick proxy vs. file serving. +const DEV_SERVER_URL = process.env.ELECTRON_RENDERER_URL; +const rendererRoot = path.resolve(__dirname, 'dist'); + +const clerk = setupMain({ + storage: storage(), + 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); + + // Only the app host serves the UI. Other hosts can be reserved for deep links. + if (url.host !== RENDERER_HOST) { + return new Response('Not found', { status: 404 }); + } + + // Dev: proxy to Vite so the renderer always runs from clerk://app. + if (DEV_SERVER_URL) { + const target = new URL(url.pathname + url.search, DEV_SERVER_URL); + // Intentionally do not forward the renderer's request headers to localhost. + const init = { method: request.method }; + + if (request.method !== 'GET' && request.method !== 'HEAD') { + init.body = request.body; + init.duplex = 'half'; + } + + try { + return await net.fetch(target.toString(), init); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error('[clerk-app-protocol] dev proxy failed', { + requested: request.url, + target: target.toString(), + devServer: DEV_SERVER_URL, + error: message, + }); + return new Response(`clerk://app dev proxy error: ${message}`, { status: 502 }); + } + } + + // Prod: serve the bundled renderer with a traversal guard and SPA fallback. + const requestedPath = decodeURIComponent(url.pathname); + const resolvedPath = path.resolve(rendererRoot, `.${requestedPath}`); + + if (resolvedPath !== rendererRoot && !resolvedPath.startsWith(rendererRoot + path.sep)) { + return new Response(null, { 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..55a1393117f --- /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": ">=22.11.0" + } +} diff --git a/integration/templates/electron-vite/preload.mjs b/integration/templates/electron-vite/preload.mjs new file mode 100644 index 00000000000..718064bfd3c --- /dev/null +++ b/integration/templates/electron-vite/preload.mjs @@ -0,0 +1,3 @@ +import { setupPreload } from '@clerk/electron/preload'; + +setupPreload(); diff --git a/integration/templates/electron-vite/src/main.tsx b/integration/templates/electron-vite/src/main.tsx new file mode 100644 index 00000000000..bcd7ca66d73 --- /dev/null +++ b/integration/templates/electron-vite/src/main.tsx @@ -0,0 +1,44 @@ +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; + +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..db4a37bd4ba --- /dev/null +++ b/integration/templates/electron-vite/src/style.css @@ -0,0 +1,20 @@ +body { + margin: 0; + min-width: 320px; + min-height: 100vh; + 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; +} 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..f0b7f75c359 --- /dev/null +++ b/integration/templates/electron-vite/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "allowSyntheticDefaultImports": true, + "isolatedModules": true, + "jsx": "react-jsx", + "lib": ["DOM", "DOM.Iterable", "ES2020"], + "module": "ESNext", + "moduleResolution": "Bundler", + "noEmit": true, + "strict": true, + "target": "ES2020", + "types": ["vite/client"] + }, + "include": ["src"] +} 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..9c838b982f2 --- /dev/null +++ b/integration/tests/electron/basic.test.ts @@ -0,0 +1,35 @@ +import { createPageObjects } from '@clerk/testing/playwright/unstable'; + +import type { FakeUser } from '../../testUtils'; +import { createTestUtils } from '../../testUtils'; +import { expect, test } from './fixtures'; + +test.describe('electron basic auth @electron', () => { + test.describe.configure({ mode: 'serial' }); + + let fakeUser: FakeUser; + + test.beforeAll(async ({ testApp }) => { + const u = createTestUtils({ app: testApp }); + fakeUser = u.services.users.createFakeUser(); + await u.services.users.createBapiUser(fakeUser); + }); + + test.afterAll(async () => { + await fakeUser?.deleteIfExists(); + }); + + 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 }); + }); +}); diff --git a/integration/tests/electron/fixtures.ts b/integration/tests/electron/fixtures.ts new file mode 100644 index 00000000000..acad90d5e8a --- /dev/null +++ b/integration/tests/electron/fixtures.ts @@ -0,0 +1,62 @@ +import * as path from 'node:path'; + +import { setupClerkTestingToken } from '@clerk/testing/playwright'; +import { _electron as electron, test as base } from '@playwright/test'; +import type { ElectronApplication, Page } from '@playwright/test'; + +import type { Application } from '../../models/application'; +import { appConfigs } from '../../presets'; +import { run } from '../../scripts'; +import { setupClerkTestingEnv } from '../chrome-extension/helpers'; + +type WorkerFixtures = { + testApp: Application; +}; + +type TestFixtures = { + electronApplication: ElectronApplication; + electronPage: Page; +}; + +const electronExecutable = (app: Application) => + path.resolve(app.appDir, 'node_modules', '.bin', process.platform === 'win32' ? 'electron.cmd' : 'electron'); + +export const test = base.extend({ + testApp: [ + async ({}, use) => { + const env = appConfigs.envs.withEmailCodes; + const app = await appConfigs.electron.vite.commit(); + + await app.withEnv(env); + await app.setup(); + // pkglab installs with scripts disabled, so install Electron's binary explicitly. + await run('node node_modules/electron/install.js', { cwd: app.appDir }); + await app.build(); + await setupClerkTestingEnv(env); + + await use(app); + await app.teardown(); + }, + { scope: 'worker', timeout: 120_000 }, + ], + + electronApplication: async ({ testApp }, use) => { + const application = await electron.launch({ + args: [path.resolve(testApp.appDir, 'main.mjs')], + cwd: testApp.appDir, + executablePath: electronExecutable(testApp), + }); + + await use(application); + await application.close(); + }, + + electronPage: async ({ electronApplication }, use) => { + const page = await electronApplication.firstWindow(); + await setupClerkTestingToken({ context: page.context() }); + await page.reload(); + await use(page); + }, +}); + +export { expect } from '@playwright/test'; diff --git a/package.json b/package.json index d49b3668f42..94790c2f874 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 42298cbc8f2..1766fb9c531 100644 --- a/turbo.json +++ b/turbo.json @@ -363,6 +363,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"],