Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
908f6b3
feat(integration): add E2E_STAGING flag for staging env auto-swap
jacekradko Mar 13, 2026
b2daf97
ci: temporarily allow jacek/* branches in e2e-staging ref validation
jacekradko Mar 13, 2026
4526919
fix(integration): return empty instead of throwing when no staging ap…
jacekradko Mar 13, 2026
fbc6171
fix(integration): use marker instead of null for missing staging keys
jacekradko Mar 13, 2026
bfc0234
fix(integration): use CLERK_API_URL presence instead of env var marker
jacekradko Mar 13, 2026
478a85a
fix(ci): revert temporary jacek/* ref allowlist in e2e-staging
jacekradko Mar 13, 2026
b099c4f
fix: address code review feedback from PR #8060
jacekradko Mar 13, 2026
d41ca3c
fix: format files with prettier
jacekradko Mar 13, 2026
53a7c0c
fix(e2e): guard against missing sk in handshake test mock JWKS server
jacekradko Mar 13, 2026
8e2c064
chore: add empty changeset
jacekradko Mar 13, 2026
664d80e
feat(ci): add 6 staging test suites to e2e-staging workflow matrix
jacekradko Mar 13, 2026
f7bdc57
feat(ci): add fastify turbo tasks and staging script
jacekradko Mar 13, 2026
daee773
refactor(ci): remove duplicate staging scripts, add INTEGRATION_STAGI…
jacekradko Mar 13, 2026
acd0a0c
Merge branch 'main' into jacek/staging-env-swap
jacekradko Mar 13, 2026
4bb2d27
Merge branch 'main' into jacek/staging-env-swap
jacekradko Mar 13, 2026
76c8d09
Merge branch 'main' into jacek/staging-env-swap
jacekradko Mar 13, 2026
ed9dc5f
fix(e2e): skip broken cache-components tests
jacekradko Mar 13, 2026
61dd852
Merge branch 'main' into jacek/staging-env-swap
jacekradko Mar 14, 2026
e54c881
refactor(e2e): simplify env config API with withInstanceKeys and add …
jacekradko Mar 16, 2026
caf2773
style: format envs.ts and longRunningApps.ts with prettier
jacekradko Mar 16, 2026
723b063
fix: address PR review feedback for turbo.json and README
jacekradko Mar 16, 2026
8d9526d
style: format README with prettier
jacekradko Mar 16, 2026
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/staging-env-swap.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
41 changes: 25 additions & 16 deletions .github/workflows/e2e-staging.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,24 +6,24 @@ on:
workflow_dispatch:
inputs:
ref:
description: "Branch to test against"
description: 'Branch to test against'
required: false
default: "main"
default: 'main'
type: string
clerk-go-commit-sha:
description: "clerk_go commit SHA for status reporting"
description: 'clerk_go commit SHA for status reporting'
required: false
type: string
sdk-source:
description: "SDK source: 'latest' uses published @latest from npm, 'ref' builds from the checked-out branch"
required: false
default: "latest"
default: 'latest'
type: choice
options:
- latest
- ref
notify-slack:
description: "Send Slack notification on failure"
description: 'Send Slack notification on failure'
required: false
default: true
type: boolean
Expand All@@ -39,7 +39,7 @@ concurrency:
jobs:
integration-tests:
name: Integration Tests (${{ matrix.test-name }}, ${{ matrix.test-project }})
runs-on: "blacksmith-8vcpu-ubuntu-2204"
runs-on: 'blacksmith-8vcpu-ubuntu-2204'
defaults:
run:
shell: bash
Expand All@@ -49,9 +49,16 @@ jobs:
fail-fast: false
matrix:
test-name:
- "sessions:staging"
- "handshake:staging"
test-project: ["chrome"]
- 'sessions:staging'
- 'handshake:staging'
- 'generic'
- 'cache-components'
- 'express'
- 'hono'
- 'quickstart'
- 'react-router'
- 'tanstack-react-start'
test-project: ['chrome']

steps:
- name: Normalize inputs
Expand DownExpand Up@@ -96,7 +103,7 @@ jobs:
ref: ${{ steps.inputs.outputs.ref }}
fetch-depth: 1
fetch-tags: false
filter: "blob:none"
filter: 'blob:none'
show-progress: false

- name: Setup
Expand DownExpand Up@@ -164,8 +171,8 @@ jobs:
- name: Write all ENV certificates to files in integration/certs
uses: actions/github-script@v7
env:
INTEGRATION_CERTS: "${{ secrets.INTEGRATION_CERTS }}"
INTEGRATION_ROOT_CA: "${{ secrets.INTEGRATION_ROOT_CA }}"
INTEGRATION_CERTS: '${{ secrets.INTEGRATION_CERTS }}'
INTEGRATION_ROOT_CA: '${{ secrets.INTEGRATION_ROOT_CA }}'
with:
script: |
const fs = require('fs');
Expand All@@ -186,14 +193,16 @@ jobs:
timeout-minutes: 25
run: pnpm turbo test:integration:${{ matrix.test-name }} $TURBO_ARGS
env:
E2E_DEBUG: "1"
E2E_DEBUG: '1'
E2E_STAGING: '1'
E2E_SDK_SOURCE: ${{ steps.inputs.outputs.sdk-source }}
E2E_APP_CLERK_JS_DIR: ${{ runner.temp }}
E2E_APP_CLERK_UI_DIR: ${{ runner.temp }}
E2E_CLERK_JS_VERSION: "latest"
E2E_CLERK_UI_VERSION: "latest"
E2E_CLERK_JS_VERSION: 'latest'
E2E_CLERK_UI_VERSION: 'latest'
E2E_PROJECT: ${{ matrix.test-project }}
INTEGRATION_INSTANCE_KEYS: ${{ secrets.INTEGRATION_INSTANCE_KEYS }}
INTEGRATION_STAGING_INSTANCE_KEYS: ${{ secrets.INTEGRATION_STAGING_INSTANCE_KEYS }}
NODE_EXTRA_CA_CERTS: ${{ github.workspace }}/integration/certs/rootCA.pem

- name: Upload test-results
Expand All@@ -208,7 +217,7 @@ jobs:
name: Report Results
needs: [integration-tests]
if: always()
runs-on: "blacksmith-8vcpu-ubuntu-2204"
runs-on: 'blacksmith-8vcpu-ubuntu-2204'
defaults:
run:
shell: bash
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line numberDiff line numberDiff line change
Expand Up@@ -81,6 +81,7 @@ playground/*/yarn.lock

# integration testing
.keys.json
.keys.staging.json
.env.json
.temp_integration
playwright-report
Expand Down
85 changes: 63 additions & 22 deletions integration/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -108,6 +108,43 @@ E2E_APP_SK=sk_test_xxx E2E_APP_PK=pk_test_xxx E2E_APP_URL=http://localhost:3000

You need to replace all environment variables with your specific values/needs, above are just placeholders.

## Running tests against staging

The integration suite supports running tests against Clerk's **staging** environment (`clerkstage.dev`) in addition to the default production environment. This is used to validate SDK behavior against staging API changes before they ship to production.

### How it works

When `E2E_STAGING=1` is set, the `withInstanceKeys` wrapper in [`presets/envs.ts`](../integration/presets/envs.ts) automatically swaps each environment config's PK/SK to their staging equivalents and sets `CLERK_API_URL` to `https://api.clerkstage.dev`. Staging keys are looked up by prefixing the production key name with `clerkstage-` (e.g., `with-email-codes` becomes `clerkstage-with-email-codes`).

If a staging key doesn't exist for a given config, the config is left without a staging API URL. The `isStagingReady` function checks whether a config has been swapped, and long running apps that aren't staging-ready are **gracefully skipped** rather than causing test failures. This means you can run the full test suite against staging even if only a subset of instances have been mirrored.

### Staging keys

Staging keys are stored separately from production keys:

- **Locally**: `.keys.staging.json` (same format as `.keys.json`, gitignored)
- **CI**: `INTEGRATION_STAGING_INSTANCE_KEYS` environment variable (JSON, same format as `INTEGRATION_INSTANCE_KEYS`)

Running `pnpm integration:secrets` will fetch staging keys from 1Password if they exist. If the staging keys field isn't available, the script skips them gracefully.

### Running locally

```shell
E2E_STAGING=1 pnpm test:integration:base
```

Or for a specific preset:

```shell
E2E_STAGING=1 pnpm test:integration:nextjs
```

Tests whose environment configs don't have staging keys will be skipped automatically.

### CI

The [`e2e-staging.yml`](../.github/workflows/e2e-staging.yml) workflow runs integration tests against staging. It sets `E2E_STAGING=1` and provides `INTEGRATION_STAGING_INSTANCE_KEYS` from repository secrets. This workflow is triggered by `workflow_dispatch` (manually or from `clerk_go`) and is typically used to validate staging API changes before release.

## Writing your first test

