Skip to content
Open
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/yummy-hoops-drum.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,7 @@ jobs:
"react-router",
"custom",
"hono",
"chrome-extension",
]
test-project: ["chrome"]
include:
Expand Down
21 changes: 21 additions & 0 deletions integration/playwright.chrome-extension.config.ts
Original file line number Diff line number Diff line change
@@ -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.
},
],
});
19 changes: 19 additions & 0 deletions integration/presets/chrome-extension.ts
Original file line number Diff line number Diff line change
@@ -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;
2 changes: 2 additions & 0 deletions integration/presets/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -14,6 +15,7 @@ import { tanstack } from './tanstack';
import { vue } from './vue';

export const appConfigs = {
chromeExtension,
customFlows,
envs,
express,
Expand Down
17 changes: 17 additions & 0 deletions integration/templates/chrome-extension-vite/manifest.json
Original file line number Diff line number Diff line change
@@ -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'"
}
}
24 changes: 24 additions & 0 deletions integration/templates/chrome-extension-vite/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
12 changes: 12 additions & 0 deletions integration/templates/chrome-extension-vite/popup.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Clerk Test Extension</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/popup.tsx"></script>
</body>
</html>
31 changes: 31 additions & 0 deletions integration/templates/chrome-extension-vite/src/background.ts
Original file line number Diff line number Diff line change
@@ -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<any> | 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
}
});
41 changes: 41 additions & 0 deletions integration/templates/chrome-extension-vite/src/popup.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<ClerkProvider
publishableKey={PUBLISHABLE_KEY}
routerPush={() => {}}
routerReplace={() => {}}
>
<main>
<Show when='signed-out'>
<SignIn />
</Show>
<Show when='signed-in'>
<UserButton />
<AuthInfo />
</Show>
</main>
</ClerkProvider>
);
}

function AuthInfo() {
const { userId, sessionId } = 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>,
);
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
/// <reference types="vite/client" />
17 changes: 17 additions & 0 deletions integration/templates/chrome-extension-vite/tsconfig.json
Original file line number Diff line number Diff line change
@@ -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"]
}
Original file line number Diff line number Diff line change
@@ -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 || ''),
},
};
});
19 changes: 19 additions & 0 deletions integration/templates/chrome-extension-vite/vite.config.ts
Original file line number Diff line number Diff line change
@@ -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',
},
});
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-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) {
Expand Down
45 changes: 45 additions & 0 deletions integration/tests/chrome-extension/background.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
64 changes: 64 additions & 0 deletions integration/tests/chrome-extension/basic.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading
Loading