diff --git a/.changeset/tidy-env-awaits.md b/.changeset/tidy-env-awaits.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/tidy-env-awaits.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 916932893e1..08c8036f388 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,7 +19,7 @@ concurrency: jobs: # Check triggering actor permissions to prevent PRs from forks accessing secrets by default, preventing them from exfiltrating secrets for malicious purposes check-permissions: - runs-on: "blacksmith-8vcpu-ubuntu-2204" + runs-on: 'blacksmith-8vcpu-ubuntu-2204' defaults: run: shell: bash @@ -48,7 +48,7 @@ jobs: pre-checks: needs: [check-permissions] name: Formatting | Dedupe | Changeset - runs-on: "blacksmith-8vcpu-ubuntu-2204" + runs-on: 'blacksmith-8vcpu-ubuntu-2204' defaults: run: shell: bash @@ -61,7 +61,7 @@ jobs: persist-credentials: false fetch-depth: 100 # Enough for changeset status comparison, much faster than full history fetch-tags: false - filter: "blob:none" + filter: 'blob:none' show-progress: false - name: Fetch main branch for changeset comparison @@ -104,7 +104,7 @@ jobs: build-packages: needs: [check-permissions] name: Build Packages - runs-on: "blacksmith-8vcpu-ubuntu-2204" + runs-on: 'blacksmith-8vcpu-ubuntu-2204' permissions: contents: read defaults: @@ -122,7 +122,7 @@ jobs: persist-credentials: false fetch-depth: 1 fetch-tags: false - filter: "blob:none" + filter: 'blob:none' show-progress: false - name: Setup @@ -152,7 +152,7 @@ jobs: name: Bundle size permissions: contents: read - runs-on: "blacksmith-8vcpu-ubuntu-2204" + runs-on: 'blacksmith-8vcpu-ubuntu-2204' defaults: run: shell: bash @@ -165,7 +165,7 @@ jobs: persist-credentials: false fetch-depth: 1 fetch-tags: false - filter: "blob:none" + filter: 'blob:none' show-progress: false - name: Setup @@ -196,7 +196,7 @@ jobs: permissions: contents: read actions: write # needed for actions/upload-artifact - runs-on: "blacksmith-8vcpu-ubuntu-2204" + runs-on: 'blacksmith-8vcpu-ubuntu-2204' defaults: run: shell: bash @@ -212,7 +212,7 @@ jobs: persist-credentials: false fetch-depth: 1 fetch-tags: false - filter: "blob:none" + filter: 'blob:none' show-progress: false - name: Setup @@ -249,7 +249,7 @@ jobs: permissions: contents: read actions: write # needed for actions/upload-artifact - runs-on: "blacksmith-8vcpu-ubuntu-2204" + runs-on: 'blacksmith-8vcpu-ubuntu-2204' defaults: run: shell: bash @@ -263,11 +263,11 @@ jobs: matrix: include: - node-version: 24.15.0 - test-filter: "**" - filter-label: "**" + test-filter: '**' + filter-label: '**' - node-version: 20.19.0 - test-filter: "**" - filter-label: "**" + test-filter: '**' + filter-label: '**' steps: - name: Checkout Repo @@ -276,7 +276,7 @@ jobs: persist-credentials: false fetch-depth: 1 fetch-tags: false - filter: "blob:none" + filter: 'blob:none' show-progress: false - name: Setup @@ -328,7 +328,7 @@ jobs: permissions: contents: read actions: write # needed for actions/upload-artifact - runs-on: "blacksmith-8vcpu-ubuntu-2204" + runs-on: 'blacksmith-8vcpu-ubuntu-2204' defaults: run: shell: bash @@ -339,46 +339,46 @@ jobs: matrix: test-name: [ - "generic", - "express", - "fastify", - "ap-flows", - "localhost", - "sessions", - "sessions:staging", - "handshake", - "handshake:staging", - "astro", - "tanstack-react-start", - "vue", - "nuxt", - "react-router", - "custom", - "hono", - "chrome-extension", - "electron", + 'generic', + 'express', + 'fastify', + 'ap-flows', + 'localhost', + 'sessions', + 'sessions:staging', + 'handshake', + 'handshake:staging', + 'astro', + 'tanstack-react-start', + 'vue', + 'nuxt', + 'react-router', + 'custom', + 'hono', + 'chrome-extension', + 'electron', ] - test-project: ["chrome"] + test-project: ['chrome'] include: - - test-name: "billing" - test-project: "chrome" - - test-name: "machine" - test-project: "chrome" - - test-name: "nextjs" - test-project: "chrome" - next-version: "15" - - test-name: "nextjs" - test-project: "chrome" - next-version: "16" - - test-name: "quickstart" - test-project: "chrome" - next-version: "15" - - test-name: "quickstart" - test-project: "chrome" - next-version: "16" - - test-name: "cache-components" - test-project: "chrome" - next-version: "16" + - test-name: 'billing' + test-project: 'chrome' + - test-name: 'machine' + test-project: 'chrome' + - test-name: 'nextjs' + test-project: 'chrome' + next-version: '15' + - test-name: 'nextjs' + test-project: 'chrome' + next-version: '16' + - test-name: 'quickstart' + test-project: 'chrome' + next-version: '15' + - test-name: 'quickstart' + test-project: 'chrome' + next-version: '16' + - test-name: 'cache-components' + test-project: 'chrome' + next-version: '16' steps: - name: Checkout Repo @@ -387,7 +387,7 @@ jobs: persist-credentials: false fetch-depth: 1 fetch-tags: false - filter: "blob:none" + filter: 'blob:none' show-progress: false - name: Setup @@ -413,8 +413,8 @@ jobs: env: 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_NEXTJS_VERSION: ${{ matrix.next-version }} E2E_PROJECT: ${{ matrix.test-project }} INTEGRATION_INSTANCE_KEYS: ${{ secrets.INTEGRATION_INSTANCE_KEYS }} @@ -478,8 +478,8 @@ jobs: - name: Write all ENV certificates to files in integration/certs uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # 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'); @@ -523,11 +523,13 @@ jobs: pnpm turbo test:integration:${{ matrix.test-name }} $TURBO_ARGS fi env: - E2E_DEBUG: "1" + CLERK_E2E_OAUTH_PROVIDER_CLIENT_SECRET: ${{ secrets.CLERK_E2E_OAUTH_PROVIDER_CLIENT_SECRET }} + CLERK_PLATFORM_API_KEY: ${{ secrets.CLERK_PLATFORM_API_KEY }} + E2E_DEBUG: '1' 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_NEXTJS_VERSION: ${{ matrix.next-version }} E2E_PROJECT: ${{ matrix.test-project }} INTEGRATION_INSTANCE_KEYS: ${{ secrets.INTEGRATION_INSTANCE_KEYS }} @@ -539,6 +541,8 @@ jobs: timeout-minutes: 4 run: pnpm test:integration:cleanup env: + CLERK_E2E_OAUTH_PROVIDER_CLIENT_SECRET: ${{ secrets.CLERK_E2E_OAUTH_PROVIDER_CLIENT_SECRET }} + CLERK_PLATFORM_API_KEY: ${{ secrets.CLERK_PLATFORM_API_KEY }} INTEGRATION_INSTANCE_KEYS: ${{ secrets.INTEGRATION_INSTANCE_KEYS }} NODE_EXTRA_CA_CERTS: ${{ github.workspace }}/integration/certs/rootCA.pem @@ -562,7 +566,7 @@ jobs: pkg-pr-new: name: Publish with pkg-pr-new needs: [check-permissions, build-packages] - runs-on: "blacksmith-8vcpu-ubuntu-2204" + runs-on: 'blacksmith-8vcpu-ubuntu-2204' defaults: run: shell: bash @@ -577,7 +581,7 @@ jobs: persist-credentials: false fetch-depth: 1 fetch-tags: false - filter: "blob:none" + filter: 'blob:none' show-progress: false - name: Setup Node diff --git a/.github/workflows/e2e-cleanups.yml b/.github/workflows/e2e-cleanups.yml index 729c22d9334..d562a206c1b 100644 --- a/.github/workflows/e2e-cleanups.yml +++ b/.github/workflows/e2e-cleanups.yml @@ -38,5 +38,6 @@ jobs: - name: Run cleanup run: pnpm test:integration:cleanup env: + CLERK_PLATFORM_API_KEY: ${{ secrets.CLERK_PLATFORM_API_KEY }} INTEGRATION_INSTANCE_KEYS: ${{ secrets.INTEGRATION_INSTANCE_KEYS }} INTEGRATION_STAGING_INSTANCE_KEYS: ${{ secrets.INTEGRATION_STAGING_INSTANCE_KEYS }} diff --git a/.github/workflows/nightly-checks.yml b/.github/workflows/nightly-checks.yml index 61ad42c4b23..350a51a6d03 100644 --- a/.github/workflows/nightly-checks.yml +++ b/.github/workflows/nightly-checks.yml @@ -57,6 +57,13 @@ jobs: pnpm init pkglab add @clerk/ui + - name: Configure test run cleanup + run: echo "INTEGRATION_TEST_RUN_KEY=${RUN_ID}-${RUN_ATTEMPT}-nightly-${TEST_NAME}" >> "$GITHUB_ENV" + env: + RUN_ID: ${{ github.run_id }} + RUN_ATTEMPT: ${{ github.run_attempt }} + TEST_NAME: ${{ matrix.test-name }} + - name: Run Integration Tests id: integration_tests continue-on-error: true @@ -71,6 +78,8 @@ jobs: fi echo "exit_code=${PIPESTATUS[0]}" >> $GITHUB_OUTPUT env: + CLERK_E2E_OAUTH_PROVIDER_CLIENT_SECRET: ${{ secrets.CLERK_E2E_OAUTH_PROVIDER_CLIENT_SECRET }} + CLERK_PLATFORM_API_KEY: ${{ secrets.CLERK_PLATFORM_API_KEY }} E2E_APP_CLERK_JS_DIR: ${{runner.temp}} E2E_APP_CLERK_UI_DIR: ${{runner.temp}} # Don't set E2E_CLERK_JS_VERSION - let it use '*' (via linkPackage) @@ -118,6 +127,14 @@ jobs: test-results/ retention-days: 7 + - name: Delete integration-test resources + if: ${{ always() && steps.integration_tests.outcome != 'skipped' }} + timeout-minutes: 4 + run: pnpm test:integration:cleanup + env: + CLERK_PLATFORM_API_KEY: ${{ secrets.CLERK_PLATFORM_API_KEY }} + INTEGRATION_INSTANCE_KEYS: ${{ secrets.INTEGRATION_INSTANCE_KEYS }} + - name: Report Status if: always() uses: ravsamhq/notify-slack-action@4ed28566c2bdcdaee6dca2b46b9666d01b4ed8a4 # v1 diff --git a/integration/README.md b/integration/README.md index cc74296bf82..d39053c0586 100644 --- a/integration/README.md +++ b/integration/README.md @@ -331,7 +331,7 @@ 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 wrapped with `withInstanceKeys`: ```ts - const yourConciseName = withInstanceKeys('your-concise-name', base.clone().setId('yourConciseName')); + const yourConciseName = await 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. @@ -562,7 +562,7 @@ await app.withEnv(appConfigs.envs.withEmailCodes); 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 = withInstanceKeys( +const withCustomRoles = await withInstanceKeys( 'with-custom-roles', base .clone() @@ -572,6 +572,27 @@ const withCustomRoles = withInstanceKeys( ); ``` +For non-staging tests, when `CLERK_PLATFORM_API_KEY` is set and `integration/configs/.js` exists, `withInstanceKeys` creates a development application through the Platform API. It applies the exported configuration and uses the keys from the new application. If the file does not exist, the wrapper uses the existing instance keys map. + +Use `defineConfig` for configuration files. JavaScript configuration files can read environment variables. The optional `setup` function receives a `ClerkClient` after the instance configuration is applied. Omit `setup` when no additional operations are required. + +```js +import { defineConfig } from '../presets/platformApplication.js'; + +export default defineConfig({ + config: { + session: { + lifetime: Number(process.env.CLERK_E2E_SESSION_LIFETIME || 3600), + }, + }, + setup: async clerkClient => { + await clerkClient.allowlistIdentifiers.createAllowlistIdentifier({ identifier: 'allowed@example.com' }); + }, +}); +``` + +The application name includes a deterministic, letter-only token derived from `INTEGRATION_TEST_RUN_KEY` when it is available. Otherwise, it uses a random letter-only suffix. Application keys are cached during `E2E_APP_ID` runs because their setup and test workers must use the same instance. Global teardown removes the cache file. + 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. diff --git a/integration/cleanup/cleanup.setup.ts b/integration/cleanup/cleanup.setup.ts index 5340fb34ed7..6eab15ccf05 100644 --- a/integration/cleanup/cleanup.setup.ts +++ b/integration/cleanup/cleanup.setup.ts @@ -4,13 +4,15 @@ import { parsePublishableKey } from '@clerk/shared/keys'; import { isStaging } from '@clerk/shared/utils'; import { test as setup } from '@playwright/test'; -import { appConfigs } from '../presets/'; -import { findE2ERunUsers, getE2ERunMarker } from '../testUtils/e2eRun'; +import { constants } from '../constants'; +import { instanceKeys } from '../presets/instanceKeys'; +import { deleteApplication, listApplications } from '../presets/platformApplication'; +import { findE2ERunUsers, getE2EApplicationRunMarker, getE2ERunMarker } from '../testUtils/e2eRun'; import { withRetry } from '../testUtils/retryableClerkClient'; setup('cleanup instances ', async () => { const runMarker = getE2ERunMarker(); - const entries = Array.from(appConfigs.secrets.instanceKeys.values()) + const entries = Array.from(instanceKeys.values()) .map(({ pk, sk }) => { const secretKey = sk; if (!secretKey) { @@ -193,13 +195,49 @@ setup('cleanup instances ', async () => { cleanupSummary.push(instanceSummary); } + const applicationCleanupErrors: string[] = []; + let applicationsDeleted = 0; + if (constants.CLERK_PLATFORM_API_KEY) { + try { + const applications = await listApplications(constants.CLERK_PLATFORM_API_KEY); + console.log(`Found ${applications.length} Platform API applications.`); + + const applicationRunMarker = getE2EApplicationRunMarker(constants.INTEGRATION_TEST_RUN_KEY); + if (applicationRunMarker) { + const applicationNameSuffix = `-${applicationRunMarker}`; + const applicationsToDelete = applications.filter(application => + application.name.endsWith(applicationNameSuffix), + ); + + for (const application of applicationsToDelete) { + try { + await deleteApplication(constants.CLERK_PLATFORM_API_KEY, application.application_id); + applicationsDeleted++; + console.log(`Deleted Platform API application ${application.application_id} (${application.name}).`); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + applicationCleanupErrors.push(`${application.application_id}: ${message}`); + } + } + } else { + console.log('INTEGRATION_TEST_RUN_KEY is not set. Skipping Platform API application deletion.'); + } + } catch (error) { + applicationCleanupErrors.push(error instanceof Error ? error.message : String(error)); + } + } else { + console.log('CLERK_PLATFORM_API_KEY is not set. Skipping Platform API application cleanup.'); + } + // Final summary const totalUsersDeleted = cleanupSummary.reduce((sum, instance) => sum + instance.usersDeleted, 0); const totalOrgsDeleted = cleanupSummary.reduce((sum, instance) => sum + instance.orgsDeleted, 0); const errorInstances = cleanupSummary.filter(instance => instance.status === 'error').length; const unauthorizedInstances = cleanupSummary.filter(instance => instance.status === 'unauthorized').length; - console.log(`\nšŸ“Š Summary: ${totalUsersDeleted} users, ${totalOrgsDeleted} orgs deleted`); + console.log( + `\nšŸ“Š Summary: ${totalUsersDeleted} users, ${totalOrgsDeleted} orgs, ${applicationsDeleted} applications deleted`, + ); if (errorInstances > 0 || unauthorizedInstances > 0) { console.log(` ${errorInstances} errors, ${unauthorizedInstances} unauthorized`); } @@ -214,7 +252,12 @@ setup('cleanup instances ', async () => { }); } - if (errorInstances === 0 && unauthorizedInstances === 0) { + if (applicationCleanupErrors.length > 0) { + console.log('\nPlatform application cleanup errors:'); + applicationCleanupErrors.forEach(error => console.log(` - ${error}`)); + } + + if (errorInstances === 0 && unauthorizedInstances === 0 && applicationCleanupErrors.length === 0) { console.log('\nāœ… Cleanup completed successfully with no errors'); } }); diff --git a/integration/configs/with-email-codes.js b/integration/configs/with-email-codes.js new file mode 100644 index 00000000000..b68bd6838bf --- /dev/null +++ b/integration/configs/with-email-codes.js @@ -0,0 +1,399 @@ +import { defineConfig } from '../presets/platformApplication.js'; + +const customOAuthClientSecret = process.env.CLERK_E2E_OAUTH_PROVIDER_CLIENT_SECRET; + +export default defineConfig({ + config: { + auth_access_control: { + allowlist_blocklist_enforced_on_sign_in: true, + allowlist_enabled: false, + block_disposable_email_domains: false, + block_email_subaddresses: false, + blocklist_enabled: false, + sign_up_mode: 'public', + }, + auth_attack_protection: { + bot_protection: { + captcha_enabled: false, + captcha_widget_type: '', + }, + email_link_require_same_client: false, + enumeration_protection: 'bulk', + pii_protection_enabled: true, + user_lockout: { + duration_in_minutes: 60, + enabled: true, + max_attempts: 100, + }, + }, + auth_email: { + immutable: false, + required_for_sign_up: true, + sign_in_strategies: ['email_code', 'email_link'], + used_for_sign_in: true, + used_for_sign_up: true, + verification_strategies: ['email_code'], + verify_at_sign_up: true, + }, + auth_multi_factor: { + authenticator_app: { + enabled: true, + }, + backup_code: { + enabled: true, + }, + required_for_sign_in: false, + required_for_sign_up: false, + }, + auth_passkey: { + allow_autofill: true, + satisfies_second_factor: true, + show_sign_in_button: true, + used_for_sign_in: false, + }, + auth_password: { + device_trust: { + enabled: false, + }, + disable_hibp: false, + enabled: true, + enforce_hibp_on_sign_in: false, + max_length: 0, + min_length: 8, + min_zxcvbn_strength: 0, + require_lowercase: false, + require_numbers: false, + require_special_char: true, + require_uppercase: false, + required: true, + show_zxcvbn: false, + }, + auth_phone: { + immutable: false, + required_for_sign_up: false, + second_factor_strategies: ['phone_code'], + sign_in_strategies: ['phone_code'], + used_for_second_factor: true, + used_for_sign_in: true, + used_for_sign_up: true, + verification_strategies: ['phone_code'], + verify_at_sign_up: true, + }, + auth_username: { + allow_extended_special_characters: false, + allow_numeric_usernames: false, + immutable: false, + max_length: 64, + min_length: 4, + required_for_sign_up: false, + used_for_sign_in: true, + used_for_sign_up: true, + }, + auth_web3: { + required_for_sign_up: false, + sign_in_strategies: ['web3_metamask_signature'], + used_for_sign_in: true, + used_for_sign_up: true, + verification_strategies: ['web3_metamask_signature'], + verify_at_sign_up: true, + }, + billing: { + features: {}, + free_trial_requires_payment_method: true, + organization_enabled: false, + plans: {}, + user_enabled: false, + }, + branding: { + favicon: null, + logo: null, + show_clerk_branding: true, + }, + compliance: { + legal_consent: { + enabled: false, + privacy_policy_url: null, + terms_of_service_url: null, + }, + }, + connection_oauth_apple: { + authenticatable: true, + block_email_subaddresses: false, + bundle_id: '', + client_id: '', + client_secret: '', + enabled: false, + key_id: '', + team_id: '', + }, + connection_oauth_atlassian: { + authenticatable: true, + block_email_subaddresses: false, + client_id: '', + client_secret: '', + enabled: false, + }, + connection_oauth_bitbucket: { + authenticatable: true, + block_email_subaddresses: false, + client_id: '', + client_secret: '', + enabled: false, + }, + connection_oauth_box: { + authenticatable: true, + block_email_subaddresses: false, + client_id: '', + client_secret: '', + enabled: false, + }, + connection_oauth_coinbase: { + authenticatable: true, + block_email_subaddresses: false, + client_id: '', + client_secret: '', + enabled: false, + }, + connection_oauth_discord: { + authenticatable: true, + block_email_subaddresses: false, + client_id: '', + client_secret: '', + enabled: false, + }, + connection_oauth_dropbox: { + authenticatable: true, + block_email_subaddresses: false, + client_id: '', + client_secret: '', + enabled: false, + }, + connection_oauth_enstall: null, + connection_oauth_expressen: null, + connection_oauth_facebook: { + authenticatable: true, + block_email_subaddresses: false, + client_id: '', + client_secret: '', + enabled: false, + }, + connection_oauth_github: { + authenticatable: false, + block_email_subaddresses: false, + client_id: '', + client_secret: '', + enabled: false, + }, + connection_oauth_gitlab: { + authenticatable: true, + block_email_subaddresses: false, + client_id: '', + client_secret: '', + enabled: false, + }, + connection_oauth_google: { + authenticatable: true, + block_email_subaddresses: false, + client_id: '', + client_secret: '', + enabled: true, + show_account_selector_prompt: false, + }, + connection_oauth_hubspot: { + authenticatable: true, + block_email_subaddresses: false, + client_id: '', + client_secret: '', + enabled: false, + }, + connection_oauth_huggingface: { + authenticatable: true, + block_email_subaddresses: false, + client_id: '', + client_secret: '', + enabled: false, + }, + connection_oauth_line: { + authenticatable: true, + block_email_subaddresses: false, + client_id: '', + client_secret: '', + enabled: false, + }, + connection_oauth_linear: { + actor: 'user', + authenticatable: true, + block_email_subaddresses: false, + client_id: '', + client_secret: '', + enabled: false, + }, + connection_oauth_linkedin: { + authenticatable: true, + block_email_subaddresses: false, + client_id: '', + client_secret: '', + enabled: false, + }, + connection_oauth_linkedin_oidc: { + authenticatable: true, + block_email_subaddresses: false, + client_id: '', + client_secret: '', + enabled: false, + }, + connection_oauth_microsoft: { + authenticatable: true, + block_email_subaddresses: false, + client_id: '', + client_secret: '', + enabled: false, + show_account_selector_prompt: false, + }, + connection_oauth_notion: { + authenticatable: true, + block_email_subaddresses: false, + client_id: '', + client_secret: '', + enabled: false, + }, + connection_oauth_slack: { + authenticatable: true, + block_email_subaddresses: false, + client_id: '', + client_secret: '', + enabled: false, + }, + connection_oauth_spotify: { + authenticatable: true, + block_email_subaddresses: false, + client_id: '', + client_secret: '', + enabled: false, + }, + connection_oauth_tiktok: { + authenticatable: true, + block_email_subaddresses: false, + client_id: '', + client_secret: '', + enabled: false, + }, + connection_oauth_twitch: { + authenticatable: true, + block_email_subaddresses: false, + client_id: '', + client_secret: '', + enabled: false, + }, + connection_oauth_twitter: { + authenticatable: true, + block_email_subaddresses: false, + client_id: '', + client_secret: '', + enabled: false, + }, + connection_oauth_vercel: { + authenticatable: true, + block_email_subaddresses: false, + client_id: '', + client_secret: '', + enabled: false, + }, + connection_oauth_x: { + authenticatable: true, + block_email_subaddresses: false, + client_id: '', + client_secret: '', + enabled: false, + }, + connection_oauth_xero: { + authenticatable: true, + block_email_subaddresses: false, + client_id: '', + client_secret: '', + enabled: false, + }, + connections_oauth_custom: { + e2e_oauth_provider: { + auth_url: 'https://honest-wildcat-44.clerk.accounts.dev/oauth/authorize', + authenticatable: true, + base_scopes: [], + client_id: 'dqDPafoayDXekse9', + client_secret: customOAuthClientSecret, + discovery_url: 'https://honest-wildcat-44.clerk.accounts.dev/.well-known/openid-configuration', + enabled: true, + name: 'E2E OAuth Provider', + requires_pkce: false, + token_url: 'https://honest-wildcat-44.clerk.accounts.dev/oauth/token', + user_info_url: 'https://honest-wildcat-44.clerk.accounts.dev/oauth/userinfo', + user_mapping: { + id: { + path: 'user_id', + }, + }, + }, + }, + organization_settings: { + admin_delete_enabled: true, + creator_role: 'org:admin', + domains_default_role: 'org:member', + domains_enabled: true, + domains_enrollment_modes: ['manual_invitation', 'automatic_invitation', 'automatic_suggestion'], + enabled: true, + initial_role_set_key: 'role_set:default', + max_allowed_domains: 10, + max_allowed_memberships: 3, + max_allowed_roles: 10, + max_role_sets_allowed: 30, + force_organization_selection: false, + organization_creation_defaults: { + automatic_organization_creation: { + enabled: false, + }, + detect_from_email_domain: { + enabled: false, + }, + enabled: false, + fallback: { + name: '', + }, + organization_name_template: { + enabled: false, + template: '', + }, + }, + slug_disabled: false, + }, + paths: { + after_sign_out_all: null, + home: null, + oauth_consent: null, + sign_in: null, + sign_up: null, + unauthorized_sign_in: null, + waitlist: null, + }, + session: null, + session_settings: { + inactivity_timeout: { + duration_seconds: 0, + enabled: false, + }, + maximum_lifetime: { + duration_seconds: 604800, + enabled: true, + }, + multi_session_enabled: true, + }, + user_model: { + first_name: { + enabled: true, + required: false, + }, + last_name: { + enabled: true, + required: false, + }, + }, + }, +}); diff --git a/integration/constants.ts b/integration/constants.ts index b4be19cffe6..0750c777864 100644 --- a/integration/constants.ts +++ b/integration/constants.ts @@ -87,6 +87,8 @@ export const constants = { */ INTEGRATION_INSTANCE_KEYS: process.env.INTEGRATION_INSTANCE_KEYS, INTEGRATION_STAGING_INSTANCE_KEYS: process.env.INTEGRATION_STAGING_INSTANCE_KEYS, + INTEGRATION_TEST_RUN_KEY: process.env.INTEGRATION_TEST_RUN_KEY, + CLERK_PLATFORM_API_KEY: process.env.CLERK_PLATFORM_API_KEY, } as const; /** diff --git a/integration/package.json b/integration/package.json new file mode 100644 index 00000000000..e986b24bbae --- /dev/null +++ b/integration/package.json @@ -0,0 +1,4 @@ +{ + "private": true, + "type": "module" +} diff --git a/integration/playwright.chrome-extension.config.ts b/integration/playwright.chrome-extension.config.ts index e79c33ef17d..6258bf456d9 100644 --- a/integration/playwright.chrome-extension.config.ts +++ b/integration/playwright.chrome-extension.config.ts @@ -4,7 +4,7 @@ import * as path from 'path'; import { common } from './playwright.config'; -config({ path: path.resolve(__dirname, '.env.local') }); +config({ path: path.resolve(import.meta.dirname, '.env.local') }); export default defineConfig({ ...common, diff --git a/integration/playwright.cleanup.config.ts b/integration/playwright.cleanup.config.ts index 4f35a4e1248..2efbf81b8b2 100644 --- a/integration/playwright.cleanup.config.ts +++ b/integration/playwright.cleanup.config.ts @@ -4,7 +4,7 @@ import * as path from 'path'; import { common } from './playwright.config'; -config({ path: path.resolve(__dirname, '.env.local') }); +config({ path: path.resolve(import.meta.dirname, '.env.local') }); export default defineConfig({ ...common, diff --git a/integration/playwright.config.ts b/integration/playwright.config.ts index 911172a94a6..0b09ed58937 100644 --- a/integration/playwright.config.ts +++ b/integration/playwright.config.ts @@ -5,7 +5,7 @@ import { defineConfig, devices } from '@playwright/test'; import { config } from 'dotenv'; import * as path from 'path'; -config({ path: path.resolve(__dirname, '.env.local') }); +config({ path: path.resolve(import.meta.dirname, '.env.local') }); export const common: PlaywrightTestConfig = { testDir: './tests', diff --git a/integration/playwright.deployments.config.ts b/integration/playwright.deployments.config.ts index e62b477cd35..661f9718721 100644 --- a/integration/playwright.deployments.config.ts +++ b/integration/playwright.deployments.config.ts @@ -4,7 +4,7 @@ import * as path from 'path'; import { common } from './playwright.config'; -config({ path: path.resolve(__dirname, '.env.local') }); +config({ path: path.resolve(import.meta.dirname, '.env.local') }); export default defineConfig({ ...common, diff --git a/integration/presets/envs.ts b/integration/presets/envs.ts index 6acba638337..8b068cf05d3 100644 --- a/integration/presets/envs.ts +++ b/integration/presets/envs.ts @@ -1,4 +1,6 @@ +import { createHash } from 'node:crypto'; import { resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; import { automatedEnvironmentVariables } from '@clerk/shared/utils'; import fs from 'fs-extra'; @@ -6,39 +8,87 @@ import fs from 'fs-extra'; import { constants } from '../constants'; import type { EnvironmentConfig } from '../models/environment'; import { environmentConfig } from '../models/environment'; +import { instanceKeys } from './instanceKeys'; +import type { PlatformApplication, PlatformApplicationConfig } from './platformApplication'; +import { createApplicationFromConfig } from './platformApplication'; -const getInstanceKeys = () => { - let keys: Record; - try { - keys = constants.INTEGRATION_INSTANCE_KEYS - ? JSON.parse(constants.INTEGRATION_INSTANCE_KEYS) - : fs.readJSONSync(resolve(__dirname, '..', '.keys.json')) || null; - } catch (e) { - console.log('Could not find .keys.json file', e); - } - if (!keys) { - throw new Error('Missing instance keys. Is your env or .keys.json file populated?'); +export { instanceKeys }; + +const STAGING_API_URL = 'https://api.clerkstage.dev'; +const STAGING_KEY_PREFIX = 'clerkstage-'; +const platformApplicationCachePaths = new Set(); + +export const removePlatformApplicationCache = async () => { + await Promise.all([...platformApplicationCachePaths].map(cachePath => fs.remove(cachePath))); +}; + +const isPlatformApplication = (value: unknown): value is PlatformApplication => { + if (!value || typeof value !== 'object') { + return false; } - // Merge staging keys if available - try { - const stagingKeys: Record = constants.INTEGRATION_STAGING_INSTANCE_KEYS - ? JSON.parse(constants.INTEGRATION_STAGING_INSTANCE_KEYS) - : fs.readJSONSync(resolve(__dirname, '..', '.keys.staging.json')) || null; - if (stagingKeys) { - Object.assign(keys, stagingKeys); - } - } catch { - // Staging keys are optional + const application = value as Partial; + return Boolean(application.applicationId && application.instanceId && application.pk && application.sk); +}; + +const getPlatformApplication = async ( + keyName: string, + definition: PlatformApplicationConfig, +): Promise => { + const platformApiKey = constants.CLERK_PLATFORM_API_KEY; + if (!platformApiKey) { + throw new Error('CLERK_PLATFORM_API_KEY is required to create a Platform API application.'); + } + if (!constants.E2E_APP_ID) { + const application = await createApplicationFromConfig( + platformApiKey, + keyName, + definition, + constants.INTEGRATION_TEST_RUN_KEY, + ); + console.log(`Created Platform API application ${application.applicationId} for ${keyName}.`); + return application; + } + const cacheKey = createHash('sha256') + .update(keyName) + .update(JSON.stringify(definition.config)) + .update(constants.INTEGRATION_TEST_RUN_KEY || '') + .update(constants.E2E_APP_ID) + .digest('hex'); + const cachePath = resolve(constants.TMP_DIR, 'platform-applications', `${cacheKey}.json`); + platformApplicationCachePaths.add(cachePath); + const cached = (await fs.pathExists(cachePath)) ? await fs.readJSON(cachePath, { throws: false }) : null; + + if (isPlatformApplication(cached)) { + console.log(`Using Platform API application ${cached.applicationId} for ${keyName}.`); + return cached; } - return new Map(Object.entries(keys)); + const application = await createApplicationFromConfig( + platformApiKey, + keyName, + definition, + constants.INTEGRATION_TEST_RUN_KEY, + ); + await fs.outputJSON(cachePath, application, { mode: 0o600 }); + console.log(`Created Platform API application ${application.applicationId} for ${keyName}.`); + return application; }; -export const instanceKeys = getInstanceKeys(); +const loadPlatformApplicationConfig = async (configPath: string): Promise => { + const configModule = (await import(pathToFileURL(configPath).href)) as { default?: PlatformApplicationConfig }; + const definition = configModule.default; -const STAGING_API_URL = 'https://api.clerkstage.dev'; -const STAGING_KEY_PREFIX = 'clerkstage-'; + if (!definition || typeof definition !== 'object' || !('config' in definition)) { + throw new Error(`${configPath} must export a default configuration created with defineConfig().`); + } + + if (definition.setup !== undefined && typeof definition.setup !== 'function') { + throw new Error(`${configPath} setup must be a function.`); + } + + return definition; +}; /** * Check whether an env config is ready for staging tests. @@ -47,22 +97,31 @@ const STAGING_KEY_PREFIX = 'clerkstage-'; * (indicated by CLERK_API_URL being set to the staging URL). */ export function isStagingReady(env: EnvironmentConfig): boolean { - if (process.env.E2E_STAGING !== '1') return true; + if (process.env.E2E_STAGING !== '1') { + return true; + } return env.privateVariables.get('CLERK_API_URL') === STAGING_API_URL; } /** - * Sets PK/SK from the instance keys map and handles staging environment swapping. + * Creates an application from a matching config file or sets PK/SK from the instance keys map. * When E2E_STAGING=1 is set, swaps PK/SK to staging keys (looked up as `clerkstage-`) * and adds CLERK_API_URL. If the staging key doesn't exist, removes any inherited CLERK_API_URL * so the config falls back to production and is filtered from long-running apps by isStagingReady. * In non-staging mode, sets the production PK/SK and returns. */ -function withInstanceKeys(keyName: string, env: EnvironmentConfig): EnvironmentConfig { - const keys = instanceKeys.get(keyName)!; +async function withInstanceKeys(keyName: string, env: EnvironmentConfig): Promise { + const configPath = resolve(import.meta.dirname, '..', 'configs', `${keyName}.js`); + const keys = + process.env.E2E_STAGING !== '1' && (await fs.pathExists(configPath)) && constants.CLERK_PLATFORM_API_KEY + ? await getPlatformApplication(keyName, await loadPlatformApplicationConfig(configPath)) + : instanceKeys.get(keyName)!; + instanceKeys.set(keyName, keys); env.setEnvVariable('private', 'CLERK_SECRET_KEY', keys.sk).setEnvVariable('public', 'CLERK_PUBLISHABLE_KEY', keys.pk); - if (process.env.E2E_STAGING !== '1') return env; + if (process.env.E2E_STAGING !== '1') { + return env; + } const stagingKeyName = STAGING_KEY_PREFIX + keyName; if (!instanceKeys.has(stagingKeyName)) { @@ -96,7 +155,7 @@ automatedEnvironmentVariables.forEach(name => { withKeyless.setEnvVariable('private', name, 'false'); }); -const withEmailCodes = withInstanceKeys( +const withEmailCodes = await withInstanceKeys( 'with-email-codes', base .clone() @@ -104,7 +163,7 @@ const withEmailCodes = withInstanceKeys( .setEnvVariable('private', 'CLERK_ENCRYPTION_KEY', constants.E2E_CLERK_ENCRYPTION_KEY || 'a-key'), ); -const sessionsProd1 = withInstanceKeys( +const sessionsProd1 = await withInstanceKeys( 'sessions-prod-1', base .clone() @@ -122,9 +181,9 @@ const withSharedUIVariant = withEmailCodes .setId('withSharedUIVariant') .setEnvVariable('public', 'CLERK_UI_VARIANT', 'shared'); -const withEmailLinks = withInstanceKeys('with-email-links', base.clone().setId('withEmailLinks')); +const withEmailLinks = await withInstanceKeys('with-email-links', base.clone().setId('withEmailLinks')); -const withEnterpriseSso = withInstanceKeys( +const withEnterpriseSso = await withInstanceKeys( 'with-enterprise-sso', base .clone() @@ -132,7 +191,7 @@ const withEnterpriseSso = withInstanceKeys( .setEnvVariable('private', 'CLERK_ENCRYPTION_KEY', constants.E2E_CLERK_ENCRYPTION_KEY || 'a-key'), ); -const withCustomRoles = withInstanceKeys( +const withCustomRoles = await withInstanceKeys( 'with-custom-roles', base .clone() @@ -141,7 +200,7 @@ const withCustomRoles = withInstanceKeys( .setEnvVariable('public', 'CLERK_UI_URL', constants.E2E_APP_CLERK_UI || 'http://localhost:18212/ui.browser.js'), ); -const withReverification = withInstanceKeys( +const withReverification = await withInstanceKeys( 'with-reverification', base .clone() @@ -187,11 +246,14 @@ const withDynamicKeys = withEmailCodes .setEnvVariable('private', 'CLERK_SECRET_KEY', '') .setEnvVariable('private', 'CLERK_DYNAMIC_SECRET_KEY', withEmailCodes.privateVariables.get('CLERK_SECRET_KEY')); -const withRestrictedMode = withInstanceKeys('with-restricted-mode', withEmailCodes.clone().setId('withRestrictedMode')); +const withRestrictedMode = await withInstanceKeys( + 'with-restricted-mode', + withEmailCodes.clone().setId('withRestrictedMode'), +); -const withLegalConsent = withInstanceKeys('with-legal-consent', base.clone().setId('withLegalConsent')); +const withLegalConsent = await withInstanceKeys('with-legal-consent', base.clone().setId('withLegalConsent')); -const withWaitlistMode = withInstanceKeys('with-waitlist-mode', withEmailCodes.clone().setId('withWaitlistMode')); +const withWaitlistMode = await withInstanceKeys('with-waitlist-mode', withEmailCodes.clone().setId('withWaitlistMode')); const withEmailCodesProxy = withEmailCodes .clone() @@ -208,7 +270,7 @@ const withSignInOrUpEmailLinksFlow = withEmailLinks .setId('withSignInOrUpEmailLinksFlow') .setEnvVariable('public', 'CLERK_SIGN_UP_URL', undefined); -const withSignInOrUpwithRestrictedModeFlow = withInstanceKeys( +const withSignInOrUpwithRestrictedModeFlow = await withInstanceKeys( 'with-restricted-mode', withEmailCodes .clone() @@ -216,7 +278,7 @@ const withSignInOrUpwithRestrictedModeFlow = withInstanceKeys( .setEnvVariable('public', 'CLERK_SIGN_UP_URL', undefined), ); -const withSessionTasks = withInstanceKeys( +const withSessionTasks = await withInstanceKeys( 'with-session-tasks', base .clone() @@ -224,12 +286,12 @@ const withSessionTasks = withInstanceKeys( .setEnvVariable('private', 'CLERK_ENCRYPTION_KEY', constants.E2E_CLERK_ENCRYPTION_KEY || 'a-key'), ); -const withSessionTasksResetPassword = withInstanceKeys( +const withSessionTasksResetPassword = await withInstanceKeys( 'with-session-tasks-reset-password', base.clone().setId('withSessionTasksResetPassword'), ); -const withSessionTasksSetupMfa = withInstanceKeys( +const withSessionTasksSetupMfa = await withInstanceKeys( 'with-session-tasks-setup-mfa', base .clone() @@ -237,19 +299,25 @@ const withSessionTasksSetupMfa = withInstanceKeys( .setEnvVariable('private', 'CLERK_ENCRYPTION_KEY', constants.E2E_CLERK_ENCRYPTION_KEY || 'a-key'), ); -const withBillingJwtV2 = withInstanceKeys('with-billing', base.clone().setId('withBillingJwtV2')); +const withBillingJwtV2 = await withInstanceKeys('with-billing', base.clone().setId('withBillingJwtV2')); -const withBilling = withInstanceKeys('with-billing', base.clone().setId('withBilling')); +const withBilling = await withInstanceKeys('with-billing', base.clone().setId('withBilling')); -const withWhatsappPhoneCode = withInstanceKeys('with-whatsapp-phone-code', base.clone().setId('withWhatsappPhoneCode')); +const withWhatsappPhoneCode = await withInstanceKeys( + 'with-whatsapp-phone-code', + base.clone().setId('withWhatsappPhoneCode'), +); -const withAPIKeys = withInstanceKeys('with-api-keys', base.clone().setId('withAPIKeys')); +const withAPIKeys = await withInstanceKeys('with-api-keys', base.clone().setId('withAPIKeys')); -const withProtectService = withInstanceKeys('with-protect-service', base.clone().setId('withProtectService')); +const withProtectService = await withInstanceKeys('with-protect-service', base.clone().setId('withProtectService')); -const withNeedsClientTrust = withInstanceKeys('with-needs-client-trust', base.clone().setId('withNeedsClientTrust')); +const withNeedsClientTrust = await withInstanceKeys( + 'with-needs-client-trust', + base.clone().setId('withNeedsClientTrust'), +); -const withPasskeys = withInstanceKeys('with-passkeys', base.clone().setId('withPasskeys')); +const withPasskeys = await withInstanceKeys('with-passkeys', base.clone().setId('withPasskeys')); export const envs = { base, diff --git a/integration/presets/instanceKeys.ts b/integration/presets/instanceKeys.ts new file mode 100644 index 00000000000..6b648448345 --- /dev/null +++ b/integration/presets/instanceKeys.ts @@ -0,0 +1,34 @@ +import { resolve } from 'node:path'; + +import fs from 'fs-extra'; + +import { constants } from '../constants'; + +const getInstanceKeys = () => { + let keys: Record; + try { + keys = constants.INTEGRATION_INSTANCE_KEYS + ? JSON.parse(constants.INTEGRATION_INSTANCE_KEYS) + : fs.readJSONSync(resolve(import.meta.dirname, '..', '.keys.json')) || null; + } catch (error) { + console.log('Could not find .keys.json file', error); + } + if (!keys) { + throw new Error('Missing instance keys. Is your env or .keys.json file populated?'); + } + + try { + const stagingKeys: Record = constants.INTEGRATION_STAGING_INSTANCE_KEYS + ? JSON.parse(constants.INTEGRATION_STAGING_INSTANCE_KEYS) + : fs.readJSONSync(resolve(import.meta.dirname, '..', '.keys.staging.json')) || null; + if (stagingKeys) { + Object.assign(keys, stagingKeys); + } + } catch { + // Staging keys are optional + } + + return new Map(Object.entries(keys)); +}; + +export const instanceKeys = getInstanceKeys(); diff --git a/integration/presets/platformApplication.ts b/integration/presets/platformApplication.ts new file mode 100644 index 00000000000..714daaff05d --- /dev/null +++ b/integration/presets/platformApplication.ts @@ -0,0 +1,123 @@ +import { randomBytes } from 'node:crypto'; + +import type { ClerkClient } from '@clerk/backend'; +import { createClerkClient } from '@clerk/backend'; + +import { getE2EApplicationRunMarker } from '../testUtils/e2eRun'; + +const PLATFORM_API_URL = 'https://api.clerk.com'; + +export type InstanceKeys = { pk: string; sk: string }; +export type PlatformApplication = InstanceKeys & { applicationId: string; instanceId: string }; +export type PlatformApplicationListItem = { + application_id: string; + name: string; + instances: Array<{ environment_type?: string; instance_id?: string }>; +}; +export type PlatformApplicationConfig = { + config: unknown; + setup?: (clerkClient: ClerkClient) => Promise | void; +}; + +export const defineConfig = (config: PlatformApplicationConfig): PlatformApplicationConfig => config; + +const platformApiRequest = async (platformApiKey: string, url: URL, init?: RequestInit) => { + const headers = new Headers(init?.headers); + headers.set('Authorization', `Bearer ${platformApiKey}`); + headers.set('Accept', 'application/json'); + const response = await fetch(url, { ...init, headers }); + + if (!response.ok) { + throw new Error(`${init?.method || 'GET'} ${url} failed (${response.status}): ${await response.text()}`); + } + + return response; +}; + +export const listApplications = async (platformApiKey: string): Promise => { + const response = await platformApiRequest(platformApiKey, new URL('/v1/platform/applications', PLATFORM_API_URL)); + return (await response.json()) as PlatformApplicationListItem[]; +}; + +export const deleteApplication = async (platformApiKey: string, applicationId: string): Promise => { + await platformApiRequest( + platformApiKey, + new URL(`/v1/platform/applications/${encodeURIComponent(applicationId)}`, PLATFORM_API_URL), + { method: 'DELETE' }, + ); +}; + +export const createApplicationFromConfig = async ( + platformApiKey: string, + keyName: string, + definition: PlatformApplicationConfig, + runKey?: string, +): Promise => { + const { config, setup } = definition; + const applicationRunMarker = + getE2EApplicationRunMarker(runKey) || + `random-${Array.from(randomBytes(10), byte => String.fromCharCode(97 + (byte % 26))).join('')}`; + const applicationName = `e2e-${keyName}-${applicationRunMarker}`; + const createUrl = new URL('/v1/platform/applications', PLATFORM_API_URL); + const createResponse = await platformApiRequest(platformApiKey, createUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: applicationName, from_source: 'cli' }), + }); + const created = (await createResponse.json()) as { application_id?: string }; + + if (!created.application_id) { + throw new Error('The create response does not contain an application ID.'); + } + + const applicationPath = `/v1/platform/applications/${encodeURIComponent(created.application_id)}`; + const applicationUrl = new URL(`${applicationPath}?include_secret_keys=true`, PLATFORM_API_URL); + const applicationResponse = await platformApiRequest(platformApiKey, applicationUrl); + const application = (await applicationResponse.json()) as { + instances?: Array<{ + environment_type?: string; + instance_id?: string; + publishable_key?: string; + secret_key?: string; + }>; + }; + const developmentInstance = application.instances?.find(instance => instance.environment_type === 'development'); + + if (!developmentInstance?.instance_id) { + throw new Error(`Application ${created.application_id} does not have a development instance.`); + } + if (!developmentInstance.publishable_key || !developmentInstance.secret_key) { + throw new Error(`Development instance ${developmentInstance.instance_id} does not contain both API keys.`); + } + + const configUrl = new URL( + `${applicationPath}/instances/${encodeURIComponent(developmentInstance.instance_id)}/config`, + PLATFORM_API_URL, + ); + await platformApiRequest(platformApiKey, configUrl, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(config), + }); + + const organizationSettings = (config as { organization_settings?: { force_organization_selection?: boolean } }) + .organization_settings; + if (organizationSettings?.force_organization_selection === false) { + await platformApiRequest(platformApiKey, configUrl, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ organization_settings: { force_organization_selection: false } }), + }); + } + + if (setup) { + await setup(createClerkClient({ secretKey: developmentInstance.secret_key })); + } + + return { + applicationId: created.application_id, + instanceId: developmentInstance.instance_id, + pk: developmentInstance.publishable_key, + sk: developmentInstance.secret_key, + }; +}; diff --git a/integration/scripts/index.ts b/integration/scripts/index.ts index ff301be7798..5399bc06ae1 100644 --- a/integration/scripts/index.ts +++ b/integration/scripts/index.ts @@ -1,5 +1,5 @@ import { default as _chalk } from 'chalk'; -import * as _fs from 'fs-extra'; +import _fs from 'fs-extra'; import _getPort from 'get-port'; export const getPort = _getPort; diff --git a/integration/templates/index.ts b/integration/templates/index.ts index 2fd05daa6ae..77d6c2bb30c 100644 --- a/integration/templates/index.ts +++ b/integration/templates/index.ts @@ -1,30 +1,27 @@ import { resolve } from 'node:path'; export const templates = { - // __dirname and __filename are defined only in CJS files - // If /integration becomes a module in the future, use these helpers: - // 'next-app-router': fileURLToPath(new URL('./next-app-router', import.meta.url)), - 'next-app-router': resolve(__dirname, './next-app-router'), - 'next-cache-components': resolve(__dirname, './next-cache-components'), - 'next-app-router-quickstart': resolve(__dirname, './next-app-router-quickstart'), - 'next-app-router-bundled-ui': resolve(__dirname, './next-app-router-bundled-ui'), - 'next-app-router-quickstart-v6': resolve(__dirname, './next-app-router-quickstart-v6'), - 'react-vite': resolve(__dirname, './react-vite'), - 'express-vite': resolve(__dirname, './express-vite'), - 'fastify-vite': resolve(__dirname, './fastify-vite'), - 'hono-vite': resolve(__dirname, './hono-vite'), - 'elements-next': resolve(__dirname, './elements-next'), - 'astro-node': resolve(__dirname, './astro-node'), - 'astro-hybrid': resolve(__dirname, './astro-hybrid'), - 'expo-web': resolve(__dirname, './expo-web'), - 'tanstack-react-start': resolve(__dirname, './tanstack-react-start'), - 'vue-vite': resolve(__dirname, './vue-vite'), - 'nuxt-node': resolve(__dirname, './nuxt-node'), - 'react-router-node': resolve(__dirname, './react-router-node'), - 'react-router-library': resolve(__dirname, './react-router-library'), - 'custom-flows-react-vite': resolve(__dirname, './custom-flows-react-vite'), - 'chrome-extension-vite': resolve(__dirname, './chrome-extension-vite'), - 'electron-vite': resolve(__dirname, './electron-vite'), + 'next-app-router': resolve(import.meta.dirname, './next-app-router'), + 'next-cache-components': resolve(import.meta.dirname, './next-cache-components'), + 'next-app-router-quickstart': resolve(import.meta.dirname, './next-app-router-quickstart'), + 'next-app-router-bundled-ui': resolve(import.meta.dirname, './next-app-router-bundled-ui'), + 'next-app-router-quickstart-v6': resolve(import.meta.dirname, './next-app-router-quickstart-v6'), + 'react-vite': resolve(import.meta.dirname, './react-vite'), + 'express-vite': resolve(import.meta.dirname, './express-vite'), + 'fastify-vite': resolve(import.meta.dirname, './fastify-vite'), + 'hono-vite': resolve(import.meta.dirname, './hono-vite'), + 'elements-next': resolve(import.meta.dirname, './elements-next'), + 'astro-node': resolve(import.meta.dirname, './astro-node'), + 'astro-hybrid': resolve(import.meta.dirname, './astro-hybrid'), + 'expo-web': resolve(import.meta.dirname, './expo-web'), + 'tanstack-react-start': resolve(import.meta.dirname, './tanstack-react-start'), + 'vue-vite': resolve(import.meta.dirname, './vue-vite'), + 'nuxt-node': resolve(import.meta.dirname, './nuxt-node'), + 'react-router-node': resolve(import.meta.dirname, './react-router-node'), + 'react-router-library': resolve(import.meta.dirname, './react-router-library'), + 'custom-flows-react-vite': resolve(import.meta.dirname, './custom-flows-react-vite'), + 'chrome-extension-vite': resolve(import.meta.dirname, './chrome-extension-vite'), + 'electron-vite': resolve(import.meta.dirname, './electron-vite'), } as const; if (new Set([...Object.values(templates)]).size !== Object.values(templates).length) { diff --git a/integration/testUtils/e2eRun.ts b/integration/testUtils/e2eRun.ts index e09c280ef45..f1f9c77b2f5 100644 --- a/integration/testUtils/e2eRun.ts +++ b/integration/testUtils/e2eRun.ts @@ -8,13 +8,30 @@ type E2EUserRecord = { privateMetadata: Record; }; -export const getE2ERunMarker = (runKey = process.env.INTEGRATION_TEST_RUN_KEY): string | undefined => { +const encodeHexAsLetters = (hex: string): string => + Array.from(hex, character => String.fromCharCode(97 + Number.parseInt(character, 16))).join(''); + +export const getE2ERunToken = (runKey = process.env.INTEGRATION_TEST_RUN_KEY): string | undefined => { if (!runKey) { return; } const digest = createHash('sha256').update(runKey).digest('hex').slice(0, 20); - return `e2e_${digest}`; + return encodeHexAsLetters(digest); +}; + +export const getE2ERunMarker = (runKey = process.env.INTEGRATION_TEST_RUN_KEY): string | undefined => { + const runToken = getE2ERunToken(runKey); + if (!runToken) { + return; + } + + return `e2e_${runToken}`; +}; + +export const getE2EApplicationRunMarker = (runKey = process.env.INTEGRATION_TEST_RUN_KEY): string | undefined => { + const runToken = getE2ERunToken(runKey); + return runToken ? `run-${runToken}` : undefined; }; export const userMatchesE2ERun = (user: E2EUserRecord, marker: string): boolean => diff --git a/integration/testUtils/emailService.ts b/integration/testUtils/emailService.ts index c1cb085494d..3f87f614a6b 100644 --- a/integration/testUtils/emailService.ts +++ b/integration/testUtils/emailService.ts @@ -1,4 +1,4 @@ -import { runWithExponentialBackOff } from '@clerk/shared/utils'; +import { retry } from '@clerk/shared/retry'; type Message = { _id: string; @@ -23,7 +23,7 @@ export const createEmailService = () => { } // Retry in case the email delivery is delayed await new Promise(res => setTimeout(res, 1500)); - return runWithExponentialBackOff( + return retry( async () => { const res = await fetcher(url); const json = (await res.json()) as unknown as { messages: Message[] }; @@ -34,8 +34,8 @@ export const createEmailService = () => { return message; }, { - firstDelay: 750, - timeMultiple: 2, + initialDelay: 750, + factor: 2, shouldRetry: (_, iterationsCount) => iterationsCount < 5, }, ); diff --git a/integration/testUtils/handshake.ts b/integration/testUtils/handshake.ts index 74c4cea2026..5df95bfe6e3 100644 --- a/integration/testUtils/handshake.ts +++ b/integration/testUtils/handshake.ts @@ -1,5 +1,5 @@ // @ts-ignore ignore types -import * as jwt from 'jsonwebtoken'; +import jwt from 'jsonwebtoken'; // @ts-ignore ignore types import * as uuid from 'uuid'; diff --git a/integration/tests/global.teardown.ts b/integration/tests/global.teardown.ts index 71442e3a2f8..30574cf0a47 100644 --- a/integration/tests/global.teardown.ts +++ b/integration/tests/global.teardown.ts @@ -3,6 +3,7 @@ import { test as setup } from '@playwright/test'; import { constants } from '../constants'; import { stateFile } from '../models/stateFile'; import { appConfigs } from '../presets'; +import { removePlatformApplicationCache } from '../presets/envs'; import { killClerkJsHttpServer, killClerkUiHttpServer, parseEnvOptions } from '../scripts'; import { printRetrySummary } from '../testUtils/retryableClerkClient'; @@ -12,6 +13,7 @@ setup('teardown long running apps', async () => { const { appUrl } = parseEnvOptions(); await killClerkJsHttpServer(); await killClerkUiHttpServer(); + await removePlatformApplicationCache(); if (appUrl || !constants.E2E_CLEANUP) { // if appUrl is provided, it means that the user is running an app manually diff --git a/integration/tsconfig.json b/integration/tsconfig.json index 3db7f853e2e..411067ff5af 100644 --- a/integration/tsconfig.json +++ b/integration/tsconfig.json @@ -1,8 +1,10 @@ { "compilerOptions": { "allowSyntheticDefaultImports": true, + "allowJs": true, "esModuleInterop": true, - "module": "NodeNext", + "module": "ESNext", + "moduleResolution": "Bundler", "target": "ESNext" }, "exclude": ["templates"]