Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .changeset/electron-e2e-tests.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
16 changes: 15 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,7 @@ jobs:
"custom",
"hono",
"chrome-extension",
"electron",
]
test-project: ["chrome"]
include:
Expand Down Expand Up @@ -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}}
Expand Down
17 changes: 17 additions & 0 deletions integration/presets/electron.ts
Original file line number Diff line number Diff line change
@@ -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')
Comment on lines +9 to +12

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Wire dev and serve to real commands.

pnpm dev currently just performs a build, and pnpm serve exits immediately, so the generated Electron template has no runnable development or start path.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@integration/presets/electron.ts` around lines 9 - 12, The Electron preset
currently wires the package scripts in `integration/presets/electron.ts` so
`dev` only builds and `serve` is a noop, which leaves no runnable app flow.
Update the `addScript('dev', ...)` and `addScript('serve', ...)` entries to
point to the actual Electron development and startup commands used by the
template, keeping `setup` and `build` unchanged. Use the existing script wiring
in the preset as the place to fix this so generated projects expose a real `dev`
path and a real `serve` path.

.addDependency('@clerk/electron', PKGLAB);

export const electron = {
vite,
} as const;
2 changes: 2 additions & 0 deletions integration/presets/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -17,6 +18,7 @@ import { vue } from './vue';
export const appConfigs = {
chromeExtension,
customFlows,
electron,
envs,
express,
fastify,
Expand Down
18 changes: 18 additions & 0 deletions integration/templates/electron-vite/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0"
/>
<title>Clerk Electron E2E</title>
</head>
<body>
<div id="root"></div>
<script
type="module"
src="/src/main.tsx"
></script>
</body>
</html>
77 changes: 77 additions & 0 deletions integration/templates/electron-vite/main.mjs
Original file line number Diff line number Diff line change
@@ -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();
});
26 changes: 26 additions & 0 deletions integration/templates/electron-vite/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
3 changes: 3 additions & 0 deletions integration/templates/electron-vite/preload.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { exposeClerkBridge } from '@clerk/electron/preload';

exposeClerkBridge();
46 changes: 46 additions & 0 deletions integration/templates/electron-vite/src/main.tsx
Original file line number Diff line number Diff line change
@@ -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;
Comment on lines +7 to +8

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Validate the required Vite env vars at startup.

The as string assertions only silence TypeScript; an unset VITE_CLERK_* still flows through as undefined and fails later inside Clerk initialization. Throw a clear startup error here instead.

🛠️ Proposed change
-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;
+const PUBLISHABLE_KEY = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY;
+const CLERK_UI_URL = import.meta.env.VITE_CLERK_UI_URL;
+
+if (!PUBLISHABLE_KEY || !CLERK_UI_URL) {
+  throw new Error('Missing VITE_CLERK_PUBLISHABLE_KEY or VITE_CLERK_UI_URL for the Electron renderer');
+}

As per coding guidelines, "Validate all inputs and sanitize outputs" and "Provide meaningful error messages to developers".

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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;
const PUBLISHABLE_KEY = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY;
const CLERK_UI_URL = import.meta.env.VITE_CLERK_UI_URL;
if (!PUBLISHABLE_KEY || !CLERK_UI_URL) {
throw new Error('Missing VITE_CLERK_PUBLISHABLE_KEY or VITE_CLERK_UI_URL for the Electron renderer');
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@integration/templates/electron-vite/src/main.tsx` around lines 7 - 8, The
startup constants in main.tsx rely on TypeScript assertions only, so missing
VITE_CLERK_PUBLISHABLE_KEY or VITE_CLERK_UI_URL can still reach Clerk as
undefined. Update the initialization around PUBLISHABLE_KEY and CLERK_UI_URL to
validate both env vars immediately at startup and throw a clear,
developer-facing error if either is absent or empty. Keep the check close to the
existing env reads so the failure happens before Clerk setup, and make the
message specific enough to identify which required variable is missing.

Source: Coding guidelines


function App() {
return (
<ClerkProvider
publishableKey={PUBLISHABLE_KEY}
__internal_clerkUIUrl={CLERK_UI_URL}
routerPush={() => {}}
routerReplace={() => {}}
>
<main data-testid='electron-app'>
<Show when='signed-out'>
<SignIn />
</Show>
<Show when='signed-in'>
<UserButton />
<AuthInfo />
</Show>
</main>
</ClerkProvider>
);
}

function AuthInfo() {
const { sessionId, userId } = useAuth();

return (
<div>
<p data-testid='user-id'>{userId}</p>
<p data-testid='session-id'>{sessionId}</p>
</div>
);
}

ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);
25 changes: 25 additions & 0 deletions integration/templates/electron-vite/src/style.css
Original file line number Diff line number Diff line change
@@ -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;
}
1 change: 1 addition & 0 deletions integration/templates/electron-vite/src/vite-env.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
/// <reference types="vite/client" />
20 changes: 20 additions & 0 deletions integration/templates/electron-vite/tsconfig.json
Original file line number Diff line number Diff line change
@@ -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"]
}
6 changes: 6 additions & 0 deletions integration/templates/electron-vite/vite.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import react from '@vitejs/plugin-react';
import { defineConfig } from 'vite';

export default defineConfig({
plugins: [react()],
});
1 change: 1 addition & 0 deletions integration/templates/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
77 changes: 77 additions & 0 deletions integration/tests/electron/basic.test.ts
Original file line number Diff line number Diff line change
@@ -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<Record<'clearToken' | 'getToken' | 'saveToken', unknown>>;
oauthTransport?: Partial<Record<'getRedirectUrl' | 'open', unknown>>;
};
};

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 });
});
});
Loading
Loading