In this step-by-step instruction you'll learn how to create a new integration test. If your test case already fits into an existing file, please add a new `test()` block instead of creating a whole new file.
Expand DownExpand Up@@ -291,19 +328,14 @@ If you need to run a test suite inside a different environment (e.g. a different
}
```

1. Inside `presets/envs.ts`, create a new environment config:
1. Inside `presets/envs.ts`, create a new environment config wrapped with `withInstanceKeys`:

```ts
const yourConciseName = environmentConfig()
.setId('yourConciseName')
.setEnvVariable('private', 'CLERK_API_URL', process.env.E2E_APP_STAGING_CLERK_API_URL)
.setEnvVariable('private', 'CLERK_SECRET_KEY', envKeys['your-concise-name'].sk)
.setEnvVariable('public', 'CLERK_PUBLISHABLE_KEY', envKeys['your-concise-name'].pk)
.setEnvVariable('public', 'CLERK_SIGN_IN_URL', '/sign-in')
.setEnvVariable('public', 'CLERK_SIGN_UP_URL', '/sign-up')
.setEnvVariable('public', 'CLERK_JS', process.env.E2E_APP_CLERK_JS || 'http://localhost:18211/clerk.browser.js');
const yourConciseName = withInstanceKeys('your-concise-name', base.clone().setId('yourConciseName'));
```

The `withInstanceKeys` wrapper sets PK/SK from the instance keys map and automatically swaps to staging keys when `E2E_STAGING=1` is set. The first argument is the production key name — the staging key is looked up as `clerkstage-your-concise-name`. See [Running tests against staging](#running-tests-against-staging) for more details.

1. Export `yourConciseName` from the file:

```ts
Expand All@@ -313,7 +345,7 @@ If you need to run a test suite inside a different environment (e.g. a different
} as const;
```

1. Ensure that your new keys are added to the `INTEGRATION_INSTANCE_KEYS` environment variable inside the repository so that GitHub actions can successfully run.
1. Ensure that your new keys are added to the `INTEGRATION_INSTANCE_KEYS` environment variable inside the repository so that GitHub actions can successfully run. If you also have staging keys, add them to `INTEGRATION_STAGING_INSTANCE_KEYS`.

## Debugging tests

Expand DownExpand Up@@ -357,7 +389,10 @@ You need to replace the `XXX` in the `path` with the ID of your long running app

### Constants

To get an overview of all the available environment variables you can set, read [`constants.ts`](../integration/constants.ts).
To get an overview of all the available environment variables you can set, read [`constants.ts`](../integration/constants.ts). Notable additions include:

- `E2E_STAGING`: Set to `1` to run tests against the staging environment
- `INTEGRATION_STAGING_INSTANCE_KEYS`: JSON-encoded staging instance keys (used in CI)

### Application configs

Expand DownExpand Up@@ -498,6 +533,8 @@ A long running application is a thin wrapper around an `Application` that expose

Since installing dependencies and booting up an app is a slow operation, long running applications are designed to start **once** in `global.setup` stay open while the tests run, and then stop in `global.teardown` so they can be reused by different suites.

When running in staging mode (`E2E_STAGING=1`), long running apps are filtered by `isStagingReady`. Apps whose environment configs don't have staging keys are excluded from the list — `getByPattern` will return an empty array for those apps instead of throwing, and will log which suites were skipped. At startup, a summary of all skipped apps is also logged. This allows the full test suite to run against staging even when only a subset of instances have been mirrored. If a pattern doesn't match any known app (e.g. a typo), it will still throw an error regardless of staging mode.

You'd define it like so:

```ts
Expand All@@ -522,20 +559,22 @@ Example usage of an existing config (also see [Application](#application)):
await app.withEnv(appConfigs.envs.withEmailCodes);
```

Inside [`presets/envs.ts`](../integration/presets/envs.ts) you can also create a completely new environment config:
Inside [`presets/envs.ts`](../integration/presets/envs.ts) you can also create a completely new environment config. All new configs should be wrapped with `withInstanceKeys` to enable staging environment swapping:

```ts
const withCustomRoles = environmentConfig()
.setId('withCustomRoles')
.setEnvVariable('private', 'CLERK_API_URL', process.env.E2E_APP_STAGING_CLERK_API_URL)
.setEnvVariable('private', 'CLERK_SECRET_KEY', envKeys['with-custom-roles'].sk)
.setEnvVariable('public', 'CLERK_PUBLISHABLE_KEY', envKeys['with-custom-roles'].pk)
.setEnvVariable('public', 'CLERK_SIGN_IN_URL', '/sign-in')
.setEnvVariable('public', 'CLERK_SIGN_UP_URL', '/sign-up')
.setEnvVariable('public', 'CLERK_JS', process.env.E2E_APP_CLERK_JS || 'http://localhost:18211/clerk.browser.js');
const withCustomRoles = withInstanceKeys(
'with-custom-roles',
base
.clone()
.setId('withCustomRoles')
.setEnvVariable('public', 'CLERK_SIGN_IN_URL', '/sign-in')
.setEnvVariable('public', 'CLERK_SIGN_UP_URL', '/sign-up'),
);
```

Read [creating a new environment config](#creating-a-new-environment-config) to learn more.
When `E2E_STAGING=1`, this will automatically look up `clerkstage-with-custom-roles` from the staging keys and swap the PK, SK, and API URL. If the staging key doesn't exist, the config will not be staging-ready and any long running apps using it will be gracefully skipped.

Read [creating a new environment config](#creating-a-new-environment-config) and [running tests against staging](#running-tests-against-staging) to learn more.

### Deployments

Expand DownExpand Up@@ -578,7 +617,9 @@ The integration suite uses [`presets/envs.ts`](../integration/presets/envs.ts) t

This is why you created the `.keys.json` file in the [initial setup](#initial-setup) step. Those secret and publishable keys are used to create environment configs. Inside GitHub actions these keys are provided through the `INTEGRATION_INSTANCE_KEYS` environment variable.

They keys defined in `.keys.json.sample` correspond with the Clerk instances in the **Integration testing** organization.
The keys defined in `.keys.json.sample` correspond with the Clerk instances in the **Integration testing** organization.

For staging tests, a separate `.keys.staging.json` file (or `INTEGRATION_STAGING_INSTANCE_KEYS` env var in CI) provides staging instance keys. These are merged into the key map at startup and used by `withInstanceKeys` when `E2E_STAGING=1` is set. See [Running tests against staging](#running-tests-against-staging) for details.

### Test isolation

Expand Down
1 change: 1 addition & 0 deletions integration/constants.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -86,4 +86,5 @@ export const constants = {
* PK and SK pairs from the env to use for integration tests.
*/
INTEGRATION_INSTANCE_KEYS: process.env.INTEGRATION_INSTANCE_KEYS,
INTEGRATION_STAGING_INSTANCE_KEYS: process.env.INTEGRATION_STAGING_INSTANCE_KEYS,
} as const;
100 changes: 100 additions & 0 deletions integration/presets/__tests__/longRunningApps.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

// Create a Proxy that returns a mock object for any property access (nested)
const deepProxy = (): any =>
new Proxy(
{},
{
get: () => ({}),
},
);

// Mock all preset modules to avoid loading real configs
vi.mock('../astro', () => ({ astro: deepProxy() }));
vi.mock('../expo', () => ({ expo: deepProxy() }));
vi.mock('../express', () => ({ express: deepProxy() }));
vi.mock('../hono', () => ({ hono: deepProxy() }));
vi.mock('../next', () => ({ next: deepProxy() }));
vi.mock('../nuxt', () => ({ nuxt: deepProxy() }));
vi.mock('../react', () => ({ react: deepProxy() }));
vi.mock('../react-router', () => ({ reactRouter: deepProxy() }));
vi.mock('../tanstack', () => ({ tanstack: deepProxy() }));
vi.mock('../vue', () => ({ vue: deepProxy() }));

// Mock longRunningApplication to pass through config as-is
vi.mock('../../models/longRunningApplication', () => ({
longRunningApplication: (params: any) => ({ id: params.id, env: params.env }),
}));

// Mock envs — use a Proxy so any envs.* property returns a unique mock env
const mockIsStagingReady = vi.fn(() => true);
vi.mock('../envs', () => {
const envProxy = new Proxy(
{},
{
get: (_target, prop: string) => ({ __mockEnvId: prop }),
},
);
return {
envs: envProxy,
isStagingReady: (...args: any[]) => mockIsStagingReady(...args),
};
});

describe('createLongRunningApps', () => {
let createLongRunningApps: typeof import('../longRunningApps').createLongRunningApps;

beforeEach(async () => {
vi.resetModules();
mockIsStagingReady.mockImplementation(() => true);
const mod = await import('../longRunningApps');
createLongRunningApps = mod.createLongRunningApps;
});

afterEach(() => {
delete process.env.E2E_STAGING;
});

describe('getByPattern', () => {
it('returns matching apps for a valid exact pattern', () => {
const apps = createLongRunningApps();
const result = apps.getByPattern(['react.vite.withEmailCodes']);
expect(result).toHaveLength(1);
expect(result[0].id).toBe('react.vite.withEmailCodes');
});

it('returns matching apps for a valid glob pattern', () => {
const apps = createLongRunningApps();
const result = apps.getByPattern(['react.vite.*']);
expect(result.length).toBeGreaterThanOrEqual(1);
expect(result.every((r: any) => r.id.startsWith('react.vite.'))).toBe(true);
});

it('throws for an invalid pattern (typo) in normal mode', () => {
const apps = createLongRunningApps();
expect(() => apps.getByPattern(['react.vite.withEmailCodez'])).toThrow(/Could not find long running app with id/);
});

it('throws for an invalid pattern (typo) even when E2E_STAGING=1', () => {
process.env.E2E_STAGING = '1';
const apps = createLongRunningApps();
expect(() => apps.getByPattern(['react.vite.withEmailCodez'])).toThrow(/Could not find long running app with id/);
});

it('returns [] for a known app filtered by isStagingReady when E2E_STAGING=1', () => {
process.env.E2E_STAGING = '1';
// Filter out all apps (simulates no staging keys)
mockIsStagingReady.mockImplementation(() => false);
const apps = createLongRunningApps();
const result = apps.getByPattern(['react.vite.withEmailCodes']);
expect(result).toEqual([]);
});

it('throws for a known app filtered by isStagingReady without E2E_STAGING', () => {
// Filter out all apps
mockIsStagingReady.mockImplementation(() => false);
const apps = createLongRunningApps();
expect(() => apps.getByPattern(['react.vite.withEmailCodes'])).toThrow(/Could not find long running app with id/);
});
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
908f6b3
feat(integration): add E2E_STAGING flag for staging env auto-swap
jacekradko Mar 13, 2026
b2daf97
ci: temporarily allow jacek/* branches in e2e-staging ref validation
jacekradko Mar 13, 2026
4526919
fix(integration): return empty instead of throwing when no staging ap…
jacekradko Mar 13, 2026
fbc6171
fix(integration): use marker instead of null for missing staging keys
jacekradko Mar 13, 2026
bfc0234
fix(integration): use CLERK_API_URL presence instead of env var marker
jacekradko Mar 13, 2026
478a85a
fix(ci): revert temporary jacek/* ref allowlist in e2e-staging
jacekradko Mar 13, 2026
b099c4f
fix: address code review feedback from PR #8060
jacekradko Mar 13, 2026
d41ca3c
fix: format files with prettier
jacekradko Mar 13, 2026
53a7c0c
fix(e2e): guard against missing sk in handshake test mock JWKS server
jacekradko Mar 13, 2026
8e2c064
chore: add empty changeset
jacekradko Mar 13, 2026
664d80e
feat(ci): add 6 staging test suites to e2e-staging workflow matrix
jacekradko Mar 13, 2026
f7bdc57
feat(ci): add fastify turbo tasks and staging script
jacekradko Mar 13, 2026
daee773
refactor(ci): remove duplicate staging scripts, add INTEGRATION_STAGI…
jacekradko Mar 13, 2026
acd0a0c
Merge branch 'main' into jacek/staging-env-swap
jacekradko Mar 13, 2026
4bb2d27
Merge branch 'main' into jacek/staging-env-swap
jacekradko Mar 13, 2026
76c8d09
Merge branch 'main' into jacek/staging-env-swap
jacekradko Mar 13, 2026
ed9dc5f
fix(e2e): skip broken cache-components tests
jacekradko Mar 13, 2026
61dd852
Merge branch 'main' into jacek/staging-env-swap
jacekradko Mar 14, 2026
e54c881
refactor(e2e): simplify env config API with withInstanceKeys and add …
jacekradko Mar 16, 2026
caf2773
style: format envs.ts and longRunningApps.ts with prettier
jacekradko Mar 16, 2026
723b063
fix: address PR review feedback for turbo.json and README
jacekradko Mar 16, 2026
8d9526d
style: format README with prettier
jacekradko Mar 16, 2026
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/staging-env-swap.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
41 changes: 25 additions & 16 deletions .github/workflows/e2e-staging.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,24 +6,24 @@ on:
workflow_dispatch:
inputs:
ref:
description: "Branch to test against"
description: 'Branch to test against'
required: false
default: "main"
default: 'main'
type: string
clerk-go-commit-sha:
description: "clerk_go commit SHA for status reporting"
description: 'clerk_go commit SHA for status reporting'
required: false
type: string
sdk-source:
description: "SDK source: 'latest' uses published @latest from npm, 'ref' builds from the checked-out branch"
required: false
default: "latest"
default: 'latest'
type: choice
options:
- latest
- ref
notify-slack:
description: "Send Slack notification on failure"
description: 'Send Slack notification on failure'
required: false
default: true
type: boolean
Expand All@@ -39,7 +39,7 @@ concurrency:
jobs:
integration-tests:
name: Integration Tests (${{ matrix.test-name }}, ${{ matrix.test-project }})
runs-on: "blacksmith-8vcpu-ubuntu-2204"
runs-on: 'blacksmith-8vcpu-ubuntu-2204'
defaults:
run:
shell: bash
Expand All@@ -49,9 +49,16 @@ jobs:
fail-fast: false
matrix:
test-name:
- "sessions:staging"
- "handshake:staging"
test-project: ["chrome"]
- 'sessions:staging'
- 'handshake:staging'
- 'generic'
- 'cache-components'
- 'express'
- 'hono'
- 'quickstart'
- 'react-router'
- 'tanstack-react-start'
test-project: ['chrome']

steps:
- name: Normalize inputs
Expand DownExpand Up@@ -96,7 +103,7 @@ jobs:
ref: ${{ steps.inputs.outputs.ref }}
fetch-depth: 1
fetch-tags: false
filter: "blob:none"
filter: 'blob:none'
show-progress: false

- name: Setup
Expand DownExpand Up@@ -164,8 +171,8 @@ jobs:
- name: Write all ENV certificates to files in integration/certs
uses: actions/github-script@v7
env:
INTEGRATION_CERTS: "${{ secrets.INTEGRATION_CERTS }}"
INTEGRATION_ROOT_CA: "${{ secrets.INTEGRATION_ROOT_CA }}"
INTEGRATION_CERTS: '${{ secrets.INTEGRATION_CERTS }}'
INTEGRATION_ROOT_CA: '${{ secrets.INTEGRATION_ROOT_CA }}'
with:
script: |
const fs = require('fs');
Expand All@@ -186,14 +193,16 @@ jobs:
timeout-minutes: 25
run: pnpm turbo test:integration:${{ matrix.test-name }} $TURBO_ARGS
env:
E2E_DEBUG: "1"
E2E_DEBUG: '1'
E2E_STAGING: '1'
E2E_SDK_SOURCE: ${{ steps.inputs.outputs.sdk-source }}
E2E_APP_CLERK_JS_DIR: ${{ runner.temp }}
E2E_APP_CLERK_UI_DIR: ${{ runner.temp }}
E2E_CLERK_JS_VERSION: "latest"
E2E_CLERK_UI_VERSION: "latest"
E2E_CLERK_JS_VERSION: 'latest'
E2E_CLERK_UI_VERSION: 'latest'
E2E_PROJECT: ${{ matrix.test-project }}
INTEGRATION_INSTANCE_KEYS: ${{ secrets.INTEGRATION_INSTANCE_KEYS }}
INTEGRATION_STAGING_INSTANCE_KEYS: ${{ secrets.INTEGRATION_STAGING_INSTANCE_KEYS }}
NODE_EXTRA_CA_CERTS: ${{ github.workspace }}/integration/certs/rootCA.pem

- name: Upload test-results
Expand All@@ -208,7 +217,7 @@ jobs:
name: Report Results
needs: [integration-tests]
if: always()
runs-on: "blacksmith-8vcpu-ubuntu-2204"
runs-on: 'blacksmith-8vcpu-ubuntu-2204'
defaults:
run:
shell: bash
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line numberDiff line numberDiff line change
Expand Up@@ -81,6 +81,7 @@ playground/*/yarn.lock

# integration testing
.keys.json
.keys.staging.json
.env.json
.temp_integration
playwright-report
Expand Down
85 changes: 63 additions & 22 deletions integration/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -108,6 +108,43 @@ E2E_APP_SK=sk_test_xxx E2E_APP_PK=pk_test_xxx E2E_APP_URL=http://localhost:3000

You need to replace all environment variables with your specific values/needs, above are just placeholders.

## Running tests against staging

The integration suite supports running tests against Clerk's **staging** environment (`clerkstage.dev`) in addition to the default production environment. This is used to validate SDK behavior against staging API changes before they ship to production.

### How it works

When `E2E_STAGING=1` is set, the `withInstanceKeys` wrapper in [`presets/envs.ts`](../integration/presets/envs.ts) automatically swaps each environment config's PK/SK to their staging equivalents and sets `CLERK_API_URL` to `https://api.clerkstage.dev`. Staging keys are looked up by prefixing the production key name with `clerkstage-` (e.g., `with-email-codes` becomes `clerkstage-with-email-codes`).

If a staging key doesn't exist for a given config, the config is left without a staging API URL. The `isStagingReady` function checks whether a config has been swapped, and long running apps that aren't staging-ready are **gracefully skipped** rather than causing test failures. This means you can run the full test suite against staging even if only a subset of instances have been mirrored.

### Staging keys

Staging keys are stored separately from production keys:

- **Locally**: `.keys.staging.json` (same format as `.keys.json`, gitignored)
- **CI**: `INTEGRATION_STAGING_INSTANCE_KEYS` environment variable (JSON, same format as `INTEGRATION_INSTANCE_KEYS`)

Running `pnpm integration:secrets` will fetch staging keys from 1Password if they exist. If the staging keys field isn't available, the script skips them gracefully.

### Running locally

```shell
E2E_STAGING=1 pnpm test:integration:base
```

Or for a specific preset:

```shell
E2E_STAGING=1 pnpm test:integration:nextjs
```

Tests whose environment configs don't have staging keys will be skipped automatically.

### CI

The [`e2e-staging.yml`](../.github/workflows/e2e-staging.yml) workflow runs integration tests against staging. It sets `E2E_STAGING=1` and provides `INTEGRATION_STAGING_INSTANCE_KEYS` from repository secrets. This workflow is triggered by `workflow_dispatch` (manually or from `clerk_go`) and is typically used to validate staging API changes before release.

## Writing your first test

In this step-by-step instruction you'll learn how to create a new integration test. If your test case already fits into an existing file, please add a new `test()` block instead of creating a whole new file.
Expand DownExpand Up@@ -291,19 +328,14 @@ If you need to run a test suite inside a different environment (e.g. a different
}
```

1. Inside `presets/envs.ts`, create a new environment config:
1. Inside `presets/envs.ts`, create a new environment config wrapped with `withInstanceKeys`:

```ts
const yourConciseName = environmentConfig()
.setId('yourConciseName')
.setEnvVariable('private', 'CLERK_API_URL', process.env.E2E_APP_STAGING_CLERK_API_URL)
.setEnvVariable('private', 'CLERK_SECRET_KEY', envKeys['your-concise-name'].sk)
.setEnvVariable('public', 'CLERK_PUBLISHABLE_KEY', envKeys['your-concise-name'].pk)
.setEnvVariable('public', 'CLERK_SIGN_IN_URL', '/sign-in')
.setEnvVariable('public', 'CLERK_SIGN_UP_URL', '/sign-up')
.setEnvVariable('public', 'CLERK_JS', process.env.E2E_APP_CLERK_JS || 'http://localhost:18211/clerk.browser.js');
const yourConciseName = withInstanceKeys('your-concise-name', base.clone().setId('yourConciseName'));
```

The `withInstanceKeys` wrapper sets PK/SK from the instance keys map and automatically swaps to staging keys when `E2E_STAGING=1` is set. The first argument is the production key name — the staging key is looked up as `clerkstage-your-concise-name`. See [Running tests against staging](#running-tests-against-staging) for more details.

1. Export `yourConciseName` from the file:

```ts
Expand All@@ -313,7 +345,7 @@ If you need to run a test suite inside a different environment (e.g. a different
} as const;
```

1. Ensure that your new keys are added to the `INTEGRATION_INSTANCE_KEYS` environment variable inside the repository so that GitHub actions can successfully run.
1. Ensure that your new keys are added to the `INTEGRATION_INSTANCE_KEYS` environment variable inside the repository so that GitHub actions can successfully run. If you also have staging keys, add them to `INTEGRATION_STAGING_INSTANCE_KEYS`.

## Debugging tests

Expand DownExpand Up@@ -357,7 +389,10 @@ You need to replace the `XXX` in the `path` with the ID of your long running app

### Constants

To get an overview of all the available environment variables you can set, read [`constants.ts`](../integration/constants.ts).
To get an overview of all the available environment variables you can set, read [`constants.ts`](../integration/constants.ts). Notable additions include:

- `E2E_STAGING`: Set to `1` to run tests against the staging environment
- `INTEGRATION_STAGING_INSTANCE_KEYS`: JSON-encoded staging instance keys (used in CI)

### Application configs

Expand DownExpand Up@@ -498,6 +533,8 @@ A long running application is a thin wrapper around an `Application` that expose

Since installing dependencies and booting up an app is a slow operation, long running applications are designed to start **once** in `global.setup` stay open while the tests run, and then stop in `global.teardown` so they can be reused by different suites.

When running in staging mode (`E2E_STAGING=1`), long running apps are filtered by `isStagingReady`. Apps whose environment configs don't have staging keys are excluded from the list — `getByPattern` will return an empty array for those apps instead of throwing, and will log which suites were skipped. At startup, a summary of all skipped apps is also logged. This allows the full test suite to run against staging even when only a subset of instances have been mirrored. If a pattern doesn't match any known app (e.g. a typo), it will still throw an error regardless of staging mode.

You'd define it like so:

```ts
Expand All@@ -522,20 +559,22 @@ Example usage of an existing config (also see [Application](#application)):
await app.withEnv(appConfigs.envs.withEmailCodes);
```

Inside [`presets/envs.ts`](../integration/presets/envs.ts) you can also create a completely new environment config:
Inside [`presets/envs.ts`](../integration/presets/envs.ts) you can also create a completely new environment config. All new configs should be wrapped with `withInstanceKeys` to enable staging environment swapping:

```ts
const withCustomRoles = environmentConfig()
.setId('withCustomRoles')
.setEnvVariable('private', 'CLERK_API_URL', process.env.E2E_APP_STAGING_CLERK_API_URL)
.setEnvVariable('private', 'CLERK_SECRET_KEY', envKeys['with-custom-roles'].sk)
.setEnvVariable('public', 'CLERK_PUBLISHABLE_KEY', envKeys['with-custom-roles'].pk)
.setEnvVariable('public', 'CLERK_SIGN_IN_URL', '/sign-in')
.setEnvVariable('public', 'CLERK_SIGN_UP_URL', '/sign-up')
.setEnvVariable('public', 'CLERK_JS', process.env.E2E_APP_CLERK_JS || 'http://localhost:18211/clerk.browser.js');
const withCustomRoles = withInstanceKeys(
'with-custom-roles',
base
.clone()
.setId('withCustomRoles')
.setEnvVariable('public', 'CLERK_SIGN_IN_URL', '/sign-in')
.setEnvVariable('public', 'CLERK_SIGN_UP_URL', '/sign-up'),
);
```

Read [creating a new environment config](#creating-a-new-environment-config) to learn more.
When `E2E_STAGING=1`, this will automatically look up `clerkstage-with-custom-roles` from the staging keys and swap the PK, SK, and API URL. If the staging key doesn't exist, the config will not be staging-ready and any long running apps using it will be gracefully skipped.

Read [creating a new environment config](#creating-a-new-environment-config) and [running tests against staging](#running-tests-against-staging) to learn more.

### Deployments

Expand DownExpand Up@@ -578,7 +617,9 @@ The integration suite uses [`presets/envs.ts`](../integration/presets/envs.ts) t

This is why you created the `.keys.json` file in the [initial setup](#initial-setup) step. Those secret and publishable keys are used to create environment configs. Inside GitHub actions these keys are provided through the `INTEGRATION_INSTANCE_KEYS` environment variable.

They keys defined in `.keys.json.sample` correspond with the Clerk instances in the **Integration testing** organization.
The keys defined in `.keys.json.sample` correspond with the Clerk instances in the **Integration testing** organization.

For staging tests, a separate `.keys.staging.json` file (or `INTEGRATION_STAGING_INSTANCE_KEYS` env var in CI) provides staging instance keys. These are merged into the key map at startup and used by `withInstanceKeys` when `E2E_STAGING=1` is set. See [Running tests against staging](#running-tests-against-staging) for details.

### Test isolation

Expand Down
1 change: 1 addition & 0 deletions integration/constants.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -86,4 +86,5 @@ export const constants = {
* PK and SK pairs from the env to use for integration tests.
*/
INTEGRATION_INSTANCE_KEYS: process.env.INTEGRATION_INSTANCE_KEYS,
INTEGRATION_STAGING_INSTANCE_KEYS: process.env.INTEGRATION_STAGING_INSTANCE_KEYS,
} as const;
100 changes: 100 additions & 0 deletions integration/presets/__tests__/longRunningApps.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

// Create a Proxy that returns a mock object for any property access (nested)
const deepProxy = (): any =>
new Proxy(
{},
{
get: () => ({}),
},
);

// Mock all preset modules to avoid loading real configs
vi.mock('../astro', () => ({ astro: deepProxy() }));
vi.mock('../expo', () => ({ expo: deepProxy() }));
vi.mock('../express', () => ({ express: deepProxy() }));
vi.mock('../hono', () => ({ hono: deepProxy() }));
vi.mock('../next', () => ({ next: deepProxy() }));
vi.mock('../nuxt', () => ({ nuxt: deepProxy() }));
vi.mock('../react', () => ({ react: deepProxy() }));
vi.mock('../react-router', () => ({ reactRouter: deepProxy() }));
vi.mock('../tanstack', () => ({ tanstack: deepProxy() }));
vi.mock('../vue', () => ({ vue: deepProxy() }));

// Mock longRunningApplication to pass through config as-is
vi.mock('../../models/longRunningApplication', () => ({
longRunningApplication: (params: any) => ({ id: params.id, env: params.env }),
}));

// Mock envs — use a Proxy so any envs.* property returns a unique mock env
const mockIsStagingReady = vi.fn(() => true);
vi.mock('../envs', () => {
const envProxy = new Proxy(
{},
{
get: (_target, prop: string) => ({ __mockEnvId: prop }),
},
);
return {
envs: envProxy,
isStagingReady: (...args: any[]) => mockIsStagingReady(...args),
};
});

describe('createLongRunningApps', () => {
let createLongRunningApps: typeof import('../longRunningApps').createLongRunningApps;

beforeEach(async () => {
vi.resetModules();
mockIsStagingReady.mockImplementation(() => true);
const mod = await import('../longRunningApps');
createLongRunningApps = mod.createLongRunningApps;
});

afterEach(() => {
delete process.env.E2E_STAGING;
});

describe('getByPattern', () => {
it('returns matching apps for a valid exact pattern', () => {
const apps = createLongRunningApps();
const result = apps.getByPattern(['react.vite.withEmailCodes']);
expect(result).toHaveLength(1);
expect(result[0].id).toBe('react.vite.withEmailCodes');
});

it('returns matching apps for a valid glob pattern', () => {
const apps = createLongRunningApps();
const result = apps.getByPattern(['react.vite.*']);
expect(result.length).toBeGreaterThanOrEqual(1);
expect(result.every((r: any) => r.id.startsWith('react.vite.'))).toBe(true);
});

it('throws for an invalid pattern (typo) in normal mode', () => {
const apps = createLongRunningApps();
expect(() => apps.getByPattern(['react.vite.withEmailCodez'])).toThrow(/Could not find long running app with id/);
});

it('throws for an invalid pattern (typo) even when E2E_STAGING=1', () => {
process.env.E2E_STAGING = '1';
const apps = createLongRunningApps();
expect(() => apps.getByPattern(['react.vite.withEmailCodez'])).toThrow(/Could not find long running app with id/);
});

it('returns [] for a known app filtered by isStagingReady when E2E_STAGING=1', () => {
process.env.E2E_STAGING = '1';
// Filter out all apps (simulates no staging keys)
mockIsStagingReady.mockImplementation(() => false);
const apps = createLongRunningApps();
const result = apps.getByPattern(['react.vite.withEmailCodes']);
expect(result).toEqual([]);
});

it('throws for a known app filtered by isStagingReady without E2E_STAGING', () => {
// Filter out all apps
mockIsStagingReady.mockImplementation(() => false);
const apps = createLongRunningApps();
expect(() => apps.getByPattern(['react.vite.withEmailCodes'])).toThrow(/Could not find long running app with id/);
});
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
908f6b3
feat(integration): add E2E_STAGING flag for staging env auto-swap
jacekradko Mar 13, 2026
b2daf97
ci: temporarily allow jacek/* branches in e2e-staging ref validation
jacekradko Mar 13, 2026
4526919
fix(integration): return empty instead of throwing when no staging ap…
jacekradko Mar 13, 2026
fbc6171
fix(integration): use marker instead of null for missing staging keys
jacekradko Mar 13, 2026
bfc0234
fix(integration): use CLERK_API_URL presence instead of env var marker
jacekradko Mar 13, 2026
478a85a
fix(ci): revert temporary jacek/* ref allowlist in e2e-staging
jacekradko Mar 13, 2026
b099c4f
fix: address code review feedback from PR #8060
jacekradko Mar 13, 2026
d41ca3c
fix: format files with prettier
jacekradko Mar 13, 2026
53a7c0c
fix(e2e): guard against missing sk in handshake test mock JWKS server
jacekradko Mar 13, 2026
8e2c064
chore: add empty changeset
jacekradko Mar 13, 2026
664d80e
feat(ci): add 6 staging test suites to e2e-staging workflow matrix
jacekradko Mar 13, 2026
f7bdc57
feat(ci): add fastify turbo tasks and staging script
jacekradko Mar 13, 2026
daee773
refactor(ci): remove duplicate staging scripts, add INTEGRATION_STAGI…
jacekradko Mar 13, 2026
acd0a0c
Merge branch 'main' into jacek/staging-env-swap
jacekradko Mar 13, 2026
4bb2d27
Merge branch 'main' into jacek/staging-env-swap
jacekradko Mar 13, 2026
76c8d09
Merge branch 'main' into jacek/staging-env-swap
jacekradko Mar 13, 2026
ed9dc5f
fix(e2e): skip broken cache-components tests
jacekradko Mar 13, 2026
61dd852
Merge branch 'main' into jacek/staging-env-swap
jacekradko Mar 14, 2026
e54c881
refactor(e2e): simplify env config API with withInstanceKeys and add …
jacekradko Mar 16, 2026
caf2773
style: format envs.ts and longRunningApps.ts with prettier
jacekradko Mar 16, 2026
723b063
fix: address PR review feedback for turbo.json and README
jacekradko Mar 16, 2026
8d9526d
style: format README with prettier
jacekradko Mar 16, 2026
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/staging-env-swap.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
41 changes: 25 additions & 16 deletions .github/workflows/e2e-staging.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,24 +6,24 @@ on:
workflow_dispatch:
inputs:
ref:
description: "Branch to test against"
description: 'Branch to test against'
required: false
default: "main"
default: 'main'
type: string
clerk-go-commit-sha:
description: "clerk_go commit SHA for status reporting"
description: 'clerk_go commit SHA for status reporting'
required: false
type: string
sdk-source:
description: "SDK source: 'latest' uses published @latest from npm, 'ref' builds from the checked-out branch"
required: false
default: "latest"
default: 'latest'
type: choice
options:
- latest
- ref
notify-slack:
description: "Send Slack notification on failure"
description: 'Send Slack notification on failure'
required: false
default: true
type: boolean
Expand All@@ -39,7 +39,7 @@ concurrency:
jobs:
integration-tests:
name: Integration Tests (${{ matrix.test-name }}, ${{ matrix.test-project }})
runs-on: "blacksmith-8vcpu-ubuntu-2204"
runs-on: 'blacksmith-8vcpu-ubuntu-2204'
defaults:
run:
shell: bash
Expand All@@ -49,9 +49,16 @@ jobs:
fail-fast: false
matrix:
test-name:
- "sessions:staging"
- "handshake:staging"
test-project: ["chrome"]
- 'sessions:staging'
- 'handshake:staging'
- 'generic'
- 'cache-components'
- 'express'
- 'hono'
- 'quickstart'
- 'react-router'
- 'tanstack-react-start'
test-project: ['chrome']

steps:
- name: Normalize inputs
Expand DownExpand Up@@ -96,7 +103,7 @@ jobs:
ref: ${{ steps.inputs.outputs.ref }}
fetch-depth: 1
fetch-tags: false
filter: "blob:none"
filter: 'blob:none'
show-progress: false

- name: Setup
Expand DownExpand Up@@ -164,8 +171,8 @@ jobs:
- name: Write all ENV certificates to files in integration/certs
uses: actions/github-script@v7
env:
INTEGRATION_CERTS: "${{ secrets.INTEGRATION_CERTS }}"
INTEGRATION_ROOT_CA: "${{ secrets.INTEGRATION_ROOT_CA }}"
INTEGRATION_CERTS: '${{ secrets.INTEGRATION_CERTS }}'
INTEGRATION_ROOT_CA: '${{ secrets.INTEGRATION_ROOT_CA }}'
with:
script: |
const fs = require('fs');
Expand All@@ -186,14 +193,16 @@ jobs:
timeout-minutes: 25
run: pnpm turbo test:integration:${{ matrix.test-name }} $TURBO_ARGS
env:
E2E_DEBUG: "1"
E2E_DEBUG: '1'
E2E_STAGING: '1'
E2E_SDK_SOURCE: ${{ steps.inputs.outputs.sdk-source }}
E2E_APP_CLERK_JS_DIR: ${{ runner.temp }}
E2E_APP_CLERK_UI_DIR: ${{ runner.temp }}
E2E_CLERK_JS_VERSION: "latest"
E2E_CLERK_UI_VERSION: "latest"
E2E_CLERK_JS_VERSION: 'latest'
E2E_CLERK_UI_VERSION: 'latest'
E2E_PROJECT: ${{ matrix.test-project }}
INTEGRATION_INSTANCE_KEYS: ${{ secrets.INTEGRATION_INSTANCE_KEYS }}
INTEGRATION_STAGING_INSTANCE_KEYS: ${{ secrets.INTEGRATION_STAGING_INSTANCE_KEYS }}
NODE_EXTRA_CA_CERTS: ${{ github.workspace }}/integration/certs/rootCA.pem

- name: Upload test-results
Expand All@@ -208,7 +217,7 @@ jobs:
name: Report Results
needs: [integration-tests]
if: always()
runs-on: "blacksmith-8vcpu-ubuntu-2204"
runs-on: 'blacksmith-8vcpu-ubuntu-2204'
defaults:
run:
shell: bash
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line numberDiff line numberDiff line change
Expand Up@@ -81,6 +81,7 @@ playground/*/yarn.lock

# integration testing
.keys.json
.keys.staging.json
.env.json
.temp_integration
playwright-report
Expand Down
85 changes: 63 additions & 22 deletions integration/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -108,6 +108,43 @@ E2E_APP_SK=sk_test_xxx E2E_APP_PK=pk_test_xxx E2E_APP_URL=http://localhost:3000

You need to replace all environment variables with your specific values/needs, above are just placeholders.

## Running tests against staging

The integration suite supports running tests against Clerk's **staging** environment (`clerkstage.dev`) in addition to the default production environment. This is used to validate SDK behavior against staging API changes before they ship to production.

### How it works

When `E2E_STAGING=1` is set, the `withInstanceKeys` wrapper in [`presets/envs.ts`](../integration/presets/envs.ts) automatically swaps each environment config's PK/SK to their staging equivalents and sets `CLERK_API_URL` to `https://api.clerkstage.dev`. Staging keys are looked up by prefixing the production key name with `clerkstage-` (e.g., `with-email-codes` becomes `clerkstage-with-email-codes`).

If a staging key doesn't exist for a given config, the config is left without a staging API URL. The `isStagingReady` function checks whether a config has been swapped, and long running apps that aren't staging-ready are **gracefully skipped** rather than causing test failures. This means you can run the full test suite against staging even if only a subset of instances have been mirrored.

### Staging keys

Staging keys are stored separately from production keys:

- **Locally**: `.keys.staging.json` (same format as `.keys.json`, gitignored)
- **CI**: `INTEGRATION_STAGING_INSTANCE_KEYS` environment variable (JSON, same format as `INTEGRATION_INSTANCE_KEYS`)

Running `pnpm integration:secrets` will fetch staging keys from 1Password if they exist. If the staging keys field isn't available, the script skips them gracefully.

### Running locally

```shell
E2E_STAGING=1 pnpm test:integration:base
```

Or for a specific preset:

```shell
E2E_STAGING=1 pnpm test:integration:nextjs
```

Tests whose environment configs don't have staging keys will be skipped automatically.

### CI

The [`e2e-staging.yml`](../.github/workflows/e2e-staging.yml) workflow runs integration tests against staging. It sets `E2E_STAGING=1` and provides `INTEGRATION_STAGING_INSTANCE_KEYS` from repository secrets. This workflow is triggered by `workflow_dispatch` (manually or from `clerk_go`) and is typically used to validate staging API changes before release.

## Writing your first test

In this step-by-step instruction you'll learn how to create a new integration test. If your test case already fits into an existing file, please add a new `test()` block instead of creating a whole new file.
Expand DownExpand Up@@ -291,19 +328,14 @@ If you need to run a test suite inside a different environment (e.g. a different
}
```

1. Inside `presets/envs.ts`, create a new environment config:
1. Inside `presets/envs.ts`, create a new environment config wrapped with `withInstanceKeys`:

```ts
const yourConciseName = environmentConfig()
.setId('yourConciseName')
.setEnvVariable('private', 'CLERK_API_URL', process.env.E2E_APP_STAGING_CLERK_API_URL)
.setEnvVariable('private', 'CLERK_SECRET_KEY', envKeys['your-concise-name'].sk)
.setEnvVariable('public', 'CLERK_PUBLISHABLE_KEY', envKeys['your-concise-name'].pk)
.setEnvVariable('public', 'CLERK_SIGN_IN_URL', '/sign-in')
.setEnvVariable('public', 'CLERK_SIGN_UP_URL', '/sign-up')
.setEnvVariable('public', 'CLERK_JS', process.env.E2E_APP_CLERK_JS || 'http://localhost:18211/clerk.browser.js');
const yourConciseName = withInstanceKeys('your-concise-name', base.clone().setId('yourConciseName'));
```

The `withInstanceKeys` wrapper sets PK/SK from the instance keys map and automatically swaps to staging keys when `E2E_STAGING=1` is set. The first argument is the production key name — the staging key is looked up as `clerkstage-your-concise-name`. See [Running tests against staging](#running-tests-against-staging) for more details.

1. Export `yourConciseName` from the file:

```ts
Expand All@@ -313,7 +345,7 @@ If you need to run a test suite inside a different environment (e.g. a different
} as const;
```

1. Ensure that your new keys are added to the `INTEGRATION_INSTANCE_KEYS` environment variable inside the repository so that GitHub actions can successfully run.
1. Ensure that your new keys are added to the `INTEGRATION_INSTANCE_KEYS` environment variable inside the repository so that GitHub actions can successfully run. If you also have staging keys, add them to `INTEGRATION_STAGING_INSTANCE_KEYS`.

## Debugging tests

Expand DownExpand Up@@ -357,7 +389,10 @@ You need to replace the `XXX` in the `path` with the ID of your long running app

### Constants

To get an overview of all the available environment variables you can set, read [`constants.ts`](../integration/constants.ts).
To get an overview of all the available environment variables you can set, read [`constants.ts`](../integration/constants.ts). Notable additions include:

- `E2E_STAGING`: Set to `1` to run tests against the staging environment
- `INTEGRATION_STAGING_INSTANCE_KEYS`: JSON-encoded staging instance keys (used in CI)

### Application configs

Expand DownExpand Up@@ -498,6 +533,8 @@ A long running application is a thin wrapper around an `Application` that expose

Since installing dependencies and booting up an app is a slow operation, long running applications are designed to start **once** in `global.setup` stay open while the tests run, and then stop in `global.teardown` so they can be reused by different suites.

When running in staging mode (`E2E_STAGING=1`), long running apps are filtered by `isStagingReady`. Apps whose environment configs don't have staging keys are excluded from the list — `getByPattern` will return an empty array for those apps instead of throwing, and will log which suites were skipped. At startup, a summary of all skipped apps is also logged. This allows the full test suite to run against staging even when only a subset of instances have been mirrored. If a pattern doesn't match any known app (e.g. a typo), it will still throw an error regardless of staging mode.

You'd define it like so:

```ts
Expand All@@ -522,20 +559,22 @@ Example usage of an existing config (also see [Application](#application)):
await app.withEnv(appConfigs.envs.withEmailCodes);
```

Inside [`presets/envs.ts`](../integration/presets/envs.ts) you can also create a completely new environment config:
Inside [`presets/envs.ts`](../integration/presets/envs.ts) you can also create a completely new environment config. All new configs should be wrapped with `withInstanceKeys` to enable staging environment swapping:

```ts
const withCustomRoles = environmentConfig()
.setId('withCustomRoles')
.setEnvVariable('private', 'CLERK_API_URL', process.env.E2E_APP_STAGING_CLERK_API_URL)
.setEnvVariable('private', 'CLERK_SECRET_KEY', envKeys['with-custom-roles'].sk)
.setEnvVariable('public', 'CLERK_PUBLISHABLE_KEY', envKeys['with-custom-roles'].pk)
.setEnvVariable('public', 'CLERK_SIGN_IN_URL', '/sign-in')
.setEnvVariable('public', 'CLERK_SIGN_UP_URL', '/sign-up')
.setEnvVariable('public', 'CLERK_JS', process.env.E2E_APP_CLERK_JS || 'http://localhost:18211/clerk.browser.js');
const withCustomRoles = withInstanceKeys(
'with-custom-roles',
base
.clone()
.setId('withCustomRoles')
.setEnvVariable('public', 'CLERK_SIGN_IN_URL', '/sign-in')
.setEnvVariable('public', 'CLERK_SIGN_UP_URL', '/sign-up'),
);
```

Read [creating a new environment config](#creating-a-new-environment-config) to learn more.
When `E2E_STAGING=1`, this will automatically look up `clerkstage-with-custom-roles` from the staging keys and swap the PK, SK, and API URL. If the staging key doesn't exist, the config will not be staging-ready and any long running apps using it will be gracefully skipped.

Read [creating a new environment config](#creating-a-new-environment-config) and [running tests against staging](#running-tests-against-staging) to learn more.

### Deployments

Expand DownExpand Up@@ -578,7 +617,9 @@ The integration suite uses [`presets/envs.ts`](../integration/presets/envs.ts) t

This is why you created the `.keys.json` file in the [initial setup](#initial-setup) step. Those secret and publishable keys are used to create environment configs. Inside GitHub actions these keys are provided through the `INTEGRATION_INSTANCE_KEYS` environment variable.

They keys defined in `.keys.json.sample` correspond with the Clerk instances in the **Integration testing** organization.
The keys defined in `.keys.json.sample` correspond with the Clerk instances in the **Integration testing** organization.

For staging tests, a separate `.keys.staging.json` file (or `INTEGRATION_STAGING_INSTANCE_KEYS` env var in CI) provides staging instance keys. These are merged into the key map at startup and used by `withInstanceKeys` when `E2E_STAGING=1` is set. See [Running tests against staging](#running-tests-against-staging) for details.

### Test isolation

Expand Down
1 change: 1 addition & 0 deletions integration/constants.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -86,4 +86,5 @@ export const constants = {
* PK and SK pairs from the env to use for integration tests.
*/
INTEGRATION_INSTANCE_KEYS: process.env.INTEGRATION_INSTANCE_KEYS,
INTEGRATION_STAGING_INSTANCE_KEYS: process.env.INTEGRATION_STAGING_INSTANCE_KEYS,
} as const;
100 changes: 100 additions & 0 deletions integration/presets/__tests__/longRunningApps.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

// Create a Proxy that returns a mock object for any property access (nested)
const deepProxy = (): any =>
new Proxy(
{},
{
get: () => ({}),
},
);

// Mock all preset modules to avoid loading real configs
vi.mock('../astro', () => ({ astro: deepProxy() }));
vi.mock('../expo', () => ({ expo: deepProxy() }));
vi.mock('../express', () => ({ express: deepProxy() }));
vi.mock('../hono', () => ({ hono: deepProxy() }));
vi.mock('../next', () => ({ next: deepProxy() }));
vi.mock('../nuxt', () => ({ nuxt: deepProxy() }));
vi.mock('../react', () => ({ react: deepProxy() }));
vi.mock('../react-router', () => ({ reactRouter: deepProxy() }));
vi.mock('../tanstack', () => ({ tanstack: deepProxy() }));
vi.mock('../vue', () => ({ vue: deepProxy() }));

// Mock longRunningApplication to pass through config as-is
vi.mock('../../models/longRunningApplication', () => ({
longRunningApplication: (params: any) => ({ id: params.id, env: params.env }),
}));

// Mock envs — use a Proxy so any envs.* property returns a unique mock env
const mockIsStagingReady = vi.fn(() => true);
vi.mock('../envs', () => {
const envProxy = new Proxy(
{},
{
get: (_target, prop: string) => ({ __mockEnvId: prop }),
},
);
return {
envs: envProxy,
isStagingReady: (...args: any[]) => mockIsStagingReady(...args),
};
});

describe('createLongRunningApps', () => {
let createLongRunningApps: typeof import('../longRunningApps').createLongRunningApps;

beforeEach(async () => {
vi.resetModules();
mockIsStagingReady.mockImplementation(() => true);
const mod = await import('../longRunningApps');
createLongRunningApps = mod.createLongRunningApps;
});

afterEach(() => {
delete process.env.E2E_STAGING;
});

describe('getByPattern', () => {
it('returns matching apps for a valid exact pattern', () => {
const apps = createLongRunningApps();
const result = apps.getByPattern(['react.vite.withEmailCodes']);
expect(result).toHaveLength(1);
expect(result[0].id).toBe('react.vite.withEmailCodes');
});

it('returns matching apps for a valid glob pattern', () => {
const apps = createLongRunningApps();
const result = apps.getByPattern(['react.vite.*']);
expect(result.length).toBeGreaterThanOrEqual(1);
expect(result.every((r: any) => r.id.startsWith('react.vite.'))).toBe(true);
});

it('throws for an invalid pattern (typo) in normal mode', () => {
const apps = createLongRunningApps();
expect(() => apps.getByPattern(['react.vite.withEmailCodez'])).toThrow(/Could not find long running app with id/);
});

it('throws for an invalid pattern (typo) even when E2E_STAGING=1', () => {
process.env.E2E_STAGING = '1';
const apps = createLongRunningApps();
expect(() => apps.getByPattern(['react.vite.withEmailCodez'])).toThrow(/Could not find long running app with id/);
});

it('returns [] for a known app filtered by isStagingReady when E2E_STAGING=1', () => {
process.env.E2E_STAGING = '1';
// Filter out all apps (simulates no staging keys)
mockIsStagingReady.mockImplementation(() => false);
const apps = createLongRunningApps();
const result = apps.getByPattern(['react.vite.withEmailCodes']);
expect(result).toEqual([]);
});

it('throws for a known app filtered by isStagingReady without E2E_STAGING', () => {
// Filter out all apps
mockIsStagingReady.mockImplementation(() => false);
const apps = createLongRunningApps();
expect(() => apps.getByPattern(['react.vite.withEmailCodes'])).toThrow(/Could not find long running app with id/);
});
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
908f6b3
feat(integration): add E2E_STAGING flag for staging env auto-swap
jacekradko Mar 13, 2026
b2daf97
ci: temporarily allow jacek/* branches in e2e-staging ref validation
jacekradko Mar 13, 2026
4526919
fix(integration): return empty instead of throwing when no staging ap…
jacekradko Mar 13, 2026
fbc6171
fix(integration): use marker instead of null for missing staging keys
jacekradko Mar 13, 2026
bfc0234
fix(integration): use CLERK_API_URL presence instead of env var marker
jacekradko Mar 13, 2026
478a85a
fix(ci): revert temporary jacek/* ref allowlist in e2e-staging
jacekradko Mar 13, 2026
b099c4f
fix: address code review feedback from PR #8060
jacekradko Mar 13, 2026
d41ca3c
fix: format files with prettier
jacekradko Mar 13, 2026
53a7c0c
fix(e2e): guard against missing sk in handshake test mock JWKS server
jacekradko Mar 13, 2026
8e2c064
chore: add empty changeset
jacekradko Mar 13, 2026
664d80e
feat(ci): add 6 staging test suites to e2e-staging workflow matrix
jacekradko Mar 13, 2026
f7bdc57
feat(ci): add fastify turbo tasks and staging script
jacekradko Mar 13, 2026
daee773
refactor(ci): remove duplicate staging scripts, add INTEGRATION_STAGI…
jacekradko Mar 13, 2026
acd0a0c
Merge branch 'main' into jacek/staging-env-swap
jacekradko Mar 13, 2026
4bb2d27
Merge branch 'main' into jacek/staging-env-swap
jacekradko Mar 13, 2026
76c8d09
Merge branch 'main' into jacek/staging-env-swap
jacekradko Mar 13, 2026
ed9dc5f
fix(e2e): skip broken cache-components tests
jacekradko Mar 13, 2026
61dd852
Merge branch 'main' into jacek/staging-env-swap
jacekradko Mar 14, 2026
e54c881
refactor(e2e): simplify env config API with withInstanceKeys and add …
jacekradko Mar 16, 2026
caf2773
style: format envs.ts and longRunningApps.ts with prettier
jacekradko Mar 16, 2026
723b063
fix: address PR review feedback for turbo.json and README
jacekradko Mar 16, 2026
8d9526d
style: format README with prettier
jacekradko Mar 16, 2026
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/staging-env-swap.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
41 changes: 25 additions & 16 deletions .github/workflows/e2e-staging.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,24 +6,24 @@ on:
workflow_dispatch:
inputs:
ref:
description: "Branch to test against"
description: 'Branch to test against'
required: false
default: "main"
default: 'main'
type: string
clerk-go-commit-sha:
description: "clerk_go commit SHA for status reporting"
description: 'clerk_go commit SHA for status reporting'
required: false
type: string
sdk-source:
description: "SDK source: 'latest' uses published @latest from npm, 'ref' builds from the checked-out branch"
required: false
default: "latest"
default: 'latest'
type: choice
options:
- latest
- ref
notify-slack:
description: "Send Slack notification on failure"
description: 'Send Slack notification on failure'
required: false
default: true
type: boolean
Expand All@@ -39,7 +39,7 @@ concurrency:
jobs:
integration-tests:
name: Integration Tests (${{ matrix.test-name }}, ${{ matrix.test-project }})
runs-on: "blacksmith-8vcpu-ubuntu-2204"
runs-on: 'blacksmith-8vcpu-ubuntu-2204'
defaults:
run:
shell: bash
Expand All@@ -49,9 +49,16 @@ jobs:
fail-fast: false
matrix:
test-name:
- "sessions:staging"
- "handshake:staging"
test-project: ["chrome"]
- 'sessions:staging'
- 'handshake:staging'
- 'generic'
- 'cache-components'
- 'express'
- 'hono'
- 'quickstart'
- 'react-router'
- 'tanstack-react-start'
test-project: ['chrome']

steps:
- name: Normalize inputs
Expand DownExpand Up@@ -96,7 +103,7 @@ jobs:
ref: ${{ steps.inputs.outputs.ref }}
fetch-depth: 1
fetch-tags: false
filter: "blob:none"
filter: 'blob:none'
show-progress: false

- name: Setup
Expand DownExpand Up@@ -164,8 +171,8 @@ jobs:
- name: Write all ENV certificates to files in integration/certs
uses: actions/github-script@v7
env:
INTEGRATION_CERTS: "${{ secrets.INTEGRATION_CERTS }}"
INTEGRATION_ROOT_CA: "${{ secrets.INTEGRATION_ROOT_CA }}"
INTEGRATION_CERTS: '${{ secrets.INTEGRATION_CERTS }}'
INTEGRATION_ROOT_CA: '${{ secrets.INTEGRATION_ROOT_CA }}'
with:
script: |
const fs = require('fs');
Expand All@@ -186,14 +193,16 @@ jobs:
timeout-minutes: 25
run: pnpm turbo test:integration:${{ matrix.test-name }} $TURBO_ARGS
env:
E2E_DEBUG: "1"
E2E_DEBUG: '1'
E2E_STAGING: '1'
E2E_SDK_SOURCE: ${{ steps.inputs.outputs.sdk-source }}
E2E_APP_CLERK_JS_DIR: ${{ runner.temp }}
E2E_APP_CLERK_UI_DIR: ${{ runner.temp }}
E2E_CLERK_JS_VERSION: "latest"
E2E_CLERK_UI_VERSION: "latest"
E2E_CLERK_JS_VERSION: 'latest'
E2E_CLERK_UI_VERSION: 'latest'
E2E_PROJECT: ${{ matrix.test-project }}
INTEGRATION_INSTANCE_KEYS: ${{ secrets.INTEGRATION_INSTANCE_KEYS }}
INTEGRATION_STAGING_INSTANCE_KEYS: ${{ secrets.INTEGRATION_STAGING_INSTANCE_KEYS }}
NODE_EXTRA_CA_CERTS: ${{ github.workspace }}/integration/certs/rootCA.pem

- name: Upload test-results
Expand All@@ -208,7 +217,7 @@ jobs:
name: Report Results
needs: [integration-tests]
if: always()
runs-on: "blacksmith-8vcpu-ubuntu-2204"
runs-on: 'blacksmith-8vcpu-ubuntu-2204'
defaults:
run:
shell: bash
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line numberDiff line numberDiff line change
Expand Up@@ -81,6 +81,7 @@ playground/*/yarn.lock

# integration testing
.keys.json
.keys.staging.json
.env.json
.temp_integration
playwright-report
Expand Down
85 changes: 63 additions & 22 deletions integration/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -108,6 +108,43 @@ E2E_APP_SK=sk_test_xxx E2E_APP_PK=pk_test_xxx E2E_APP_URL=http://localhost:3000

You need to replace all environment variables with your specific values/needs, above are just placeholders.

## Running tests against staging

The integration suite supports running tests against Clerk's **staging** environment (`clerkstage.dev`) in addition to the default production environment. This is used to validate SDK behavior against staging API changes before they ship to production.

### How it works

When `E2E_STAGING=1` is set, the `withInstanceKeys` wrapper in [`presets/envs.ts`](../integration/presets/envs.ts) automatically swaps each environment config's PK/SK to their staging equivalents and sets `CLERK_API_URL` to `https://api.clerkstage.dev`. Staging keys are looked up by prefixing the production key name with `clerkstage-` (e.g., `with-email-codes` becomes `clerkstage-with-email-codes`).

If a staging key doesn't exist for a given config, the config is left without a staging API URL. The `isStagingReady` function checks whether a config has been swapped, and long running apps that aren't staging-ready are **gracefully skipped** rather than causing test failures. This means you can run the full test suite against staging even if only a subset of instances have been mirrored.

### Staging keys

Staging keys are stored separately from production keys:

- **Locally**: `.keys.staging.json` (same format as `.keys.json`, gitignored)
- **CI**: `INTEGRATION_STAGING_INSTANCE_KEYS` environment variable (JSON, same format as `INTEGRATION_INSTANCE_KEYS`)

Running `pnpm integration:secrets` will fetch staging keys from 1Password if they exist. If the staging keys field isn't available, the script skips them gracefully.

### Running locally

```shell
E2E_STAGING=1 pnpm test:integration:base
```

Or for a specific preset:

```shell
E2E_STAGING=1 pnpm test:integration:nextjs
```

Tests whose environment configs don't have staging keys will be skipped automatically.

### CI

The [`e2e-staging.yml`](../.github/workflows/e2e-staging.yml) workflow runs integration tests against staging. It sets `E2E_STAGING=1` and provides `INTEGRATION_STAGING_INSTANCE_KEYS` from repository secrets. This workflow is triggered by `workflow_dispatch` (manually or from `clerk_go`) and is typically used to validate staging API changes before release.

## Writing your first test

In this step-by-step instruction you'll learn how to create a new integration test. If your test case already fits into an existing file, please add a new `test()` block instead of creating a whole new file.
Expand DownExpand Up@@ -291,19 +328,14 @@ If you need to run a test suite inside a different environment (e.g. a different
}
```

1. Inside `presets/envs.ts`, create a new environment config:
1. Inside `presets/envs.ts`, create a new environment config wrapped with `withInstanceKeys`:

```ts
const yourConciseName = environmentConfig()
.setId('yourConciseName')
.setEnvVariable('private', 'CLERK_API_URL', process.env.E2E_APP_STAGING_CLERK_API_URL)
.setEnvVariable('private', 'CLERK_SECRET_KEY', envKeys['your-concise-name'].sk)
.setEnvVariable('public', 'CLERK_PUBLISHABLE_KEY', envKeys['your-concise-name'].pk)
.setEnvVariable('public', 'CLERK_SIGN_IN_URL', '/sign-in')
.setEnvVariable('public', 'CLERK_SIGN_UP_URL', '/sign-up')
.setEnvVariable('public', 'CLERK_JS', process.env.E2E_APP_CLERK_JS || 'http://localhost:18211/clerk.browser.js');
const yourConciseName = withInstanceKeys('your-concise-name', base.clone().setId('yourConciseName'));
```

The `withInstanceKeys` wrapper sets PK/SK from the instance keys map and automatically swaps to staging keys when `E2E_STAGING=1` is set. The first argument is the production key name — the staging key is looked up as `clerkstage-your-concise-name`. See [Running tests against staging](#running-tests-against-staging) for more details.

1. Export `yourConciseName` from the file:

```ts
Expand All@@ -313,7 +345,7 @@ If you need to run a test suite inside a different environment (e.g. a different
} as const;
```

1. Ensure that your new keys are added to the `INTEGRATION_INSTANCE_KEYS` environment variable inside the repository so that GitHub actions can successfully run.
1. Ensure that your new keys are added to the `INTEGRATION_INSTANCE_KEYS` environment variable inside the repository so that GitHub actions can successfully run. If you also have staging keys, add them to `INTEGRATION_STAGING_INSTANCE_KEYS`.

## Debugging tests

Expand DownExpand Up@@ -357,7 +389,10 @@ You need to replace the `XXX` in the `path` with the ID of your long running app

### Constants

To get an overview of all the available environment variables you can set, read [`constants.ts`](../integration/constants.ts).
To get an overview of all the available environment variables you can set, read [`constants.ts`](../integration/constants.ts). Notable additions include:

- `E2E_STAGING`: Set to `1` to run tests against the staging environment
- `INTEGRATION_STAGING_INSTANCE_KEYS`: JSON-encoded staging instance keys (used in CI)

### Application configs

Expand DownExpand Up@@ -498,6 +533,8 @@ A long running application is a thin wrapper around an `Application` that expose

Since installing dependencies and booting up an app is a slow operation, long running applications are designed to start **once** in `global.setup` stay open while the tests run, and then stop in `global.teardown` so they can be reused by different suites.

When running in staging mode (`E2E_STAGING=1`), long running apps are filtered by `isStagingReady`. Apps whose environment configs don't have staging keys are excluded from the list — `getByPattern` will return an empty array for those apps instead of throwing, and will log which suites were skipped. At startup, a summary of all skipped apps is also logged. This allows the full test suite to run against staging even when only a subset of instances have been mirrored. If a pattern doesn't match any known app (e.g. a typo), it will still throw an error regardless of staging mode.

You'd define it like so:

```ts
Expand All@@ -522,20 +559,22 @@ Example usage of an existing config (also see [Application](#application)):
await app.withEnv(appConfigs.envs.withEmailCodes);
```

Inside [`presets/envs.ts`](../integration/presets/envs.ts) you can also create a completely new environment config:
Inside [`presets/envs.ts`](../integration/presets/envs.ts) you can also create a completely new environment config. All new configs should be wrapped with `withInstanceKeys` to enable staging environment swapping:

```ts
const withCustomRoles = environmentConfig()
.setId('withCustomRoles')
.setEnvVariable('private', 'CLERK_API_URL', process.env.E2E_APP_STAGING_CLERK_API_URL)
.setEnvVariable('private', 'CLERK_SECRET_KEY', envKeys['with-custom-roles'].sk)
.setEnvVariable('public', 'CLERK_PUBLISHABLE_KEY', envKeys['with-custom-roles'].pk)
.setEnvVariable('public', 'CLERK_SIGN_IN_URL', '/sign-in')
.setEnvVariable('public', 'CLERK_SIGN_UP_URL', '/sign-up')
.setEnvVariable('public', 'CLERK_JS', process.env.E2E_APP_CLERK_JS || 'http://localhost:18211/clerk.browser.js');
const withCustomRoles = withInstanceKeys(
'with-custom-roles',
base
.clone()
.setId('withCustomRoles')
.setEnvVariable('public', 'CLERK_SIGN_IN_URL', '/sign-in')
.setEnvVariable('public', 'CLERK_SIGN_UP_URL', '/sign-up'),
);
```

Read [creating a new environment config](#creating-a-new-environment-config) to learn more.
When `E2E_STAGING=1`, this will automatically look up `clerkstage-with-custom-roles` from the staging keys and swap the PK, SK, and API URL. If the staging key doesn't exist, the config will not be staging-ready and any long running apps using it will be gracefully skipped.

Read [creating a new environment config](#creating-a-new-environment-config) and [running tests against staging](#running-tests-against-staging) to learn more.

### Deployments

Expand DownExpand Up@@ -578,7 +617,9 @@ The integration suite uses [`presets/envs.ts`](../integration/presets/envs.ts) t

This is why you created the `.keys.json` file in the [initial setup](#initial-setup) step. Those secret and publishable keys are used to create environment configs. Inside GitHub actions these keys are provided through the `INTEGRATION_INSTANCE_KEYS` environment variable.

They keys defined in `.keys.json.sample` correspond with the Clerk instances in the **Integration testing** organization.
The keys defined in `.keys.json.sample` correspond with the Clerk instances in the **Integration testing** organization.

For staging tests, a separate `.keys.staging.json` file (or `INTEGRATION_STAGING_INSTANCE_KEYS` env var in CI) provides staging instance keys. These are merged into the key map at startup and used by `withInstanceKeys` when `E2E_STAGING=1` is set. See [Running tests against staging](#running-tests-against-staging) for details.

### Test isolation

Expand Down
1 change: 1 addition & 0 deletions integration/constants.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -86,4 +86,5 @@ export const constants = {
* PK and SK pairs from the env to use for integration tests.
*/
INTEGRATION_INSTANCE_KEYS: process.env.INTEGRATION_INSTANCE_KEYS,
INTEGRATION_STAGING_INSTANCE_KEYS: process.env.INTEGRATION_STAGING_INSTANCE_KEYS,
} as const;
100 changes: 100 additions & 0 deletions integration/presets/__tests__/longRunningApps.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

// Create a Proxy that returns a mock object for any property access (nested)
const deepProxy = (): any =>
new Proxy(
{},
{
get: () => ({}),
},
);

// Mock all preset modules to avoid loading real configs
vi.mock('../astro', () => ({ astro: deepProxy() }));
vi.mock('../expo', () => ({ expo: deepProxy() }));
vi.mock('../express', () => ({ express: deepProxy() }));
vi.mock('../hono', () => ({ hono: deepProxy() }));
vi.mock('../next', () => ({ next: deepProxy() }));
vi.mock('../nuxt', () => ({ nuxt: deepProxy() }));
vi.mock('../react', () => ({ react: deepProxy() }));
vi.mock('../react-router', () => ({ reactRouter: deepProxy() }));
vi.mock('../tanstack', () => ({ tanstack: deepProxy() }));
vi.mock('../vue', () => ({ vue: deepProxy() }));

// Mock longRunningApplication to pass through config as-is
vi.mock('../../models/longRunningApplication', () => ({
longRunningApplication: (params: any) => ({ id: params.id, env: params.env }),
}));

// Mock envs — use a Proxy so any envs.* property returns a unique mock env
const mockIsStagingReady = vi.fn(() => true);
vi.mock('../envs', () => {
const envProxy = new Proxy(
{},
{
get: (_target, prop: string) => ({ __mockEnvId: prop }),
},
);
return {
envs: envProxy,
isStagingReady: (...args: any[]) => mockIsStagingReady(...args),
};
});

describe('createLongRunningApps', () => {
let createLongRunningApps: typeof import('../longRunningApps').createLongRunningApps;

beforeEach(async () => {
vi.resetModules();
mockIsStagingReady.mockImplementation(() => true);
const mod = await import('../longRunningApps');
createLongRunningApps = mod.createLongRunningApps;
});

afterEach(() => {
delete process.env.E2E_STAGING;
});

describe('getByPattern', () => {
it('returns matching apps for a valid exact pattern', () => {
const apps = createLongRunningApps();
const result = apps.getByPattern(['react.vite.withEmailCodes']);
expect(result).toHaveLength(1);
expect(result[0].id).toBe('react.vite.withEmailCodes');
});

it('returns matching apps for a valid glob pattern', () => {
const apps = createLongRunningApps();
const result = apps.getByPattern(['react.vite.*']);
expect(result.length).toBeGreaterThanOrEqual(1);
expect(result.every((r: any) => r.id.startsWith('react.vite.'))).toBe(true);
});

it('throws for an invalid pattern (typo) in normal mode', () => {
const apps = createLongRunningApps();
expect(() => apps.getByPattern(['react.vite.withEmailCodez'])).toThrow(/Could not find long running app with id/);
});

it('throws for an invalid pattern (typo) even when E2E_STAGING=1', () => {
process.env.E2E_STAGING = '1';
const apps = createLongRunningApps();
expect(() => apps.getByPattern(['react.vite.withEmailCodez'])).toThrow(/Could not find long running app with id/);
});

it('returns [] for a known app filtered by isStagingReady when E2E_STAGING=1', () => {
process.env.E2E_STAGING = '1';
// Filter out all apps (simulates no staging keys)
mockIsStagingReady.mockImplementation(() => false);
const apps = createLongRunningApps();
const result = apps.getByPattern(['react.vite.withEmailCodes']);
expect(result).toEqual([]);
});

it('throws for a known app filtered by isStagingReady without E2E_STAGING', () => {
// Filter out all apps
mockIsStagingReady.mockImplementation(() => false);
const apps = createLongRunningApps();
expect(() => apps.getByPattern(['react.vite.withEmailCodes'])).toThrow(/Could not find long running app with id/);
});
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
908f6b3
feat(integration): add E2E_STAGING flag for staging env auto-swap
jacekradko Mar 13, 2026
b2daf97
ci: temporarily allow jacek/* branches in e2e-staging ref validation
jacekradko Mar 13, 2026
4526919
fix(integration): return empty instead of throwing when no staging ap…
jacekradko Mar 13, 2026
fbc6171
fix(integration): use marker instead of null for missing staging keys
jacekradko Mar 13, 2026
bfc0234
fix(integration): use CLERK_API_URL presence instead of env var marker
jacekradko Mar 13, 2026
478a85a
fix(ci): revert temporary jacek/* ref allowlist in e2e-staging
jacekradko Mar 13, 2026
b099c4f
fix: address code review feedback from PR #8060
jacekradko Mar 13, 2026
d41ca3c
fix: format files with prettier
jacekradko Mar 13, 2026
53a7c0c
fix(e2e): guard against missing sk in handshake test mock JWKS server
jacekradko Mar 13, 2026
8e2c064
chore: add empty changeset
jacekradko Mar 13, 2026
664d80e
feat(ci): add 6 staging test suites to e2e-staging workflow matrix
jacekradko Mar 13, 2026
f7bdc57
feat(ci): add fastify turbo tasks and staging script
jacekradko Mar 13, 2026
daee773
refactor(ci): remove duplicate staging scripts, add INTEGRATION_STAGI…
jacekradko Mar 13, 2026
acd0a0c
Merge branch 'main' into jacek/staging-env-swap
jacekradko Mar 13, 2026
4bb2d27
Merge branch 'main' into jacek/staging-env-swap
jacekradko Mar 13, 2026
76c8d09
Merge branch 'main' into jacek/staging-env-swap
jacekradko Mar 13, 2026
ed9dc5f
fix(e2e): skip broken cache-components tests
jacekradko Mar 13, 2026
61dd852
Merge branch 'main' into jacek/staging-env-swap
jacekradko Mar 14, 2026
e54c881
refactor(e2e): simplify env config API with withInstanceKeys and add …
jacekradko Mar 16, 2026
caf2773
style: format envs.ts and longRunningApps.ts with prettier
jacekradko Mar 16, 2026
723b063
fix: address PR review feedback for turbo.json and README
jacekradko Mar 16, 2026
8d9526d
style: format README with prettier
jacekradko Mar 16, 2026
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/staging-env-swap.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
41 changes: 25 additions & 16 deletions .github/workflows/e2e-staging.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,24 +6,24 @@ on:
workflow_dispatch:
inputs:
ref:
description: "Branch to test against"
description: 'Branch to test against'
required: false
default: "main"
default: 'main'
type: string
clerk-go-commit-sha:
description: "clerk_go commit SHA for status reporting"
description: 'clerk_go commit SHA for status reporting'
required: false
type: string
sdk-source:
description: "SDK source: 'latest' uses published @latest from npm, 'ref' builds from the checked-out branch"
required: false
default: "latest"
default: 'latest'
type: choice
options:
- latest
- ref
notify-slack:
description: "Send Slack notification on failure"
description: 'Send Slack notification on failure'
required: false
default: true
type: boolean
Expand All@@ -39,7 +39,7 @@ concurrency:
jobs:
integration-tests:
name: Integration Tests (${{ matrix.test-name }}, ${{ matrix.test-project }})
runs-on: "blacksmith-8vcpu-ubuntu-2204"
runs-on: 'blacksmith-8vcpu-ubuntu-2204'
defaults:
run:
shell: bash
Expand All@@ -49,9 +49,16 @@ jobs:
fail-fast: false
matrix:
test-name:
- "sessions:staging"
- "handshake:staging"
test-project: ["chrome"]
- 'sessions:staging'
- 'handshake:staging'
- 'generic'
- 'cache-components'
- 'express'
- 'hono'
- 'quickstart'
- 'react-router'
- 'tanstack-react-start'
test-project: ['chrome']

steps:
- name: Normalize inputs
Expand DownExpand Up@@ -96,7 +103,7 @@ jobs:
ref: ${{ steps.inputs.outputs.ref }}
fetch-depth: 1
fetch-tags: false
filter: "blob:none"
filter: 'blob:none'
show-progress: false

- name: Setup
Expand DownExpand Up@@ -164,8 +171,8 @@ jobs:
- name: Write all ENV certificates to files in integration/certs
uses: actions/github-script@v7
env:
INTEGRATION_CERTS: "${{ secrets.INTEGRATION_CERTS }}"
INTEGRATION_ROOT_CA: "${{ secrets.INTEGRATION_ROOT_CA }}"
INTEGRATION_CERTS: '${{ secrets.INTEGRATION_CERTS }}'
INTEGRATION_ROOT_CA: '${{ secrets.INTEGRATION_ROOT_CA }}'
with:
script: |
const fs = require('fs');
Expand All@@ -186,14 +193,16 @@ jobs:
timeout-minutes: 25
run: pnpm turbo test:integration:${{ matrix.test-name }} $TURBO_ARGS
env:
E2E_DEBUG: "1"
E2E_DEBUG: '1'
E2E_STAGING: '1'
E2E_SDK_SOURCE: ${{ steps.inputs.outputs.sdk-source }}
E2E_APP_CLERK_JS_DIR: ${{ runner.temp }}
E2E_APP_CLERK_UI_DIR: ${{ runner.temp }}
E2E_CLERK_JS_VERSION: "latest"
E2E_CLERK_UI_VERSION: "latest"
E2E_CLERK_JS_VERSION: 'latest'
E2E_CLERK_UI_VERSION: 'latest'
E2E_PROJECT: ${{ matrix.test-project }}
INTEGRATION_INSTANCE_KEYS: ${{ secrets.INTEGRATION_INSTANCE_KEYS }}
INTEGRATION_STAGING_INSTANCE_KEYS: ${{ secrets.INTEGRATION_STAGING_INSTANCE_KEYS }}
NODE_EXTRA_CA_CERTS: ${{ github.workspace }}/integration/certs/rootCA.pem

- name: Upload test-results
Expand All@@ -208,7 +217,7 @@ jobs:
name: Report Results
needs: [integration-tests]
if: always()
runs-on: "blacksmith-8vcpu-ubuntu-2204"
runs-on: 'blacksmith-8vcpu-ubuntu-2204'
defaults:
run:
shell: bash
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line numberDiff line numberDiff line change
Expand Up@@ -81,6 +81,7 @@ playground/*/yarn.lock

# integration testing
.keys.json
.keys.staging.json
.env.json
.temp_integration
playwright-report
Expand Down
85 changes: 63 additions & 22 deletions integration/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -108,6 +108,43 @@ E2E_APP_SK=sk_test_xxx E2E_APP_PK=pk_test_xxx E2E_APP_URL=http://localhost:3000

You need to replace all environment variables with your specific values/needs, above are just placeholders.

## Running tests against staging

The integration suite supports running tests against Clerk's **staging** environment (`clerkstage.dev`) in addition to the default production environment. This is used to validate SDK behavior against staging API changes before they ship to production.

### How it works

When `E2E_STAGING=1` is set, the `withInstanceKeys` wrapper in [`presets/envs.ts`](../integration/presets/envs.ts) automatically swaps each environment config's PK/SK to their staging equivalents and sets `CLERK_API_URL` to `https://api.clerkstage.dev`. Staging keys are looked up by prefixing the production key name with `clerkstage-` (e.g., `with-email-codes` becomes `clerkstage-with-email-codes`).

If a staging key doesn't exist for a given config, the config is left without a staging API URL. The `isStagingReady` function checks whether a config has been swapped, and long running apps that aren't staging-ready are **gracefully skipped** rather than causing test failures. This means you can run the full test suite against staging even if only a subset of instances have been mirrored.

### Staging keys

Staging keys are stored separately from production keys:

- **Locally**: `.keys.staging.json` (same format as `.keys.json`, gitignored)
- **CI**: `INTEGRATION_STAGING_INSTANCE_KEYS` environment variable (JSON, same format as `INTEGRATION_INSTANCE_KEYS`)

Running `pnpm integration:secrets` will fetch staging keys from 1Password if they exist. If the staging keys field isn't available, the script skips them gracefully.

### Running locally

```shell
E2E_STAGING=1 pnpm test:integration:base
```

Or for a specific preset:

```shell
E2E_STAGING=1 pnpm test:integration:nextjs
```

Tests whose environment configs don't have staging keys will be skipped automatically.

### CI

The [`e2e-staging.yml`](../.github/workflows/e2e-staging.yml) workflow runs integration tests against staging. It sets `E2E_STAGING=1` and provides `INTEGRATION_STAGING_INSTANCE_KEYS` from repository secrets. This workflow is triggered by `workflow_dispatch` (manually or from `clerk_go`) and is typically used to validate staging API changes before release.

## Writing your first test

In this step-by-step instruction you'll learn how to create a new integration test. If your test case already fits into an existing file, please add a new `test()` block instead of creating a whole new file.
Expand DownExpand Up@@ -291,19 +328,14 @@ If you need to run a test suite inside a different environment (e.g. a different
}
```

1. Inside `presets/envs.ts`, create a new environment config:
1. Inside `presets/envs.ts`, create a new environment config wrapped with `withInstanceKeys`:

```ts
const yourConciseName = environmentConfig()
.setId('yourConciseName')
.setEnvVariable('private', 'CLERK_API_URL', process.env.E2E_APP_STAGING_CLERK_API_URL)
.setEnvVariable('private', 'CLERK_SECRET_KEY', envKeys['your-concise-name'].sk)
.setEnvVariable('public', 'CLERK_PUBLISHABLE_KEY', envKeys['your-concise-name'].pk)
.setEnvVariable('public', 'CLERK_SIGN_IN_URL', '/sign-in')
.setEnvVariable('public', 'CLERK_SIGN_UP_URL', '/sign-up')
.setEnvVariable('public', 'CLERK_JS', process.env.E2E_APP_CLERK_JS || 'http://localhost:18211/clerk.browser.js');
const yourConciseName = withInstanceKeys('your-concise-name', base.clone().setId('yourConciseName'));
```

The `withInstanceKeys` wrapper sets PK/SK from the instance keys map and automatically swaps to staging keys when `E2E_STAGING=1` is set. The first argument is the production key name — the staging key is looked up as `clerkstage-your-concise-name`. See [Running tests against staging](#running-tests-against-staging) for more details.

1. Export `yourConciseName` from the file:

```ts
Expand All@@ -313,7 +345,7 @@ If you need to run a test suite inside a different environment (e.g. a different
} as const;
```

1. Ensure that your new keys are added to the `INTEGRATION_INSTANCE_KEYS` environment variable inside the repository so that GitHub actions can successfully run.
1. Ensure that your new keys are added to the `INTEGRATION_INSTANCE_KEYS` environment variable inside the repository so that GitHub actions can successfully run. If you also have staging keys, add them to `INTEGRATION_STAGING_INSTANCE_KEYS`.

## Debugging tests

Expand DownExpand Up@@ -357,7 +389,10 @@ You need to replace the `XXX` in the `path` with the ID of your long running app

### Constants

To get an overview of all the available environment variables you can set, read [`constants.ts`](../integration/constants.ts).
To get an overview of all the available environment variables you can set, read [`constants.ts`](../integration/constants.ts). Notable additions include:

- `E2E_STAGING`: Set to `1` to run tests against the staging environment
- `INTEGRATION_STAGING_INSTANCE_KEYS`: JSON-encoded staging instance keys (used in CI)

### Application configs

Expand DownExpand Up@@ -498,6 +533,8 @@ A long running application is a thin wrapper around an `Application` that expose

Since installing dependencies and booting up an app is a slow operation, long running applications are designed to start **once** in `global.setup` stay open while the tests run, and then stop in `global.teardown` so they can be reused by different suites.

When running in staging mode (`E2E_STAGING=1`), long running apps are filtered by `isStagingReady`. Apps whose environment configs don't have staging keys are excluded from the list — `getByPattern` will return an empty array for those apps instead of throwing, and will log which suites were skipped. At startup, a summary of all skipped apps is also logged. This allows the full test suite to run against staging even when only a subset of instances have been mirrored. If a pattern doesn't match any known app (e.g. a typo), it will still throw an error regardless of staging mode.

You'd define it like so:

```ts
Expand All@@ -522,20 +559,22 @@ Example usage of an existing config (also see [Application](#application)):
await app.withEnv(appConfigs.envs.withEmailCodes);
```

Inside [`presets/envs.ts`](../integration/presets/envs.ts) you can also create a completely new environment config:
Inside [`presets/envs.ts`](../integration/presets/envs.ts) you can also create a completely new environment config. All new configs should be wrapped with `withInstanceKeys` to enable staging environment swapping:

```ts
const withCustomRoles = environmentConfig()
.setId('withCustomRoles')
.setEnvVariable('private', 'CLERK_API_URL', process.env.E2E_APP_STAGING_CLERK_API_URL)
.setEnvVariable('private', 'CLERK_SECRET_KEY', envKeys['with-custom-roles'].sk)
.setEnvVariable('public', 'CLERK_PUBLISHABLE_KEY', envKeys['with-custom-roles'].pk)
.setEnvVariable('public', 'CLERK_SIGN_IN_URL', '/sign-in')
.setEnvVariable('public', 'CLERK_SIGN_UP_URL', '/sign-up')
.setEnvVariable('public', 'CLERK_JS', process.env.E2E_APP_CLERK_JS || 'http://localhost:18211/clerk.browser.js');
const withCustomRoles = withInstanceKeys(
'with-custom-roles',
base
.clone()
.setId('withCustomRoles')
.setEnvVariable('public', 'CLERK_SIGN_IN_URL', '/sign-in')
.setEnvVariable('public', 'CLERK_SIGN_UP_URL', '/sign-up'),
);
```

Read [creating a new environment config](#creating-a-new-environment-config) to learn more.
When `E2E_STAGING=1`, this will automatically look up `clerkstage-with-custom-roles` from the staging keys and swap the PK, SK, and API URL. If the staging key doesn't exist, the config will not be staging-ready and any long running apps using it will be gracefully skipped.

Read [creating a new environment config](#creating-a-new-environment-config) and [running tests against staging](#running-tests-against-staging) to learn more.

### Deployments

Expand DownExpand Up@@ -578,7 +617,9 @@ The integration suite uses [`presets/envs.ts`](../integration/presets/envs.ts) t

This is why you created the `.keys.json` file in the [initial setup](#initial-setup) step. Those secret and publishable keys are used to create environment configs. Inside GitHub actions these keys are provided through the `INTEGRATION_INSTANCE_KEYS` environment variable.

They keys defined in `.keys.json.sample` correspond with the Clerk instances in the **Integration testing** organization.
The keys defined in `.keys.json.sample` correspond with the Clerk instances in the **Integration testing** organization.

For staging tests, a separate `.keys.staging.json` file (or `INTEGRATION_STAGING_INSTANCE_KEYS` env var in CI) provides staging instance keys. These are merged into the key map at startup and used by `withInstanceKeys` when `E2E_STAGING=1` is set. See [Running tests against staging](#running-tests-against-staging) for details.

### Test isolation

Expand Down
1 change: 1 addition & 0 deletions integration/constants.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -86,4 +86,5 @@ export const constants = {
* PK and SK pairs from the env to use for integration tests.
*/
INTEGRATION_INSTANCE_KEYS: process.env.INTEGRATION_INSTANCE_KEYS,
INTEGRATION_STAGING_INSTANCE_KEYS: process.env.INTEGRATION_STAGING_INSTANCE_KEYS,
} as const;
100 changes: 100 additions & 0 deletions integration/presets/__tests__/longRunningApps.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

// Create a Proxy that returns a mock object for any property access (nested)
const deepProxy = (): any =>
new Proxy(
{},
{
get: () => ({}),
},
);

// Mock all preset modules to avoid loading real configs
vi.mock('../astro', () => ({ astro: deepProxy() }));
vi.mock('../expo', () => ({ expo: deepProxy() }));
vi.mock('../express', () => ({ express: deepProxy() }));
vi.mock('../hono', () => ({ hono: deepProxy() }));
vi.mock('../next', () => ({ next: deepProxy() }));
vi.mock('../nuxt', () => ({ nuxt: deepProxy() }));
vi.mock('../react', () => ({ react: deepProxy() }));
vi.mock('../react-router', () => ({ reactRouter: deepProxy() }));
vi.mock('../tanstack', () => ({ tanstack: deepProxy() }));
vi.mock('../vue', () => ({ vue: deepProxy() }));

// Mock longRunningApplication to pass through config as-is
vi.mock('../../models/longRunningApplication', () => ({
longRunningApplication: (params: any) => ({ id: params.id, env: params.env }),
}));

// Mock envs — use a Proxy so any envs.* property returns a unique mock env
const mockIsStagingReady = vi.fn(() => true);
vi.mock('../envs', () => {
const envProxy = new Proxy(
{},
{
get: (_target, prop: string) => ({ __mockEnvId: prop }),
},
);
return {
envs: envProxy,
isStagingReady: (...args: any[]) => mockIsStagingReady(...args),
};
});

describe('createLongRunningApps', () => {
let createLongRunningApps: typeof import('../longRunningApps').createLongRunningApps;

beforeEach(async () => {
vi.resetModules();
mockIsStagingReady.mockImplementation(() => true);
const mod = await import('../longRunningApps');
createLongRunningApps = mod.createLongRunningApps;
});

afterEach(() => {
delete process.env.E2E_STAGING;
});

describe('getByPattern', () => {
it('returns matching apps for a valid exact pattern', () => {
const apps = createLongRunningApps();
const result = apps.getByPattern(['react.vite.withEmailCodes']);
expect(result).toHaveLength(1);
expect(result[0].id).toBe('react.vite.withEmailCodes');
});

it('returns matching apps for a valid glob pattern', () => {
const apps = createLongRunningApps();
const result = apps.getByPattern(['react.vite.*']);
expect(result.length).toBeGreaterThanOrEqual(1);
expect(result.every((r: any) => r.id.startsWith('react.vite.'))).toBe(true);
});

it('throws for an invalid pattern (typo) in normal mode', () => {
const apps = createLongRunningApps();
expect(() => apps.getByPattern(['react.vite.withEmailCodez'])).toThrow(/Could not find long running app with id/);
});

it('throws for an invalid pattern (typo) even when E2E_STAGING=1', () => {
process.env.E2E_STAGING = '1';
const apps = createLongRunningApps();
expect(() => apps.getByPattern(['react.vite.withEmailCodez'])).toThrow(/Could not find long running app with id/);
});

it('returns [] for a known app filtered by isStagingReady when E2E_STAGING=1', () => {
process.env.E2E_STAGING = '1';
// Filter out all apps (simulates no staging keys)
mockIsStagingReady.mockImplementation(() => false);
const apps = createLongRunningApps();
const result = apps.getByPattern(['react.vite.withEmailCodes']);
expect(result).toEqual([]);
});

it('throws for a known app filtered by isStagingReady without E2E_STAGING', () => {
// Filter out all apps
mockIsStagingReady.mockImplementation(() => false);
const apps = createLongRunningApps();
expect(() => apps.getByPattern(['react.vite.withEmailCodes'])).toThrow(/Could not find long running app with id/);
});
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
908f6b3
feat(integration): add E2E_STAGING flag for staging env auto-swap
jacekradko Mar 13, 2026
b2daf97
ci: temporarily allow jacek/* branches in e2e-staging ref validation
jacekradko Mar 13, 2026
4526919
fix(integration): return empty instead of throwing when no staging ap…
jacekradko Mar 13, 2026
fbc6171
fix(integration): use marker instead of null for missing staging keys
jacekradko Mar 13, 2026
bfc0234
fix(integration): use CLERK_API_URL presence instead of env var marker
jacekradko Mar 13, 2026
478a85a
fix(ci): revert temporary jacek/* ref allowlist in e2e-staging
jacekradko Mar 13, 2026
b099c4f
fix: address code review feedback from PR #8060
jacekradko Mar 13, 2026
d41ca3c
fix: format files with prettier
jacekradko Mar 13, 2026
53a7c0c
fix(e2e): guard against missing sk in handshake test mock JWKS server
jacekradko Mar 13, 2026
8e2c064
chore: add empty changeset
jacekradko Mar 13, 2026
664d80e
feat(ci): add 6 staging test suites to e2e-staging workflow matrix
jacekradko Mar 13, 2026
f7bdc57
feat(ci): add fastify turbo tasks and staging script
jacekradko Mar 13, 2026
daee773
refactor(ci): remove duplicate staging scripts, add INTEGRATION_STAGI…
jacekradko Mar 13, 2026
acd0a0c
Merge branch 'main' into jacek/staging-env-swap
jacekradko Mar 13, 2026
4bb2d27
Merge branch 'main' into jacek/staging-env-swap
jacekradko Mar 13, 2026
76c8d09
Merge branch 'main' into jacek/staging-env-swap
jacekradko Mar 13, 2026
ed9dc5f
fix(e2e): skip broken cache-components tests
jacekradko Mar 13, 2026
61dd852
Merge branch 'main' into jacek/staging-env-swap
jacekradko Mar 14, 2026
e54c881
refactor(e2e): simplify env config API with withInstanceKeys and add …
jacekradko Mar 16, 2026
caf2773
style: format envs.ts and longRunningApps.ts with prettier
jacekradko Mar 16, 2026
723b063
fix: address PR review feedback for turbo.json and README
jacekradko Mar 16, 2026
8d9526d
style: format README with prettier
jacekradko Mar 16, 2026
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/staging-env-swap.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
41 changes: 25 additions & 16 deletions .github/workflows/e2e-staging.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,24 +6,24 @@ on:
workflow_dispatch:
inputs:
ref:
description: "Branch to test against"
description: 'Branch to test against'
required: false
default: "main"
default: 'main'
type: string
clerk-go-commit-sha:
description: "clerk_go commit SHA for status reporting"
description: 'clerk_go commit SHA for status reporting'
required: false
type: string
sdk-source:
description: "SDK source: 'latest' uses published @latest from npm, 'ref' builds from the checked-out branch"
required: false
default: "latest"
default: 'latest'
type: choice
options:
- latest
- ref
notify-slack:
description: "Send Slack notification on failure"
description: 'Send Slack notification on failure'
required: false
default: true
type: boolean
Expand All@@ -39,7 +39,7 @@ concurrency:
jobs:
integration-tests:
name: Integration Tests (${{ matrix.test-name }}, ${{ matrix.test-project }})
runs-on: "blacksmith-8vcpu-ubuntu-2204"
runs-on: 'blacksmith-8vcpu-ubuntu-2204'
defaults:
run:
shell: bash
Expand All@@ -49,9 +49,16 @@ jobs:
fail-fast: false
matrix:
test-name:
- "sessions:staging"
- "handshake:staging"
test-project: ["chrome"]
- 'sessions:staging'
- 'handshake:staging'
- 'generic'
- 'cache-components'
- 'express'
- 'hono'
- 'quickstart'
- 'react-router'
- 'tanstack-react-start'
test-project: ['chrome']

steps:
- name: Normalize inputs
Expand DownExpand Up@@ -96,7 +103,7 @@ jobs:
ref: ${{ steps.inputs.outputs.ref }}
fetch-depth: 1
fetch-tags: false
filter: "blob:none"
filter: 'blob:none'
show-progress: false

- name: Setup
Expand DownExpand Up@@ -164,8 +171,8 @@ jobs:
- name: Write all ENV certificates to files in integration/certs
uses: actions/github-script@v7
env:
INTEGRATION_CERTS: "${{ secrets.INTEGRATION_CERTS }}"
INTEGRATION_ROOT_CA: "${{ secrets.INTEGRATION_ROOT_CA }}"
INTEGRATION_CERTS: '${{ secrets.INTEGRATION_CERTS }}'
INTEGRATION_ROOT_CA: '${{ secrets.INTEGRATION_ROOT_CA }}'
with:
script: |
const fs = require('fs');
Expand All@@ -186,14 +193,16 @@ jobs:
timeout-minutes: 25
run: pnpm turbo test:integration:${{ matrix.test-name }} $TURBO_ARGS
env:
E2E_DEBUG: "1"
E2E_DEBUG: '1'
E2E_STAGING: '1'
E2E_SDK_SOURCE: ${{ steps.inputs.outputs.sdk-source }}
E2E_APP_CLERK_JS_DIR: ${{ runner.temp }}
E2E_APP_CLERK_UI_DIR: ${{ runner.temp }}
E2E_CLERK_JS_VERSION: "latest"
E2E_CLERK_UI_VERSION: "latest"
E2E_CLERK_JS_VERSION: 'latest'
E2E_CLERK_UI_VERSION: 'latest'
E2E_PROJECT: ${{ matrix.test-project }}
INTEGRATION_INSTANCE_KEYS: ${{ secrets.INTEGRATION_INSTANCE_KEYS }}
INTEGRATION_STAGING_INSTANCE_KEYS: ${{ secrets.INTEGRATION_STAGING_INSTANCE_KEYS }}
NODE_EXTRA_CA_CERTS: ${{ github.workspace }}/integration/certs/rootCA.pem

- name: Upload test-results
Expand All@@ -208,7 +217,7 @@ jobs:
name: Report Results
needs: [integration-tests]
if: always()
runs-on: "blacksmith-8vcpu-ubuntu-2204"
runs-on: 'blacksmith-8vcpu-ubuntu-2204'
defaults:
run:
shell: bash
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line numberDiff line numberDiff line change
Expand Up@@ -81,6 +81,7 @@ playground/*/yarn.lock

# integration testing
.keys.json
.keys.staging.json
.env.json
.temp_integration
playwright-report
Expand Down
85 changes: 63 additions & 22 deletions integration/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -108,6 +108,43 @@ E2E_APP_SK=sk_test_xxx E2E_APP_PK=pk_test_xxx E2E_APP_URL=http://localhost:3000

You need to replace all environment variables with your specific values/needs, above are just placeholders.

## Running tests against staging

The integration suite supports running tests against Clerk's **staging** environment (`clerkstage.dev`) in addition to the default production environment. This is used to validate SDK behavior against staging API changes before they ship to production.

### How it works

When `E2E_STAGING=1` is set, the `withInstanceKeys` wrapper in [`presets/envs.ts`](../integration/presets/envs.ts) automatically swaps each environment config's PK/SK to their staging equivalents and sets `CLERK_API_URL` to `https://api.clerkstage.dev`. Staging keys are looked up by prefixing the production key name with `clerkstage-` (e.g., `with-email-codes` becomes `clerkstage-with-email-codes`).

If a staging key doesn't exist for a given config, the config is left without a staging API URL. The `isStagingReady` function checks whether a config has been swapped, and long running apps that aren't staging-ready are **gracefully skipped** rather than causing test failures. This means you can run the full test suite against staging even if only a subset of instances have been mirrored.

### Staging keys

Staging keys are stored separately from production keys:

- **Locally**: `.keys.staging.json` (same format as `.keys.json`, gitignored)
- **CI**: `INTEGRATION_STAGING_INSTANCE_KEYS` environment variable (JSON, same format as `INTEGRATION_INSTANCE_KEYS`)

Running `pnpm integration:secrets` will fetch staging keys from 1Password if they exist. If the staging keys field isn't available, the script skips them gracefully.

### Running locally

```shell
E2E_STAGING=1 pnpm test:integration:base
```

Or for a specific preset:

```shell
E2E_STAGING=1 pnpm test:integration:nextjs
```

Tests whose environment configs don't have staging keys will be skipped automatically.

### CI

The [`e2e-staging.yml`](../.github/workflows/e2e-staging.yml) workflow runs integration tests against staging. It sets `E2E_STAGING=1` and provides `INTEGRATION_STAGING_INSTANCE_KEYS` from repository secrets. This workflow is triggered by `workflow_dispatch` (manually or from `clerk_go`) and is typically used to validate staging API changes before release.

## Writing your first test

In this step-by-step instruction you'll learn how to create a new integration test. If your test case already fits into an existing file, please add a new `test()` block instead of creating a whole new file.
Expand DownExpand Up@@ -291,19 +328,14 @@ If you need to run a test suite inside a different environment (e.g. a different
}
```

1. Inside `presets/envs.ts`, create a new environment config:
1. Inside `presets/envs.ts`, create a new environment config wrapped with `withInstanceKeys`:

```ts
const yourConciseName = environmentConfig()
.setId('yourConciseName')
.setEnvVariable('private', 'CLERK_API_URL', process.env.E2E_APP_STAGING_CLERK_API_URL)
.setEnvVariable('private', 'CLERK_SECRET_KEY', envKeys['your-concise-name'].sk)
.setEnvVariable('public', 'CLERK_PUBLISHABLE_KEY', envKeys['your-concise-name'].pk)
.setEnvVariable('public', 'CLERK_SIGN_IN_URL', '/sign-in')
.setEnvVariable('public', 'CLERK_SIGN_UP_URL', '/sign-up')
.setEnvVariable('public', 'CLERK_JS', process.env.E2E_APP_CLERK_JS || 'http://localhost:18211/clerk.browser.js');
const yourConciseName = withInstanceKeys('your-concise-name', base.clone().setId('yourConciseName'));
```

The `withInstanceKeys` wrapper sets PK/SK from the instance keys map and automatically swaps to staging keys when `E2E_STAGING=1` is set. The first argument is the production key name — the staging key is looked up as `clerkstage-your-concise-name`. See [Running tests against staging](#running-tests-against-staging) for more details.

1. Export `yourConciseName` from the file:

```ts
Expand All@@ -313,7 +345,7 @@ If you need to run a test suite inside a different environment (e.g. a different
} as const;
```

1. Ensure that your new keys are added to the `INTEGRATION_INSTANCE_KEYS` environment variable inside the repository so that GitHub actions can successfully run.
1. Ensure that your new keys are added to the `INTEGRATION_INSTANCE_KEYS` environment variable inside the repository so that GitHub actions can successfully run. If you also have staging keys, add them to `INTEGRATION_STAGING_INSTANCE_KEYS`.

## Debugging tests

Expand DownExpand Up@@ -357,7 +389,10 @@ You need to replace the `XXX` in the `path` with the ID of your long running app

### Constants

To get an overview of all the available environment variables you can set, read [`constants.ts`](../integration/constants.ts).
To get an overview of all the available environment variables you can set, read [`constants.ts`](../integration/constants.ts). Notable additions include:

- `E2E_STAGING`: Set to `1` to run tests against the staging environment
- `INTEGRATION_STAGING_INSTANCE_KEYS`: JSON-encoded staging instance keys (used in CI)

### Application configs

Expand DownExpand Up@@ -498,6 +533,8 @@ A long running application is a thin wrapper around an `Application` that expose

Since installing dependencies and booting up an app is a slow operation, long running applications are designed to start **once** in `global.setup` stay open while the tests run, and then stop in `global.teardown` so they can be reused by different suites.

When running in staging mode (`E2E_STAGING=1`), long running apps are filtered by `isStagingReady`. Apps whose environment configs don't have staging keys are excluded from the list — `getByPattern` will return an empty array for those apps instead of throwing, and will log which suites were skipped. At startup, a summary of all skipped apps is also logged. This allows the full test suite to run against staging even when only a subset of instances have been mirrored. If a pattern doesn't match any known app (e.g. a typo), it will still throw an error regardless of staging mode.

You'd define it like so:

```ts
Expand All@@ -522,20 +559,22 @@ Example usage of an existing config (also see [Application](#application)):
await app.withEnv(appConfigs.envs.withEmailCodes);
```

Inside [`presets/envs.ts`](../integration/presets/envs.ts) you can also create a completely new environment config:
Inside [`presets/envs.ts`](../integration/presets/envs.ts) you can also create a completely new environment config. All new configs should be wrapped with `withInstanceKeys` to enable staging environment swapping:

```ts
const withCustomRoles = environmentConfig()
.setId('withCustomRoles')
.setEnvVariable('private', 'CLERK_API_URL', process.env.E2E_APP_STAGING_CLERK_API_URL)
.setEnvVariable('private', 'CLERK_SECRET_KEY', envKeys['with-custom-roles'].sk)
.setEnvVariable('public', 'CLERK_PUBLISHABLE_KEY', envKeys['with-custom-roles'].pk)
.setEnvVariable('public', 'CLERK_SIGN_IN_URL', '/sign-in')
.setEnvVariable('public', 'CLERK_SIGN_UP_URL', '/sign-up')
.setEnvVariable('public', 'CLERK_JS', process.env.E2E_APP_CLERK_JS || 'http://localhost:18211/clerk.browser.js');
const withCustomRoles = withInstanceKeys(
'with-custom-roles',
base
.clone()
.setId('withCustomRoles')
.setEnvVariable('public', 'CLERK_SIGN_IN_URL', '/sign-in')
.setEnvVariable('public', 'CLERK_SIGN_UP_URL', '/sign-up'),
);
```

Read [creating a new environment config](#creating-a-new-environment-config) to learn more.
When `E2E_STAGING=1`, this will automatically look up `clerkstage-with-custom-roles` from the staging keys and swap the PK, SK, and API URL. If the staging key doesn't exist, the config will not be staging-ready and any long running apps using it will be gracefully skipped.

Read [creating a new environment config](#creating-a-new-environment-config) and [running tests against staging](#running-tests-against-staging) to learn more.

### Deployments

Expand DownExpand Up@@ -578,7 +617,9 @@ The integration suite uses [`presets/envs.ts`](../integration/presets/envs.ts) t

This is why you created the `.keys.json` file in the [initial setup](#initial-setup) step. Those secret and publishable keys are used to create environment configs. Inside GitHub actions these keys are provided through the `INTEGRATION_INSTANCE_KEYS` environment variable.

They keys defined in `.keys.json.sample` correspond with the Clerk instances in the **Integration testing** organization.
The keys defined in `.keys.json.sample` correspond with the Clerk instances in the **Integration testing** organization.

For staging tests, a separate `.keys.staging.json` file (or `INTEGRATION_STAGING_INSTANCE_KEYS` env var in CI) provides staging instance keys. These are merged into the key map at startup and used by `withInstanceKeys` when `E2E_STAGING=1` is set. See [Running tests against staging](#running-tests-against-staging) for details.

### Test isolation

Expand Down
1 change: 1 addition & 0 deletions integration/constants.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -86,4 +86,5 @@ export const constants = {
* PK and SK pairs from the env to use for integration tests.
*/
INTEGRATION_INSTANCE_KEYS: process.env.INTEGRATION_INSTANCE_KEYS,
INTEGRATION_STAGING_INSTANCE_KEYS: process.env.INTEGRATION_STAGING_INSTANCE_KEYS,
} as const;
100 changes: 100 additions & 0 deletions integration/presets/__tests__/longRunningApps.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

// Create a Proxy that returns a mock object for any property access (nested)
const deepProxy = (): any =>
new Proxy(
{},
{
get: () => ({}),
},
);

// Mock all preset modules to avoid loading real configs
vi.mock('../astro', () => ({ astro: deepProxy() }));
vi.mock('../expo', () => ({ expo: deepProxy() }));
vi.mock('../express', () => ({ express: deepProxy() }));
vi.mock('../hono', () => ({ hono: deepProxy() }));
vi.mock('../next', () => ({ next: deepProxy() }));
vi.mock('../nuxt', () => ({ nuxt: deepProxy() }));
vi.mock('../react', () => ({ react: deepProxy() }));
vi.mock('../react-router', () => ({ reactRouter: deepProxy() }));
vi.mock('../tanstack', () => ({ tanstack: deepProxy() }));
vi.mock('../vue', () => ({ vue: deepProxy() }));

// Mock longRunningApplication to pass through config as-is
vi.mock('../../models/longRunningApplication', () => ({
longRunningApplication: (params: any) => ({ id: params.id, env: params.env }),
}));

// Mock envs — use a Proxy so any envs.* property returns a unique mock env
const mockIsStagingReady = vi.fn(() => true);
vi.mock('../envs', () => {
const envProxy = new Proxy(
{},
{
get: (_target, prop: string) => ({ __mockEnvId: prop }),
},
);
return {
envs: envProxy,
isStagingReady: (...args: any[]) => mockIsStagingReady(...args),
};
});

describe('createLongRunningApps', () => {
let createLongRunningApps: typeof import('../longRunningApps').createLongRunningApps;

beforeEach(async () => {
vi.resetModules();
mockIsStagingReady.mockImplementation(() => true);
const mod = await import('../longRunningApps');
createLongRunningApps = mod.createLongRunningApps;
});

afterEach(() => {
delete process.env.E2E_STAGING;
});

describe('getByPattern', () => {
it('returns matching apps for a valid exact pattern', () => {
const apps = createLongRunningApps();
const result = apps.getByPattern(['react.vite.withEmailCodes']);
expect(result).toHaveLength(1);
expect(result[0].id).toBe('react.vite.withEmailCodes');
});

it('returns matching apps for a valid glob pattern', () => {
const apps = createLongRunningApps();
const result = apps.getByPattern(['react.vite.*']);
expect(result.length).toBeGreaterThanOrEqual(1);
expect(result.every((r: any) => r.id.startsWith('react.vite.'))).toBe(true);
});

it('throws for an invalid pattern (typo) in normal mode', () => {
const apps = createLongRunningApps();
expect(() => apps.getByPattern(['react.vite.withEmailCodez'])).toThrow(/Could not find long running app with id/);
});

it('throws for an invalid pattern (typo) even when E2E_STAGING=1', () => {
process.env.E2E_STAGING = '1';
const apps = createLongRunningApps();
expect(() => apps.getByPattern(['react.vite.withEmailCodez'])).toThrow(/Could not find long running app with id/);
});

it('returns [] for a known app filtered by isStagingReady when E2E_STAGING=1', () => {
process.env.E2E_STAGING = '1';
// Filter out all apps (simulates no staging keys)
mockIsStagingReady.mockImplementation(() => false);
const apps = createLongRunningApps();
const result = apps.getByPattern(['react.vite.withEmailCodes']);
expect(result).toEqual([]);
});

it('throws for a known app filtered by isStagingReady without E2E_STAGING', () => {
// Filter out all apps
mockIsStagingReady.mockImplementation(() => false);
const apps = createLongRunningApps();
expect(() => apps.getByPattern(['react.vite.withEmailCodes'])).toThrow(/Could not find long running app with id/);
});
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
908f6b3
feat(integration): add E2E_STAGING flag for staging env auto-swap
jacekradko Mar 13, 2026
b2daf97
ci: temporarily allow jacek/* branches in e2e-staging ref validation
jacekradko Mar 13, 2026
4526919
fix(integration): return empty instead of throwing when no staging ap…
jacekradko Mar 13, 2026
fbc6171
fix(integration): use marker instead of null for missing staging keys
jacekradko Mar 13, 2026
bfc0234
fix(integration): use CLERK_API_URL presence instead of env var marker
jacekradko Mar 13, 2026
478a85a
fix(ci): revert temporary jacek/* ref allowlist in e2e-staging
jacekradko Mar 13, 2026
b099c4f
fix: address code review feedback from PR #8060
jacekradko Mar 13, 2026
d41ca3c
fix: format files with prettier
jacekradko Mar 13, 2026
53a7c0c
fix(e2e): guard against missing sk in handshake test mock JWKS server
jacekradko Mar 13, 2026
8e2c064
chore: add empty changeset
jacekradko Mar 13, 2026
664d80e
feat(ci): add 6 staging test suites to e2e-staging workflow matrix
jacekradko Mar 13, 2026
f7bdc57
feat(ci): add fastify turbo tasks and staging script
jacekradko Mar 13, 2026
daee773
refactor(ci): remove duplicate staging scripts, add INTEGRATION_STAGI…
jacekradko Mar 13, 2026
acd0a0c
Merge branch 'main' into jacek/staging-env-swap
jacekradko Mar 13, 2026
4bb2d27
Merge branch 'main' into jacek/staging-env-swap
jacekradko Mar 13, 2026
76c8d09
Merge branch 'main' into jacek/staging-env-swap
jacekradko Mar 13, 2026
ed9dc5f
fix(e2e): skip broken cache-components tests
jacekradko Mar 13, 2026
61dd852
Merge branch 'main' into jacek/staging-env-swap
jacekradko Mar 14, 2026
e54c881
refactor(e2e): simplify env config API with withInstanceKeys and add …
jacekradko Mar 16, 2026
caf2773
style: format envs.ts and longRunningApps.ts with prettier
jacekradko Mar 16, 2026
723b063
fix: address PR review feedback for turbo.json and README
jacekradko Mar 16, 2026
8d9526d
style: format README with prettier
jacekradko Mar 16, 2026
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/staging-env-swap.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
41 changes: 25 additions & 16 deletions .github/workflows/e2e-staging.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,24 +6,24 @@ on:
workflow_dispatch:
inputs:
ref:
description: "Branch to test against"
description: 'Branch to test against'
required: false
default: "main"
default: 'main'
type: string
clerk-go-commit-sha:
description: "clerk_go commit SHA for status reporting"
description: 'clerk_go commit SHA for status reporting'
required: false
type: string
sdk-source:
description: "SDK source: 'latest' uses published @latest from npm, 'ref' builds from the checked-out branch"
required: false
default: "latest"
default: 'latest'
type: choice
options:
- latest
- ref
notify-slack:
description: "Send Slack notification on failure"
description: 'Send Slack notification on failure'
required: false
default: true
type: boolean
Expand All@@ -39,7 +39,7 @@ concurrency:
jobs:
integration-tests:
name: Integration Tests (${{ matrix.test-name }}, ${{ matrix.test-project }})
runs-on: "blacksmith-8vcpu-ubuntu-2204"
runs-on: 'blacksmith-8vcpu-ubuntu-2204'
defaults:
run:
shell: bash
Expand All@@ -49,9 +49,16 @@ jobs:
fail-fast: false
matrix:
test-name:
- "sessions:staging"
- "handshake:staging"
test-project: ["chrome"]
- 'sessions:staging'
- 'handshake:staging'
- 'generic'
- 'cache-components'
- 'express'
- 'hono'
- 'quickstart'
- 'react-router'
- 'tanstack-react-start'
test-project: ['chrome']

steps:
- name: Normalize inputs
Expand DownExpand Up@@ -96,7 +103,7 @@ jobs:
ref: ${{ steps.inputs.outputs.ref }}
fetch-depth: 1
fetch-tags: false
filter: "blob:none"
filter: 'blob:none'
show-progress: false

- name: Setup
Expand DownExpand Up@@ -164,8 +171,8 @@ jobs:
- name: Write all ENV certificates to files in integration/certs
uses: actions/github-script@v7
env:
INTEGRATION_CERTS: "${{ secrets.INTEGRATION_CERTS }}"
INTEGRATION_ROOT_CA: "${{ secrets.INTEGRATION_ROOT_CA }}"
INTEGRATION_CERTS: '${{ secrets.INTEGRATION_CERTS }}'
INTEGRATION_ROOT_CA: '${{ secrets.INTEGRATION_ROOT_CA }}'
with:
script: |
const fs = require('fs');
Expand All@@ -186,14 +193,16 @@ jobs:
timeout-minutes: 25
run: pnpm turbo test:integration:${{ matrix.test-name }} $TURBO_ARGS
env:
E2E_DEBUG: "1"
E2E_DEBUG: '1'
E2E_STAGING: '1'
E2E_SDK_SOURCE: ${{ steps.inputs.outputs.sdk-source }}
E2E_APP_CLERK_JS_DIR: ${{ runner.temp }}
E2E_APP_CLERK_UI_DIR: ${{ runner.temp }}
E2E_CLERK_JS_VERSION: "latest"
E2E_CLERK_UI_VERSION: "latest"
E2E_CLERK_JS_VERSION: 'latest'
E2E_CLERK_UI_VERSION: 'latest'
E2E_PROJECT: ${{ matrix.test-project }}
INTEGRATION_INSTANCE_KEYS: ${{ secrets.INTEGRATION_INSTANCE_KEYS }}
INTEGRATION_STAGING_INSTANCE_KEYS: ${{ secrets.INTEGRATION_STAGING_INSTANCE_KEYS }}
NODE_EXTRA_CA_CERTS: ${{ github.workspace }}/integration/certs/rootCA.pem

- name: Upload test-results
Expand All@@ -208,7 +217,7 @@ jobs:
name: Report Results
needs: [integration-tests]
if: always()
runs-on: "blacksmith-8vcpu-ubuntu-2204"
runs-on: 'blacksmith-8vcpu-ubuntu-2204'
defaults:
run:
shell: bash
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line numberDiff line numberDiff line change
Expand Up@@ -81,6 +81,7 @@ playground/*/yarn.lock

# integration testing
.keys.json
.keys.staging.json
.env.json
.temp_integration
playwright-report
Expand Down
85 changes: 63 additions & 22 deletions integration/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -108,6 +108,43 @@ E2E_APP_SK=sk_test_xxx E2E_APP_PK=pk_test_xxx E2E_APP_URL=http://localhost:3000

You need to replace all environment variables with your specific values/needs, above are just placeholders.

## Running tests against staging

The integration suite supports running tests against Clerk's **staging** environment (`clerkstage.dev`) in addition to the default production environment. This is used to validate SDK behavior against staging API changes before they ship to production.

### How it works

When `E2E_STAGING=1` is set, the `withInstanceKeys` wrapper in [`presets/envs.ts`](../integration/presets/envs.ts) automatically swaps each environment config's PK/SK to their staging equivalents and sets `CLERK_API_URL` to `https://api.clerkstage.dev`. Staging keys are looked up by prefixing the production key name with `clerkstage-` (e.g., `with-email-codes` becomes `clerkstage-with-email-codes`).

If a staging key doesn't exist for a given config, the config is left without a staging API URL. The `isStagingReady` function checks whether a config has been swapped, and long running apps that aren't staging-ready are **gracefully skipped** rather than causing test failures. This means you can run the full test suite against staging even if only a subset of instances have been mirrored.

### Staging keys

Staging keys are stored separately from production keys:

- **Locally**: `.keys.staging.json` (same format as `.keys.json`, gitignored)
- **CI**: `INTEGRATION_STAGING_INSTANCE_KEYS` environment variable (JSON, same format as `INTEGRATION_INSTANCE_KEYS`)

Running `pnpm integration:secrets` will fetch staging keys from 1Password if they exist. If the staging keys field isn't available, the script skips them gracefully.

### Running locally

```shell
E2E_STAGING=1 pnpm test:integration:base
```

Or for a specific preset:

```shell
E2E_STAGING=1 pnpm test:integration:nextjs
```

Tests whose environment configs don't have staging keys will be skipped automatically.

### CI

The [`e2e-staging.yml`](../.github/workflows/e2e-staging.yml) workflow runs integration tests against staging. It sets `E2E_STAGING=1` and provides `INTEGRATION_STAGING_INSTANCE_KEYS` from repository secrets. This workflow is triggered by `workflow_dispatch` (manually or from `clerk_go`) and is typically used to validate staging API changes before release.

## Writing your first test

In this step-by-step instruction you'll learn how to create a new integration test. If your test case already fits into an existing file, please add a new `test()` block instead of creating a whole new file.
Expand DownExpand Up@@ -291,19 +328,14 @@ If you need to run a test suite inside a different environment (e.g. a different
}
```

1. Inside `presets/envs.ts`, create a new environment config:
1. Inside `presets/envs.ts`, create a new environment config wrapped with `withInstanceKeys`:

```ts
const yourConciseName = environmentConfig()
.setId('yourConciseName')
.setEnvVariable('private', 'CLERK_API_URL', process.env.E2E_APP_STAGING_CLERK_API_URL)
.setEnvVariable('private', 'CLERK_SECRET_KEY', envKeys['your-concise-name'].sk)
.setEnvVariable('public', 'CLERK_PUBLISHABLE_KEY', envKeys['your-concise-name'].pk)
.setEnvVariable('public', 'CLERK_SIGN_IN_URL', '/sign-in')
.setEnvVariable('public', 'CLERK_SIGN_UP_URL', '/sign-up')
.setEnvVariable('public', 'CLERK_JS', process.env.E2E_APP_CLERK_JS || 'http://localhost:18211/clerk.browser.js');
const yourConciseName = withInstanceKeys('your-concise-name', base.clone().setId('yourConciseName'));
```

The `withInstanceKeys` wrapper sets PK/SK from the instance keys map and automatically swaps to staging keys when `E2E_STAGING=1` is set. The first argument is the production key name — the staging key is looked up as `clerkstage-your-concise-name`. See [Running tests against staging](#running-tests-against-staging) for more details.

1. Export `yourConciseName` from the file:

```ts
Expand All@@ -313,7 +345,7 @@ If you need to run a test suite inside a different environment (e.g. a different
} as const;
```

1. Ensure that your new keys are added to the `INTEGRATION_INSTANCE_KEYS` environment variable inside the repository so that GitHub actions can successfully run.
1. Ensure that your new keys are added to the `INTEGRATION_INSTANCE_KEYS` environment variable inside the repository so that GitHub actions can successfully run. If you also have staging keys, add them to `INTEGRATION_STAGING_INSTANCE_KEYS`.

## Debugging tests

Expand DownExpand Up@@ -357,7 +389,10 @@ You need to replace the `XXX` in the `path` with the ID of your long running app

### Constants

To get an overview of all the available environment variables you can set, read [`constants.ts`](../integration/constants.ts).
To get an overview of all the available environment variables you can set, read [`constants.ts`](../integration/constants.ts). Notable additions include:

- `E2E_STAGING`: Set to `1` to run tests against the staging environment
- `INTEGRATION_STAGING_INSTANCE_KEYS`: JSON-encoded staging instance keys (used in CI)

### Application configs

Expand DownExpand Up@@ -498,6 +533,8 @@ A long running application is a thin wrapper around an `Application` that expose

Since installing dependencies and booting up an app is a slow operation, long running applications are designed to start **once** in `global.setup` stay open while the tests run, and then stop in `global.teardown` so they can be reused by different suites.

When running in staging mode (`E2E_STAGING=1`), long running apps are filtered by `isStagingReady`. Apps whose environment configs don't have staging keys are excluded from the list — `getByPattern` will return an empty array for those apps instead of throwing, and will log which suites were skipped. At startup, a summary of all skipped apps is also logged. This allows the full test suite to run against staging even when only a subset of instances have been mirrored. If a pattern doesn't match any known app (e.g. a typo), it will still throw an error regardless of staging mode.

You'd define it like so:

```ts
Expand All@@ -522,20 +559,22 @@ Example usage of an existing config (also see [Application](#application)):
await app.withEnv(appConfigs.envs.withEmailCodes);
```

Inside [`presets/envs.ts`](../integration/presets/envs.ts) you can also create a completely new environment config:
Inside [`presets/envs.ts`](../integration/presets/envs.ts) you can also create a completely new environment config. All new configs should be wrapped with `withInstanceKeys` to enable staging environment swapping:

```ts
const withCustomRoles = environmentConfig()
.setId('withCustomRoles')
.setEnvVariable('private', 'CLERK_API_URL', process.env.E2E_APP_STAGING_CLERK_API_URL)
.setEnvVariable('private', 'CLERK_SECRET_KEY', envKeys['with-custom-roles'].sk)
.setEnvVariable('public', 'CLERK_PUBLISHABLE_KEY', envKeys['with-custom-roles'].pk)
.setEnvVariable('public', 'CLERK_SIGN_IN_URL', '/sign-in')
.setEnvVariable('public', 'CLERK_SIGN_UP_URL', '/sign-up')
.setEnvVariable('public', 'CLERK_JS', process.env.E2E_APP_CLERK_JS || 'http://localhost:18211/clerk.browser.js');
const withCustomRoles = withInstanceKeys(
'with-custom-roles',
base
.clone()
.setId('withCustomRoles')
.setEnvVariable('public', 'CLERK_SIGN_IN_URL', '/sign-in')
.setEnvVariable('public', 'CLERK_SIGN_UP_URL', '/sign-up'),
);
```

Read [creating a new environment config](#creating-a-new-environment-config) to learn more.
When `E2E_STAGING=1`, this will automatically look up `clerkstage-with-custom-roles` from the staging keys and swap the PK, SK, and API URL. If the staging key doesn't exist, the config will not be staging-ready and any long running apps using it will be gracefully skipped.

Read [creating a new environment config](#creating-a-new-environment-config) and [running tests against staging](#running-tests-against-staging) to learn more.

### Deployments

Expand DownExpand Up@@ -578,7 +617,9 @@ The integration suite uses [`presets/envs.ts`](../integration/presets/envs.ts) t

This is why you created the `.keys.json` file in the [initial setup](#initial-setup) step. Those secret and publishable keys are used to create environment configs. Inside GitHub actions these keys are provided through the `INTEGRATION_INSTANCE_KEYS` environment variable.

They keys defined in `.keys.json.sample` correspond with the Clerk instances in the **Integration testing** organization.
The keys defined in `.keys.json.sample` correspond with the Clerk instances in the **Integration testing** organization.

For staging tests, a separate `.keys.staging.json` file (or `INTEGRATION_STAGING_INSTANCE_KEYS` env var in CI) provides staging instance keys. These are merged into the key map at startup and used by `withInstanceKeys` when `E2E_STAGING=1` is set. See [Running tests against staging](#running-tests-against-staging) for details.

### Test isolation

Expand Down
1 change: 1 addition & 0 deletions integration/constants.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -86,4 +86,5 @@ export const constants = {
* PK and SK pairs from the env to use for integration tests.
*/
INTEGRATION_INSTANCE_KEYS: process.env.INTEGRATION_INSTANCE_KEYS,
INTEGRATION_STAGING_INSTANCE_KEYS: process.env.INTEGRATION_STAGING_INSTANCE_KEYS,
} as const;
100 changes: 100 additions & 0 deletions integration/presets/__tests__/longRunningApps.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

// Create a Proxy that returns a mock object for any property access (nested)
const deepProxy = (): any =>
new Proxy(
{},
{
get: () => ({}),
},
);

// Mock all preset modules to avoid loading real configs
vi.mock('../astro', () => ({ astro: deepProxy() }));
vi.mock('../expo', () => ({ expo: deepProxy() }));
vi.mock('../express', () => ({ express: deepProxy() }));
vi.mock('../hono', () => ({ hono: deepProxy() }));
vi.mock('../next', () => ({ next: deepProxy() }));
vi.mock('../nuxt', () => ({ nuxt: deepProxy() }));
vi.mock('../react', () => ({ react: deepProxy() }));
vi.mock('../react-router', () => ({ reactRouter: deepProxy() }));
vi.mock('../tanstack', () => ({ tanstack: deepProxy() }));
vi.mock('../vue', () => ({ vue: deepProxy() }));

// Mock longRunningApplication to pass through config as-is
vi.mock('../../models/longRunningApplication', () => ({
longRunningApplication: (params: any) => ({ id: params.id, env: params.env }),
}));

// Mock envs — use a Proxy so any envs.* property returns a unique mock env
const mockIsStagingReady = vi.fn(() => true);
vi.mock('../envs', () => {
const envProxy = new Proxy(
{},
{
get: (_target, prop: string) => ({ __mockEnvId: prop }),
},
);
return {
envs: envProxy,
isStagingReady: (...args: any[]) => mockIsStagingReady(...args),
};
});

describe('createLongRunningApps', () => {
let createLongRunningApps: typeof import('../longRunningApps').createLongRunningApps;

beforeEach(async () => {
vi.resetModules();
mockIsStagingReady.mockImplementation(() => true);
const mod = await import('../longRunningApps');
createLongRunningApps = mod.createLongRunningApps;
});

afterEach(() => {
delete process.env.E2E_STAGING;
});

describe('getByPattern', () => {
it('returns matching apps for a valid exact pattern', () => {
const apps = createLongRunningApps();
const result = apps.getByPattern(['react.vite.withEmailCodes']);
expect(result).toHaveLength(1);
expect(result[0].id).toBe('react.vite.withEmailCodes');
});

it('returns matching apps for a valid glob pattern', () => {
const apps = createLongRunningApps();
const result = apps.getByPattern(['react.vite.*']);
expect(result.length).toBeGreaterThanOrEqual(1);
expect(result.every((r: any) => r.id.startsWith('react.vite.'))).toBe(true);
});

it('throws for an invalid pattern (typo) in normal mode', () => {
const apps = createLongRunningApps();
expect(() => apps.getByPattern(['react.vite.withEmailCodez'])).toThrow(/Could not find long running app with id/);
});

it('throws for an invalid pattern (typo) even when E2E_STAGING=1', () => {
process.env.E2E_STAGING = '1';
const apps = createLongRunningApps();
expect(() => apps.getByPattern(['react.vite.withEmailCodez'])).toThrow(/Could not find long running app with id/);
});

it('returns [] for a known app filtered by isStagingReady when E2E_STAGING=1', () => {
process.env.E2E_STAGING = '1';
// Filter out all apps (simulates no staging keys)
mockIsStagingReady.mockImplementation(() => false);
const apps = createLongRunningApps();
const result = apps.getByPattern(['react.vite.withEmailCodes']);
expect(result).toEqual([]);
});

it('throws for a known app filtered by isStagingReady without E2E_STAGING', () => {
// Filter out all apps
mockIsStagingReady.mockImplementation(() => false);
const apps = createLongRunningApps();
expect(() => apps.getByPattern(['react.vite.withEmailCodes'])).toThrow(/Could not find long running app with id/);
});
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
908f6b3
feat(integration): add E2E_STAGING flag for staging env auto-swap
jacekradko Mar 13, 2026
b2daf97
ci: temporarily allow jacek/* branches in e2e-staging ref validation
jacekradko Mar 13, 2026
4526919
fix(integration): return empty instead of throwing when no staging ap…
jacekradko Mar 13, 2026
fbc6171
fix(integration): use marker instead of null for missing staging keys
jacekradko Mar 13, 2026
bfc0234
fix(integration): use CLERK_API_URL presence instead of env var marker
jacekradko Mar 13, 2026
478a85a
fix(ci): revert temporary jacek/* ref allowlist in e2e-staging
jacekradko Mar 13, 2026
b099c4f
fix: address code review feedback from PR #8060
jacekradko Mar 13, 2026
d41ca3c
fix: format files with prettier
jacekradko Mar 13, 2026
53a7c0c
fix(e2e): guard against missing sk in handshake test mock JWKS server
jacekradko Mar 13, 2026
8e2c064
chore: add empty changeset
jacekradko Mar 13, 2026
664d80e
feat(ci): add 6 staging test suites to e2e-staging workflow matrix
jacekradko Mar 13, 2026
f7bdc57
feat(ci): add fastify turbo tasks and staging script
jacekradko Mar 13, 2026
daee773
refactor(ci): remove duplicate staging scripts, add INTEGRATION_STAGI…
jacekradko Mar 13, 2026
acd0a0c
Merge branch 'main' into jacek/staging-env-swap
jacekradko Mar 13, 2026
4bb2d27
Merge branch 'main' into jacek/staging-env-swap
jacekradko Mar 13, 2026
76c8d09
Merge branch 'main' into jacek/staging-env-swap
jacekradko Mar 13, 2026
ed9dc5f
fix(e2e): skip broken cache-components tests
jacekradko Mar 13, 2026
61dd852
Merge branch 'main' into jacek/staging-env-swap
jacekradko Mar 14, 2026
e54c881
refactor(e2e): simplify env config API with withInstanceKeys and add …
jacekradko Mar 16, 2026
caf2773
style: format envs.ts and longRunningApps.ts with prettier
jacekradko Mar 16, 2026
723b063
fix: address PR review feedback for turbo.json and README
jacekradko Mar 16, 2026
8d9526d
style: format README with prettier
jacekradko Mar 16, 2026
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/staging-env-swap.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
41 changes: 25 additions & 16 deletions .github/workflows/e2e-staging.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,24 +6,24 @@ on:
workflow_dispatch:
inputs:
ref:
description: "Branch to test against"
description: 'Branch to test against'
required: false
default: "main"
default: 'main'
type: string
clerk-go-commit-sha:
description: "clerk_go commit SHA for status reporting"
description: 'clerk_go commit SHA for status reporting'
required: false
type: string
sdk-source:
description: "SDK source: 'latest' uses published @latest from npm, 'ref' builds from the checked-out branch"
required: false
default: "latest"
default: 'latest'
type: choice
options:
- latest
- ref
notify-slack:
description: "Send Slack notification on failure"
description: 'Send Slack notification on failure'
required: false
default: true
type: boolean
Expand All@@ -39,7 +39,7 @@ concurrency:
jobs:
integration-tests:
name: Integration Tests (${{ matrix.test-name }}, ${{ matrix.test-project }})
runs-on: "blacksmith-8vcpu-ubuntu-2204"
runs-on: 'blacksmith-8vcpu-ubuntu-2204'
defaults:
run:
shell: bash
Expand All@@ -49,9 +49,16 @@ jobs:
fail-fast: false
matrix:
test-name:
- "sessions:staging"
- "handshake:staging"
test-project: ["chrome"]
- 'sessions:staging'
- 'handshake:staging'
- 'generic'
- 'cache-components'
- 'express'
- 'hono'
- 'quickstart'
- 'react-router'
- 'tanstack-react-start'
test-project: ['chrome']

steps:
- name: Normalize inputs
Expand DownExpand Up@@ -96,7 +103,7 @@ jobs:
ref: ${{ steps.inputs.outputs.ref }}
fetch-depth: 1
fetch-tags: false
filter: "blob:none"
filter: 'blob:none'
show-progress: false

- name: Setup
Expand DownExpand Up@@ -164,8 +171,8 @@ jobs:
- name: Write all ENV certificates to files in integration/certs
uses: actions/github-script@v7
env:
INTEGRATION_CERTS: "${{ secrets.INTEGRATION_CERTS }}"
INTEGRATION_ROOT_CA: "${{ secrets.INTEGRATION_ROOT_CA }}"
INTEGRATION_CERTS: '${{ secrets.INTEGRATION_CERTS }}'
INTEGRATION_ROOT_CA: '${{ secrets.INTEGRATION_ROOT_CA }}'
with:
script: |
const fs = require('fs');
Expand All@@ -186,14 +193,16 @@ jobs:
timeout-minutes: 25
run: pnpm turbo test:integration:${{ matrix.test-name }} $TURBO_ARGS
env:
E2E_DEBUG: "1"
E2E_DEBUG: '1'
E2E_STAGING: '1'
E2E_SDK_SOURCE: ${{ steps.inputs.outputs.sdk-source }}
E2E_APP_CLERK_JS_DIR: ${{ runner.temp }}
E2E_APP_CLERK_UI_DIR: ${{ runner.temp }}
E2E_CLERK_JS_VERSION: "latest"
E2E_CLERK_UI_VERSION: "latest"
E2E_CLERK_JS_VERSION: 'latest'
E2E_CLERK_UI_VERSION: 'latest'
E2E_PROJECT: ${{ matrix.test-project }}
INTEGRATION_INSTANCE_KEYS: ${{ secrets.INTEGRATION_INSTANCE_KEYS }}
INTEGRATION_STAGING_INSTANCE_KEYS: ${{ secrets.INTEGRATION_STAGING_INSTANCE_KEYS }}
NODE_EXTRA_CA_CERTS: ${{ github.workspace }}/integration/certs/rootCA.pem

- name: Upload test-results
Expand All@@ -208,7 +217,7 @@ jobs:
name: Report Results
needs: [integration-tests]
if: always()
runs-on: "blacksmith-8vcpu-ubuntu-2204"
runs-on: 'blacksmith-8vcpu-ubuntu-2204'
defaults:
run:
shell: bash
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line numberDiff line numberDiff line change
Expand Up@@ -81,6 +81,7 @@ playground/*/yarn.lock

# integration testing
.keys.json
.keys.staging.json
.env.json
.temp_integration
playwright-report
Expand Down
85 changes: 63 additions & 22 deletions integration/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -108,6 +108,43 @@ E2E_APP_SK=sk_test_xxx E2E_APP_PK=pk_test_xxx E2E_APP_URL=http://localhost:3000

You need to replace all environment variables with your specific values/needs, above are just placeholders.

## Running tests against staging

The integration suite supports running tests against Clerk's **staging** environment (`clerkstage.dev`) in addition to the default production environment. This is used to validate SDK behavior against staging API changes before they ship to production.

### How it works

When `E2E_STAGING=1` is set, the `withInstanceKeys` wrapper in [`presets/envs.ts`](../integration/presets/envs.ts) automatically swaps each environment config's PK/SK to their staging equivalents and sets `CLERK_API_URL` to `https://api.clerkstage.dev`. Staging keys are looked up by prefixing the production key name with `clerkstage-` (e.g., `with-email-codes` becomes `clerkstage-with-email-codes`).

If a staging key doesn't exist for a given config, the config is left without a staging API URL. The `isStagingReady` function checks whether a config has been swapped, and long running apps that aren't staging-ready are **gracefully skipped** rather than causing test failures. This means you can run the full test suite against staging even if only a subset of instances have been mirrored.

### Staging keys

Staging keys are stored separately from production keys:

- **Locally**: `.keys.staging.json` (same format as `.keys.json`, gitignored)
- **CI**: `INTEGRATION_STAGING_INSTANCE_KEYS` environment variable (JSON, same format as `INTEGRATION_INSTANCE_KEYS`)

Running `pnpm integration:secrets` will fetch staging keys from 1Password if they exist. If the staging keys field isn't available, the script skips them gracefully.

### Running locally

```shell
E2E_STAGING=1 pnpm test:integration:base
```

Or for a specific preset:

```shell
E2E_STAGING=1 pnpm test:integration:nextjs
```

Tests whose environment configs don't have staging keys will be skipped automatically.

### CI

The [`e2e-staging.yml`](../.github/workflows/e2e-staging.yml) workflow runs integration tests against staging. It sets `E2E_STAGING=1` and provides `INTEGRATION_STAGING_INSTANCE_KEYS` from repository secrets. This workflow is triggered by `workflow_dispatch` (manually or from `clerk_go`) and is typically used to validate staging API changes before release.

## Writing your first test

In this step-by-step instruction you'll learn how to create a new integration test. If your test case already fits into an existing file, please add a new `test()` block instead of creating a whole new file.
Expand DownExpand Up@@ -291,19 +328,14 @@ If you need to run a test suite inside a different environment (e.g. a different
}
```

1. Inside `presets/envs.ts`, create a new environment config:
1. Inside `presets/envs.ts`, create a new environment config wrapped with `withInstanceKeys`:

```ts
const yourConciseName = environmentConfig()
.setId('yourConciseName')
.setEnvVariable('private', 'CLERK_API_URL', process.env.E2E_APP_STAGING_CLERK_API_URL)
.setEnvVariable('private', 'CLERK_SECRET_KEY', envKeys['your-concise-name'].sk)
.setEnvVariable('public', 'CLERK_PUBLISHABLE_KEY', envKeys['your-concise-name'].pk)
.setEnvVariable('public', 'CLERK_SIGN_IN_URL', '/sign-in')
.setEnvVariable('public', 'CLERK_SIGN_UP_URL', '/sign-up')
.setEnvVariable('public', 'CLERK_JS', process.env.E2E_APP_CLERK_JS || 'http://localhost:18211/clerk.browser.js');
const yourConciseName = withInstanceKeys('your-concise-name', base.clone().setId('yourConciseName'));
```

The `withInstanceKeys` wrapper sets PK/SK from the instance keys map and automatically swaps to staging keys when `E2E_STAGING=1` is set. The first argument is the production key name — the staging key is looked up as `clerkstage-your-concise-name`. See [Running tests against staging](#running-tests-against-staging) for more details.

1. Export `yourConciseName` from the file:

```ts
Expand All@@ -313,7 +345,7 @@ If you need to run a test suite inside a different environment (e.g. a different
} as const;
```

1. Ensure that your new keys are added to the `INTEGRATION_INSTANCE_KEYS` environment variable inside the repository so that GitHub actions can successfully run.
1. Ensure that your new keys are added to the `INTEGRATION_INSTANCE_KEYS` environment variable inside the repository so that GitHub actions can successfully run. If you also have staging keys, add them to `INTEGRATION_STAGING_INSTANCE_KEYS`.

## Debugging tests

Expand DownExpand Up@@ -357,7 +389,10 @@ You need to replace the `XXX` in the `path` with the ID of your long running app

### Constants

To get an overview of all the available environment variables you can set, read [`constants.ts`](../integration/constants.ts).
To get an overview of all the available environment variables you can set, read [`constants.ts`](../integration/constants.ts). Notable additions include:

- `E2E_STAGING`: Set to `1` to run tests against the staging environment
- `INTEGRATION_STAGING_INSTANCE_KEYS`: JSON-encoded staging instance keys (used in CI)

### Application configs

Expand DownExpand Up@@ -498,6 +533,8 @@ A long running application is a thin wrapper around an `Application` that expose

Since installing dependencies and booting up an app is a slow operation, long running applications are designed to start **once** in `global.setup` stay open while the tests run, and then stop in `global.teardown` so they can be reused by different suites.

When running in staging mode (`E2E_STAGING=1`), long running apps are filtered by `isStagingReady`. Apps whose environment configs don't have staging keys are excluded from the list — `getByPattern` will return an empty array for those apps instead of throwing, and will log which suites were skipped. At startup, a summary of all skipped apps is also logged. This allows the full test suite to run against staging even when only a subset of instances have been mirrored. If a pattern doesn't match any known app (e.g. a typo), it will still throw an error regardless of staging mode.

You'd define it like so:

```ts
Expand All@@ -522,20 +559,22 @@ Example usage of an existing config (also see [Application](#application)):
await app.withEnv(appConfigs.envs.withEmailCodes);
```

Inside [`presets/envs.ts`](../integration/presets/envs.ts) you can also create a completely new environment config:
Inside [`presets/envs.ts`](../integration/presets/envs.ts) you can also create a completely new environment config. All new configs should be wrapped with `withInstanceKeys` to enable staging environment swapping:

```ts
const withCustomRoles = environmentConfig()
.setId('withCustomRoles')
.setEnvVariable('private', 'CLERK_API_URL', process.env.E2E_APP_STAGING_CLERK_API_URL)
.setEnvVariable('private', 'CLERK_SECRET_KEY', envKeys['with-custom-roles'].sk)
.setEnvVariable('public', 'CLERK_PUBLISHABLE_KEY', envKeys['with-custom-roles'].pk)
.setEnvVariable('public', 'CLERK_SIGN_IN_URL', '/sign-in')
.setEnvVariable('public', 'CLERK_SIGN_UP_URL', '/sign-up')
.setEnvVariable('public', 'CLERK_JS', process.env.E2E_APP_CLERK_JS || 'http://localhost:18211/clerk.browser.js');
const withCustomRoles = withInstanceKeys(
'with-custom-roles',
base
.clone()
.setId('withCustomRoles')
.setEnvVariable('public', 'CLERK_SIGN_IN_URL', '/sign-in')
.setEnvVariable('public', 'CLERK_SIGN_UP_URL', '/sign-up'),
);
```

Read [creating a new environment config](#creating-a-new-environment-config) to learn more.
When `E2E_STAGING=1`, this will automatically look up `clerkstage-with-custom-roles` from the staging keys and swap the PK, SK, and API URL. If the staging key doesn't exist, the config will not be staging-ready and any long running apps using it will be gracefully skipped.

Read [creating a new environment config](#creating-a-new-environment-config) and [running tests against staging](#running-tests-against-staging) to learn more.

### Deployments

Expand DownExpand Up@@ -578,7 +617,9 @@ The integration suite uses [`presets/envs.ts`](../integration/presets/envs.ts) t

This is why you created the `.keys.json` file in the [initial setup](#initial-setup) step. Those secret and publishable keys are used to create environment configs. Inside GitHub actions these keys are provided through the `INTEGRATION_INSTANCE_KEYS` environment variable.

They keys defined in `.keys.json.sample` correspond with the Clerk instances in the **Integration testing** organization.
The keys defined in `.keys.json.sample` correspond with the Clerk instances in the **Integration testing** organization.

For staging tests, a separate `.keys.staging.json` file (or `INTEGRATION_STAGING_INSTANCE_KEYS` env var in CI) provides staging instance keys. These are merged into the key map at startup and used by `withInstanceKeys` when `E2E_STAGING=1` is set. See [Running tests against staging](#running-tests-against-staging) for details.

### Test isolation

Expand Down
1 change: 1 addition & 0 deletions integration/constants.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -86,4 +86,5 @@ export const constants = {
* PK and SK pairs from the env to use for integration tests.
*/
INTEGRATION_INSTANCE_KEYS: process.env.INTEGRATION_INSTANCE_KEYS,
INTEGRATION_STAGING_INSTANCE_KEYS: process.env.INTEGRATION_STAGING_INSTANCE_KEYS,
} as const;
100 changes: 100 additions & 0 deletions integration/presets/__tests__/longRunningApps.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

// Create a Proxy that returns a mock object for any property access (nested)
const deepProxy = (): any =>
new Proxy(
{},
{
get: () => ({}),
},
);

// Mock all preset modules to avoid loading real configs
vi.mock('../astro', () => ({ astro: deepProxy() }));
vi.mock('../expo', () => ({ expo: deepProxy() }));
vi.mock('../express', () => ({ express: deepProxy() }));
vi.mock('../hono', () => ({ hono: deepProxy() }));
vi.mock('../next', () => ({ next: deepProxy() }));
vi.mock('../nuxt', () => ({ nuxt: deepProxy() }));
vi.mock('../react', () => ({ react: deepProxy() }));
vi.mock('../react-router', () => ({ reactRouter: deepProxy() }));
vi.mock('../tanstack', () => ({ tanstack: deepProxy() }));
vi.mock('../vue', () => ({ vue: deepProxy() }));

// Mock longRunningApplication to pass through config as-is
vi.mock('../../models/longRunningApplication', () => ({
longRunningApplication: (params: any) => ({ id: params.id, env: params.env }),
}));

// Mock envs — use a Proxy so any envs.* property returns a unique mock env
const mockIsStagingReady = vi.fn(() => true);
vi.mock('../envs', () => {
const envProxy = new Proxy(
{},
{
get: (_target, prop: string) => ({ __mockEnvId: prop }),
},
);
return {
envs: envProxy,
isStagingReady: (...args: any[]) => mockIsStagingReady(...args),
};
});

describe('createLongRunningApps', () => {
let createLongRunningApps: typeof import('../longRunningApps').createLongRunningApps;

beforeEach(async () => {
vi.resetModules();
mockIsStagingReady.mockImplementation(() => true);
const mod = await import('../longRunningApps');
createLongRunningApps = mod.createLongRunningApps;
});

afterEach(() => {
delete process.env.E2E_STAGING;
});

describe('getByPattern', () => {
it('returns matching apps for a valid exact pattern', () => {
const apps = createLongRunningApps();
const result = apps.getByPattern(['react.vite.withEmailCodes']);
expect(result).toHaveLength(1);
expect(result[0].id).toBe('react.vite.withEmailCodes');
});

it('returns matching apps for a valid glob pattern', () => {
const apps = createLongRunningApps();
const result = apps.getByPattern(['react.vite.*']);
expect(result.length).toBeGreaterThanOrEqual(1);
expect(result.every((r: any) => r.id.startsWith('react.vite.'))).toBe(true);
});

it('throws for an invalid pattern (typo) in normal mode', () => {
const apps = createLongRunningApps();
expect(() => apps.getByPattern(['react.vite.withEmailCodez'])).toThrow(/Could not find long running app with id/);
});

it('throws for an invalid pattern (typo) even when E2E_STAGING=1', () => {
process.env.E2E_STAGING = '1';
const apps = createLongRunningApps();
expect(() => apps.getByPattern(['react.vite.withEmailCodez'])).toThrow(/Could not find long running app with id/);
});

it('returns [] for a known app filtered by isStagingReady when E2E_STAGING=1', () => {
process.env.E2E_STAGING = '1';
// Filter out all apps (simulates no staging keys)
mockIsStagingReady.mockImplementation(() => false);
const apps = createLongRunningApps();
const result = apps.getByPattern(['react.vite.withEmailCodes']);
expect(result).toEqual([]);
});

it('throws for a known app filtered by isStagingReady without E2E_STAGING', () => {
// Filter out all apps
mockIsStagingReady.mockImplementation(() => false);
const apps = createLongRunningApps();
expect(() => apps.getByPattern(['react.vite.withEmailCodes'])).toThrow(/Could not find long running app with id/);
});
});
});
Loading
Loading