From 41f496140589b7a10be70ff135f3d08c665adfd2 Mon Sep 17 00:00:00 2001 From: Jacek Date: Fri, 15 May 2026 22:19:43 -0500 Subject: [PATCH] test(clerk-js): add ABI contract tripwires --- .changeset/clerk-js-abi-contract-tests.md | 2 + .../clerk-js/src/core/__abi__/manifest.ts | 269 ++++++++++++++++++ .../core/__tests__/clerk.abi-contract.test.ts | 193 +++++++++++++ .../__tests__/clerk.abi-usage-scan.test.ts | 150 ++++++++++ 4 files changed, 614 insertions(+) create mode 100644 .changeset/clerk-js-abi-contract-tests.md create mode 100644 packages/clerk-js/src/core/__abi__/manifest.ts create mode 100644 packages/clerk-js/src/core/__tests__/clerk.abi-contract.test.ts create mode 100644 packages/clerk-js/src/core/__tests__/clerk.abi-usage-scan.test.ts diff --git a/.changeset/clerk-js-abi-contract-tests.md b/.changeset/clerk-js-abi-contract-tests.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/clerk-js-abi-contract-tests.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/clerk-js/src/core/__abi__/manifest.ts b/packages/clerk-js/src/core/__abi__/manifest.ts new file mode 100644 index 00000000000..6a4de06663c --- /dev/null +++ b/packages/clerk-js/src/core/__abi__/manifest.ts @@ -0,0 +1,269 @@ +/** + * Cross-bundle ABI manifest for `Clerk` and `IsomorphicClerk`. + * + * Each entry below is a runtime `__internal_*` member that is read or written + * by an independently-shipped npm package (e.g. `@clerk/shared`, `@clerk/react`, + * `@clerk/nextjs`, `@clerk/expo`, `@clerk/tanstack-react-start`). Because + * `@clerk/clerk-js` auto-updates from the CDN underneath those pinned-on-npm + * consumers, removing or reshaping any member here is a breaking change for + * every still-supported consumer that calls it, even if no monorepo file does. + * + * Scope (intentionally narrow): + * - Members on the live `Clerk` instance (and `IsomorphicClerk` queue) + * - That are reached at runtime from outside `@clerk/clerk-js` + * + * Out of scope (separate manifests, separate tests): + * - Script-loader options (`__internal_clerkJSUrl`, `__internal_clerkJSVersion`, + * `__internal_clerkUIUrl`, `__internal_clerkUIVersion`) + * - Window-level globals (`window.__internal_onBeforeSetActive`, + * `window.__internal_onAfterSetActive`, `window.__internal_ClerkUICtor`) + * - Resource-level internals (`__internal_toSnapshot` on Resource shapes) + * + * Policy: see `docs/CONTRIBUTING.md` ("Cross-bundle ABI"). + * Adding an entry is always fine. Removing or reshaping an entry requires a + * functional compat shim until the SDK compatibility matrix (separate work) + * confirms no still-supported consumer remains. + * + * Sibling tests: + * - `clerk.abi-contract.test.ts` asserts each entry exists on a fresh + * `new Clerk(pk)` with the right `kind`, and pins this list via snapshot. + * - `clerk.abi-usage-scan.test.ts` scans consumer packages and asserts every + * `.__internal_X` read corresponds to an entry here. + */ + +export type AbiMemberKind = + /** ES getter on the class (`get name() { ... }`). Read-only. */ + | 'getter' + /** Bound method or arrow function. `typeof inst[name] === 'function'`. */ + | 'method' + /** Plain property with a default value baked in. */ + | 'property' + /** Property declared as `T | undefined`, intentionally written by SDKs. */ + | 'assignable-slot'; + +export type AbiMember = { + /** Member name on the `Clerk` instance. */ + readonly name: string; + /** How the member is declared on `Clerk`; drives contract-test assertion. */ + readonly kind: AbiMemberKind; + /** + * Consumer packages that read or write this member at runtime today. + * Names match `packages/` not npm names. + */ + readonly consumers: readonly string[]; + /** + * Published package versions that still consume this member, even if current + * main no longer does. These keep compat shims in the manifest until the + * compatibility matrix proves they are out of the support window. + */ + readonly historicalConsumers?: readonly { + readonly packageName: string; + readonly range: string; + readonly note: string; + }[]; + /** Free-form context (e.g. "restored by #8562 as backward-compat shim"). */ + readonly note?: string; +}; + +export const ABI_MEMBERS = [ + // ── Core state and options ───────────────────────────────────────────── + { + name: '__internal_state', + kind: 'property', + consumers: ['react', 'shared'], + }, + { + name: '__internal_country', + kind: 'property', + consumers: ['ui'], + }, + { + name: '__internal_getOption', + kind: 'method', + consumers: ['react', 'shared', 'ui'], + }, + { + name: '__internal_lastEmittedResources', + kind: 'property', + consumers: ['react', 'shared', 'ui'], + }, + { + name: '__internal_environment', + kind: 'getter', + consumers: ['react', 'shared', 'expo'], + }, + { + name: '__internal_setActiveInProgress', + kind: 'property', + consumers: ['ui'], + }, + + // ── Modal open/close (consumed by @clerk/react through IsomorphicClerk) ─ + { + name: '__internal_openCheckout', + kind: 'method', + consumers: ['astro', 'react', 'ui'], + }, + { + name: '__internal_closeCheckout', + kind: 'method', + consumers: ['react'], + }, + { + name: '__internal_openPlanDetails', + kind: 'method', + consumers: ['astro', 'react', 'ui'], + }, + { + name: '__internal_closePlanDetails', + kind: 'method', + consumers: ['react'], + }, + { + name: '__internal_openSubscriptionDetails', + kind: 'method', + consumers: ['astro', 'react', 'ui'], + }, + { + name: '__internal_closeSubscriptionDetails', + kind: 'method', + consumers: ['react'], + }, + { + name: '__internal_openReverification', + kind: 'method', + consumers: ['react', 'shared'], + }, + { + name: '__internal_closeReverification', + kind: 'method', + consumers: ['react'], + }, + { + name: '__internal_openEnableOrganizationsPrompt', + kind: 'method', + consumers: ['react'], + }, + { + name: '__internal_closeEnableOrganizationsPrompt', + kind: 'method', + consumers: ['react', 'ui'], + }, + { + name: '__internal_mountOAuthConsent', + kind: 'method', + consumers: ['react'], + }, + { + name: '__internal_unmountOAuthConsent', + kind: 'method', + consumers: ['react'], + }, + + // ── Modal-adjacent helpers ───────────────────────────────────────────── + { + name: '__internal_attemptToEnableEnvironmentSetting', + kind: 'method', + consumers: ['react', 'shared'], + }, + { + name: '__internal_loadStripeJs', + kind: 'method', + consumers: ['react', 'shared'], + }, + { + name: '__internal_setEnvironment', + kind: 'method', + consumers: ['react'], + }, + { + name: '__internal_updateProps', + kind: 'method', + consumers: ['astro', 'react', 'vue'], + }, + + // ── Navigation ───────────────────────────────────────────────────────── + { + name: '__internal_addNavigationListener', + kind: 'method', + consumers: ['ui'], + }, + { + name: '__internal_navigateWithError', + kind: 'method', + consumers: ['ui'], + }, + + // ── Cached / initial resources (read AND written by SDKs) ────────────── + { + name: '__internal_getCachedResources', + kind: 'assignable-slot', + consumers: ['expo'], + note: 'expo assigns this so non-standard browsers can fall back to cached initial resources.', + }, + { + name: '__internal_reloadInitialResources', + kind: 'method', + consumers: ['expo'], + }, + + // ── FAPI hook installation (expo) ────────────────────────────────────── + { + name: '__internal_onBeforeRequest', + kind: 'method', + consumers: ['chrome-extension', 'expo'], + }, + { + name: '__internal_onAfterResponse', + kind: 'method', + consumers: ['chrome-extension', 'expo'], + }, + + // ── WebAuthn extension slots (expo assigns these at boot) ────────────── + { + name: '__internal_createPublicCredentials', + kind: 'assignable-slot', + consumers: ['expo'], + }, + { + name: '__internal_getPublicCredentials', + kind: 'assignable-slot', + consumers: ['expo'], + }, + { + name: '__internal_isWebAuthnSupported', + kind: 'assignable-slot', + consumers: ['expo', 'ui'], + }, + { + name: '__internal_isWebAuthnAutofillSupported', + kind: 'assignable-slot', + consumers: ['expo'], + }, + { + name: '__internal_isWebAuthnPlatformAuthenticatorSupported', + kind: 'assignable-slot', + consumers: ['expo'], + }, + + // ── Backward-compat shims (added in response to incidents) ───────────── + { + name: '__internal_queryClient', + kind: 'getter', + consumers: [], + historicalConsumers: [ + { + packageName: '@clerk/shared', + range: '<4.10.0', + note: '@clerk/shared < 4.10.0 reads this to bootstrap its TanStack QueryClient.', + }, + ], + note: + 'Restored in #8562 after #8434 removed it; @clerk/shared < 4.10.0 reads this to bootstrap its TanStack QueryClient. Removable only when the compatibility matrix confirms no in-window shared version still reads it.', + }, +] as const satisfies readonly AbiMember[]; + +/** Index by name for O(1) lookup in tests. */ +export const ABI_MEMBERS_BY_NAME: Readonly> = Object.freeze( + Object.fromEntries(ABI_MEMBERS.map(m => [m.name, m])), +); diff --git a/packages/clerk-js/src/core/__tests__/clerk.abi-contract.test.ts b/packages/clerk-js/src/core/__tests__/clerk.abi-contract.test.ts new file mode 100644 index 00000000000..cef9ae8c754 --- /dev/null +++ b/packages/clerk-js/src/core/__tests__/clerk.abi-contract.test.ts @@ -0,0 +1,193 @@ +/** + * Cross-bundle ABI contract test. + * + * Tripwire #1 of two (the other is `clerk.abi-usage-scan.test.ts`). + * + * Asserts that every member in `__abi__/manifest.ts` is present on a fresh + * `new Clerk(pk)` with the right `kind`. This catches *implementation + * removal*: deleting or renaming a member on `Clerk` while leaving the + * manifest unchanged fails here. + * + * The companion snapshot test pins the manifest's membership list itself, + * so deleting a manifest row also fails (separately from the usage scanner, + * which catches manifest rows that contradict real consumer usage). + * + * See `__abi__/manifest.ts` for scope and policy. + */ + +import { existsSync, readdirSync, readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { describe, expect, it, vi } from 'vitest'; + +import { ABI_MEMBERS, type AbiMember, type AbiMemberKind } from '../__abi__/manifest'; +import { Clerk } from '../clerk'; +import { Client, Environment } from '../resources/internal'; + +vi.mock('../resources/Client'); +vi.mock('../resources/Environment'); + +vi.mock('../auth/devBrowser', () => ({ + createDevBrowser: () => ({ + clear: vi.fn(), + setup: vi.fn(), + getDevBrowser: vi.fn(() => 'deadbeef'), + setDevBrowser: vi.fn(), + removeDevBrowser: vi.fn(), + refreshCookies: vi.fn(), + }), +})); + +Client.getOrCreateInstance = vi.fn().mockImplementation(() => ({ fetch: vi.fn() })); +Environment.getInstance = vi.fn().mockImplementation(() => ({ fetch: vi.fn(() => Promise.resolve({})) })); + +const publishableKey = 'pk_test_Y2xlcmsuYWJjZWYuMTIzNDUuZGV2LmxjbGNsZXJrLmNvbSQ'; +const currentDir = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(currentDir, '../../../../..'); +const clerkSource = readFileSync(resolve(currentDir, '../clerk.ts'), 'utf8'); + +/** + * Look up the property descriptor for `name`, walking the prototype chain. + * Returns `undefined` if the member does not exist anywhere on the instance + * or its prototype chain (stopping at `Object.prototype`). + * + * Using descriptors rather than direct access lets us assert presence + * without triggering side effects on lazy getters (notably + * `__internal_queryClient`, which kicks off a dynamic import on read). + */ +function findDescriptor(instance: object, name: string): PropertyDescriptor | undefined { + if (Object.getOwnPropertyDescriptor(instance, name)) { + return Object.getOwnPropertyDescriptor(instance, name); + } + let proto: object | null = Object.getPrototypeOf(instance); + while (proto && proto !== Object.prototype) { + const d = Object.getOwnPropertyDescriptor(proto, name); + if (d) { + return d; + } + proto = Object.getPrototypeOf(proto); + } + return undefined; +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function sourceDeclaresMember(name: string): boolean { + const escaped = escapeRegExp(name); + return new RegExp(String.raw`\b(?:get\s+)?${escaped}\b(?:\s*[(:=]|[?!]?:)`).test(clerkSource); +} + +function assertMember(instance: Clerk, member: AbiMember): void { + const { name, kind } = member; + const descriptor = findDescriptor(instance, name); + const declaredInSource = sourceDeclaresMember(name); + + expect( + descriptor || declaredInSource, + `manifest member ${name} is missing on the Clerk instance and in clerk.ts`, + ).toBeTruthy(); + + const checkers: Record void> = { + getter: d => { + expect(typeof d.get, `${name} is declared kind=getter; expected an ES getter`).toBe('function'); + }, + method: d => { + // Arrow methods are own data properties whose value is a function. + // Method-syntax methods are prototype data properties whose value is a function. + const value = 'value' in d ? d.value : undefined; + expect(typeof value, `${name} is declared kind=method; expected a function value`).toBe('function'); + }, + property: d => { + expect('value' in d || typeof d.get === 'function', `${name} kind=property must be a data or accessor property`).toBe( + true, + ); + }, + 'assignable-slot': d => { + // Assignable slots are declared as `public X: T | undefined`. They must + // be writable so external SDKs (e.g. @clerk/expo) can install handlers. + expect(d.writable !== false, `${name} kind=assignable-slot must be writable`).toBe(true); + }, + }; + + if (descriptor) { + checkers[kind](descriptor); + return; + } + + expect( + kind === 'property' || kind === 'assignable-slot', + `${name} is declared kind=${kind}; expected an emitted instance or prototype descriptor`, + ).toBe(true); +} + +describe('Cross-bundle ABI contract', () => { + it('every manifest member exists on a fresh Clerk instance with the right kind', () => { + const clerk = new Clerk(publishableKey); + for (const member of ABI_MEMBERS) { + assertMember(clerk, member); + } + }); + + it('manifest membership is pinned (drift detection)', () => { + // Snapshot the sorted list of member names. Adding a member updates the + // snapshot freely. Removing a member fails this test, which is the + // intended tripwire: forces the PR author to delete a snapshot line, + // which forces a reviewer to ask "what about consumers in the wild?". + const names = ABI_MEMBERS.map(m => m.name).sort(); + expect(names).toMatchInlineSnapshot(` + [ + "__internal_addNavigationListener", + "__internal_attemptToEnableEnvironmentSetting", + "__internal_closeCheckout", + "__internal_closeEnableOrganizationsPrompt", + "__internal_closePlanDetails", + "__internal_closeReverification", + "__internal_closeSubscriptionDetails", + "__internal_country", + "__internal_createPublicCredentials", + "__internal_environment", + "__internal_getCachedResources", + "__internal_getOption", + "__internal_getPublicCredentials", + "__internal_isWebAuthnAutofillSupported", + "__internal_isWebAuthnPlatformAuthenticatorSupported", + "__internal_isWebAuthnSupported", + "__internal_lastEmittedResources", + "__internal_loadStripeJs", + "__internal_mountOAuthConsent", + "__internal_navigateWithError", + "__internal_onAfterResponse", + "__internal_onBeforeRequest", + "__internal_openCheckout", + "__internal_openEnableOrganizationsPrompt", + "__internal_openPlanDetails", + "__internal_openReverification", + "__internal_openSubscriptionDetails", + "__internal_queryClient", + "__internal_reloadInitialResources", + "__internal_setActiveInProgress", + "__internal_setEnvironment", + "__internal_state", + "__internal_unmountOAuthConsent", + "__internal_updateProps", + ] + `); + }); + + it('every consumer reference in the manifest points to an existing packages/* directory name', () => { + // Sanity check: the `consumers` arrays are not free-form. They must match + // real package directory names so the usage scanner can find them. + const packagesDir = resolve(repoRoot, 'packages'); + const known = new Set( + readdirSync(packagesDir).filter(dir => existsSync(resolve(packagesDir, dir, 'package.json'))), + ); + for (const member of ABI_MEMBERS) { + for (const consumer of member.consumers) { + expect(known.has(consumer), `consumer "${consumer}" on ${member.name} is not a known packages/* dir`).toBe(true); + } + } + }); +}); diff --git a/packages/clerk-js/src/core/__tests__/clerk.abi-usage-scan.test.ts b/packages/clerk-js/src/core/__tests__/clerk.abi-usage-scan.test.ts new file mode 100644 index 00000000000..7a02b341389 --- /dev/null +++ b/packages/clerk-js/src/core/__tests__/clerk.abi-usage-scan.test.ts @@ -0,0 +1,150 @@ +/** + * Cross-bundle ABI usage scanner. + * + * Tripwire #2 of two (the other is `clerk.abi-contract.test.ts`). + * + * Scans non-`@clerk/clerk-js` package source for runtime access to + * `.__internal_X` members and verifies the manifest is the + * source of truth for those members. + */ + +import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; +import { dirname, relative, resolve, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { describe, expect, it } from 'vitest'; + +import { ABI_MEMBERS, ABI_MEMBERS_BY_NAME } from '../__abi__/manifest'; + +const currentDir = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(currentDir, '../../../../..'); +const packagesDir = resolve(repoRoot, 'packages'); + +const sourceExtensions = new Set(['.ts', '.tsx', '.mts', '.cts']); + +type UsageMap = Map>>; + +function packageDirs(): string[] { + return readdirSync(packagesDir) + .filter(dir => dir !== 'clerk-js') + .filter(dir => existsSync(resolve(packagesDir, dir, 'package.json'))) + .filter(dir => existsSync(resolve(packagesDir, dir, 'src'))) + .sort(); +} + +function shouldSkipFile(filePath: string): boolean { + const normalized = filePath.split(sep).join('/'); + if (normalized.includes('/__tests__/')) { + return true; + } + if (/\.(test|spec)\.[cm]?[tj]sx?$/.test(normalized)) { + return true; + } + if (/\.d\.ts$/.test(normalized)) { + return true; + } + if (normalized.includes('/src/types/')) { + return true; + } + return false; +} + +function walk(dir: string, out: string[] = []): string[] { + for (const entry of readdirSync(dir)) { + const path = resolve(dir, entry); + const stat = statSync(path); + if (stat.isDirectory()) { + walk(path, out); + continue; + } + if (!stat.isFile() || shouldSkipFile(path)) { + continue; + } + const extension = path.match(/(\.[^.]+)$/)?.[1]; + if (extension && sourceExtensions.has(extension)) { + out.push(path); + } + } + return out; +} + +function addUsage(usages: UsageMap, memberName: string, packageName: string, filePath: string): void { + if (!usages.has(memberName)) { + usages.set(memberName, new Map()); + } + const packageUsages = usages.get(memberName)!; + if (!packageUsages.has(packageName)) { + packageUsages.set(packageName, new Set()); + } + packageUsages.get(packageName)!.add(relative(repoRoot, filePath)); +} + +function extractMemberNames(contents: string): Set { + const names = new Set(); + + const instanceAccess = + /(?:\(?\b(?:clerk|clerkjs|clerkAny|clerkRecord|clerkInstance|__internal_clerk|isomorphicClerk)(?:Ref\.current)?|\(?\bthis\.(?:clerk|clerkjs)|\(?\bwindow\.Clerk)(?:\s+as\s+any)?\)?\??\.__internal_([A-Za-z0-9_]+)/g; + + for (const match of contents.matchAll(instanceAccess)) { + names.add(`__internal_${match[1]}`); + } + + const useClerkDestructure = /\{([^{}]*__internal_[A-Za-z0-9_][^{}]*)\}\s*=\s*(?:useClerk|useClerkInstanceContext)\s*\(/g; + for (const match of contents.matchAll(useClerkDestructure)) { + for (const member of match[1].matchAll(/\b(__internal_[A-Za-z0-9_]+)\b/g)) { + names.add(member[1]); + } + } + + return names; +} + +function scanUsages(): UsageMap { + const usages: UsageMap = new Map(); + for (const packageName of packageDirs()) { + for (const filePath of walk(resolve(packagesDir, packageName, 'src'))) { + const contents = readFileSync(filePath, 'utf8'); + for (const memberName of extractMemberNames(contents)) { + addUsage(usages, memberName, packageName, filePath); + } + } + } + return usages; +} + +function formatUsages(packageUsages: Map> | undefined): string { + if (!packageUsages) { + return ''; + } + return [...packageUsages.entries()] + .sort(([a], [b]) => a.localeCompare(b)) + .map(([packageName, files]) => `${packageName}: ${[...files].sort().join(', ')}`) + .join('\n'); +} + +describe('Cross-bundle ABI usage scan', () => { + const usages = scanUsages(); + + it('every current Clerk internal runtime reach site is declared in the ABI manifest', () => { + const undeclared = [...usages.keys()].filter(name => !ABI_MEMBERS_BY_NAME[name]).sort(); + expect(undeclared, `undeclared ABI usages:\n${undeclared.map(name => `${name}\n${formatUsages(usages.get(name))}`).join('\n')}`).toEqual([]); + }); + + it('every manifest member has a current or historical consumer', () => { + const dead = ABI_MEMBERS.filter(member => !usages.has(member.name) && !member.historicalConsumers?.length).map( + member => member.name, + ); + expect(dead, `manifest members with no current or historical consumer: ${dead.join(', ')}`).toEqual([]); + }); + + it('manifest current consumer lists match scanned current consumers', () => { + for (const member of ABI_MEMBERS) { + const actualConsumers = [...(usages.get(member.name)?.keys() ?? [])].sort(); + const declaredConsumers = [...member.consumers].sort(); + expect( + declaredConsumers, + `${member.name} consumers are out of sync.\nScanned usages:\n${formatUsages(usages.get(member.name))}`, + ).toEqual(actualConsumers); + } + }); +});