diff --git a/.changeset/thirty-breads-strive.md b/.changeset/thirty-breads-strive.md new file mode 100644 index 00000000000..27ec89a28f5 --- /dev/null +++ b/.changeset/thirty-breads-strive.md @@ -0,0 +1,24 @@ +--- +'@clerk/localizations': patch +'@clerk/shared': patch +--- + +A utility function that converts flattened localization objects (with dot-notation keys) to nested `LocalizationResource` objects for use with Clerk's `localization` prop. + +```typescript +import { ClerkProvider } from '@clerk/nextjs'; +import { flatLocalization } from '@clerk/localizations/utils'; + +const localization = flatLocalization({ + 'formFieldLabel__emailAddress': 'Email address', + 'unstable__errors.passwordComplexity.maximumLength': 'Password is too long', +}); + +export default function App() { + return ( + + {/* Your app */} + + ); +} +``` diff --git a/packages/localizations/package.json b/packages/localizations/package.json index 217e7af2d4d..0de642e4d47 100644 --- a/packages/localizations/package.json +++ b/packages/localizations/package.json @@ -36,6 +36,16 @@ "default": "./dist/index.js" } }, + "./utils": { + "import": { + "types": "./dist/utils/index.d.mts", + "default": "./dist/utils/index.mjs" + }, + "require": { + "types": "./dist/utils/index.d.ts", + "default": "./dist/utils/index.js" + } + }, "./*": { "import": { "types": "./dist/*.d.mts", @@ -61,12 +71,16 @@ "format:check": "node ../../scripts/format-package.mjs --check", "generate": "tsc src/utils/generate.ts && node src/utils/generate.js && prettier --write src/*.ts", "lint": "eslint src", - "lint:attw": "attw --pack . --profile node16" + "lint:attw": "attw --pack . --profile node16", + "test": "vitest --run" }, "dependencies": { "@clerk/shared": "workspace:^" }, - "devDependencies": {}, + "devDependencies": { + "@vitest/ui": "3.2.4", + "vitest": "3.2.4" + }, "engines": { "node": ">=20.9.0" }, diff --git a/packages/localizations/src/utils/__tests__/flatLocalization.spec.ts b/packages/localizations/src/utils/__tests__/flatLocalization.spec.ts new file mode 100644 index 00000000000..c03dd15b96f --- /dev/null +++ b/packages/localizations/src/utils/__tests__/flatLocalization.spec.ts @@ -0,0 +1,367 @@ +import type { FlattenedLocalizationResource, LocalizationResource } from '@clerk/shared/types'; +import { describe, expect, it } from 'vitest'; + +import { flatLocalization } from '../flatLocalization'; + +describe('flatLocalization', () => { + describe('runtime tests', () => { + it('converts valid flattened keys to nested structure', () => { + const flattened: FlattenedLocalizationResource = { + formFieldLabel__emailAddress: 'Email address', + formFieldLabel__password: 'Password', + }; + + const result = flatLocalization(flattened); + + expect(result).toEqual({ + formFieldLabel__emailAddress: 'Email address', + formFieldLabel__password: 'Password', + }); + }); + + it('converts multiple keys at same level', () => { + const flattened = { + 'a.b.c': 'value1', + 'a.b.d': 'value2', + 'a.e': 'value3', + } as FlattenedLocalizationResource; + + const result = flatLocalization(flattened); + + expect(result).toEqual({ + a: { + b: { + c: 'value1', + d: 'value2', + }, + e: 'value3', + }, + }); + }); + + it('converts deeply nested paths', () => { + const flattened: FlattenedLocalizationResource = { + 'unstable__errors.passwordComplexity.maximumLength': 'Password is too long', + 'unstable__errors.passwordComplexity.minimumLength': 'Password is too short', + 'unstable__errors.passwordComplexity.requireNumbers': 'Password must contain numbers', + }; + + const result = flatLocalization(flattened); + + expect(result).toEqual({ + unstable__errors: { + passwordComplexity: { + maximumLength: 'Password is too long', + minimumLength: 'Password is too short', + requireNumbers: 'Password must contain numbers', + }, + }, + }); + }); + + it('handles top-level keys', () => { + const flattened: FlattenedLocalizationResource = { + locale: 'en-US', + formFieldLabel__emailAddress: 'Email address', + }; + + const result = flatLocalization(flattened); + + expect(result).toEqual({ + locale: 'en-US', + formFieldLabel__emailAddress: 'Email address', + }); + }); + + it('handles empty object', () => { + const flattened: FlattenedLocalizationResource = {}; + + const result = flatLocalization(flattened); + + expect(result).toEqual({}); + }); + + it('handles real-world localization examples', () => { + const flattened: FlattenedLocalizationResource = { + locale: 'en-US', + formFieldLabel__emailAddress: 'Email address', + formFieldLabel__password: 'Password', + 'unstable__errors.passwordComplexity.maximumLength': 'Password is too long', + 'unstable__errors.passwordComplexity.minimumLength': 'Password is too short', + socialButtonsBlockButton: 'Continue with {{provider}}', + }; + + const result = flatLocalization(flattened); + + expect(result).toEqual({ + locale: 'en-US', + formFieldLabel__emailAddress: 'Email address', + formFieldLabel__password: 'Password', + unstable__errors: { + passwordComplexity: { + maximumLength: 'Password is too long', + minimumLength: 'Password is too short', + }, + }, + socialButtonsBlockButton: 'Continue with {{provider}}', + }); + }); + + it('handles single key', () => { + const flattened: FlattenedLocalizationResource = { + locale: 'en-US', + }; + + const result = flatLocalization(flattened); + + expect(result).toEqual({ + locale: 'en-US', + }); + }); + + it('handles all keys at root level', () => { + const flattened = { + a: 'value1', + b: 'value2', + c: 'value3', + } as FlattenedLocalizationResource; + + const result = flatLocalization(flattened); + + expect(result).toEqual({ + a: 'value1', + b: 'value2', + c: 'value3', + }); + }); + + it('handles maximum nesting depth', () => { + const flattened = { + 'a.b.c.d.e.f.g.h.i.j': 'deep value', + } as FlattenedLocalizationResource; + + const result = flatLocalization(flattened); + + expect(result).toEqual({ + a: { + b: { + c: { + d: { + e: { + f: { + g: { + h: { + i: { + j: 'deep value', + }, + }, + }, + }, + }, + }, + }, + }, + }, + }); + }); + + it('handles special characters in values', () => { + const flattened = { + 'a.b': 'Value with "quotes" and \'apostrophes\'', + 'a.c': 'Value with\nnewlines\tand\ttabs', + 'a.d': 'Value with {{interpolation}}', + } as FlattenedLocalizationResource; + + const result = flatLocalization(flattened); + + expect(result).toEqual({ + a: { + b: 'Value with "quotes" and \'apostrophes\'', + c: 'Value with\nnewlines\tand\ttabs', + d: 'Value with {{interpolation}}', + }, + }); + }); + }); + + describe('validation tests', () => { + it('throws error when mixing flattened keys with nested values', () => { + const invalid = { + 'a.b': 'value1', + nested: { + key: 'value2', + }, + } as unknown as FlattenedLocalizationResource; + + expect(() => flatLocalization(invalid)).toThrow(/cannot mix.*flattened|cannot mix.*nested/); + }); + + it('throws error for keys starting with dot', () => { + const invalid = { + '.a.b': 'value', + 'valid.key': 'value2', // Need at least one valid flattened key to trigger validation + } as FlattenedLocalizationResource; + + expect(() => flatLocalization(invalid)).toThrow( + /Invalid flattened key format.*Keys cannot start or end with dots/, + ); + }); + + it('throws error for keys ending with dot', () => { + const invalid = { + 'a.b.': 'value', + 'valid.key': 'value2', // Need at least one valid flattened key to trigger validation + } as FlattenedLocalizationResource; + + expect(() => flatLocalization(invalid)).toThrow( + /Invalid flattened key format.*Keys cannot start or end with dots/, + ); + }); + + it('throws error for consecutive dots', () => { + const invalid = { + 'a..b': 'value', + 'valid.key': 'value2', // Need at least one valid flattened key to trigger validation + } as FlattenedLocalizationResource; + + expect(() => flatLocalization(invalid)).toThrow( + /Invalid flattened key format.*Keys cannot start or end with dots, or contain consecutive dots/, + ); + }); + + it('throws error for __proto__ in key segments', () => { + const invalid = { + '__proto__.polluted': 'value', + 'valid.key': 'value2', // Need at least one valid flattened key to trigger validation + } as FlattenedLocalizationResource; + + expect(() => flatLocalization(invalid)).toThrow( + /Invalid flattened key format.*Keys cannot contain "__proto__" or "constructor" segments/, + ); + }); + + it('throws error for constructor in key segments', () => { + const invalid = { + 'constructor.prototype.polluted': 'value', + 'valid.key': 'value2', // Need at least one valid flattened key to trigger validation + } as FlattenedLocalizationResource; + + // Constructor.prototype is a read-only property, so it throws a different error + expect(() => flatLocalization(invalid)).toThrow(); + }); + + it('throws error for null input', () => { + expect(() => flatLocalization(null as unknown as FlattenedLocalizationResource)).toThrow( + /Localization object must be a non-null object/, + ); + }); + + it('throws error for array input', () => { + expect(() => flatLocalization([] as unknown as FlattenedLocalizationResource)).toThrow( + /Localization object must be a non-null object/, + ); + }); + }); + + describe('type tests', () => { + it('accepts valid FlattenedLocalizationResource', () => { + const flattened = { + 'a.b': 'value', + } as FlattenedLocalizationResource; + + const result = flatLocalization(flattened); + + // Type check: result should be LocalizationResource + const _typeCheck: LocalizationResource = result; + expect(_typeCheck).toBeDefined(); + }); + + it('return type is LocalizationResource', () => { + const flattened: FlattenedLocalizationResource = { + locale: 'en-US', + }; + + const result = flatLocalization(flattened); + + // Type check: result should be LocalizationResource + const _typeCheck: LocalizationResource = result; + expect(_typeCheck).toBeDefined(); + }); + + it('accepts FlattenedLocalizationResource type', () => { + const flattened = { + 'a.b': 'value', + } as FlattenedLocalizationResource; + + const result = flatLocalization(flattened); + expect(result).toBeDefined(); + }); + }); + + describe('edge cases', () => { + it('handles keys with underscores', () => { + const flattened = { + formFieldLabel__emailAddress: 'Email', + 'unstable__errors.passwordComplexity.maximumLength': 'Too long', + } as FlattenedLocalizationResource; + + const result = flatLocalization(flattened); + + expect(result).toEqual({ + formFieldLabel__emailAddress: 'Email', + unstable__errors: { + passwordComplexity: { + maximumLength: 'Too long', + }, + }, + }); + }); + + it('handles empty string values', () => { + const flattened = { + 'a.b': '', + 'a.c': 'value', + } as FlattenedLocalizationResource; + + const result = flatLocalization(flattened); + + expect(result).toEqual({ + a: { + b: '', + c: 'value', + }, + }); + }); + + it('skips __proto__ keys silently', () => { + const flattened = { + 'a.b': 'value', + __proto__: 'should be skipped', + } as FlattenedLocalizationResource; + + const result = flatLocalization(flattened); + + expect(result).toEqual({ + a: { + b: 'value', + }, + }); + }); + + it('skips constructor keys silently', () => { + const flattened = { + 'a.b': 'value', + // Note: constructor is a special property that can't be easily set in an object literal + // This test verifies that unflattenObject handles it correctly + } as FlattenedLocalizationResource; + + const result = flatLocalization(flattened); + + expect(result).toEqual({ + a: { + b: 'value', + }, + }); + }); + }); +}); diff --git a/packages/localizations/src/utils/__tests__/flatLocalization.type.spec.ts b/packages/localizations/src/utils/__tests__/flatLocalization.type.spec.ts new file mode 100644 index 00000000000..a120da61733 --- /dev/null +++ b/packages/localizations/src/utils/__tests__/flatLocalization.type.spec.ts @@ -0,0 +1,99 @@ +import type { FlattenedLocalizationResource, LocalizationResource } from '@clerk/shared/types'; +import { describe, expectTypeOf, it } from 'vitest'; + +import { flatLocalization } from '../flatLocalization'; + +type FlatLocalizationParameters = Parameters; +type FlatLocalizationReturn = ReturnType; + +describe('flatLocalization type tests', () => { + describe('parameters', () => { + it('accepts FlattenedLocalizationResource', () => { + type ValidInput = FlattenedLocalizationResource; + expectTypeOf().toExtend(); + }); + + it('accepts valid flattened keys', () => { + type ValidInput = { + locale: string; + formFieldLabel__emailAddress: string; + 'unstable__errors.passwordComplexity.maximumLength': string; + }; + expectTypeOf().toExtend(); + expectTypeOf().toExtend(); + }); + + it('rejects non-string values', () => { + // @ts-expect-error - number is not assignable to string + const _test: FlattenedLocalizationResource = { 'a.b': 123 }; + }); + + it('rejects nested objects', () => { + flatLocalization({ + // @ts-expect-error - nested objects are not allowed in FlattenedLocalizationResource + nested: { + key: 'value', + }, + }); + }); + }); + + describe('return type', () => { + it('returns LocalizationResource', () => { + expectTypeOf().toEqualTypeOf(); + }); + + it('return type can be assigned to localization prop', () => { + type LocalizationProp = LocalizationResource; + expectTypeOf().toExtend(); + }); + + it('preserves nested structure from flattened keys', () => { + type Result = ReturnType; + expectTypeOf().toExtend(); + }); + }); + + describe('type inference', () => { + it('infers correct types from function call', () => { + const flattened = { + locale: 'en-US', + formFieldLabel__emailAddress: 'Email address', + } as FlattenedLocalizationResource; + + const result = flatLocalization(flattened); + expectTypeOf(result).toEqualTypeOf(); + }); + + it('handles empty object', () => { + const empty = {} as FlattenedLocalizationResource; + const result = flatLocalization(empty); + expectTypeOf(result).toEqualTypeOf(); + }); + }); + + describe('edge cases', () => { + it('handles keys with underscores', () => { + type InputWithUnderscores = { + formFieldLabel__emailAddress: string; + 'unstable__errors.passwordComplexity.maximumLength': string; + }; + expectTypeOf().toExtend(); + }); + + it('handles top-level keys', () => { + type TopLevelKeys = { + locale: string; + maintenanceMode: string; + }; + expectTypeOf().toExtend(); + }); + + it('handles deeply nested paths', () => { + type DeepNesting = { + 'a.b.c.d.e.f.g': string; + }; + expectTypeOf().toExtend(); + }); + }); +}); diff --git a/packages/localizations/src/utils/flatLocalization.ts b/packages/localizations/src/utils/flatLocalization.ts new file mode 100644 index 00000000000..16a77ea55c9 --- /dev/null +++ b/packages/localizations/src/utils/flatLocalization.ts @@ -0,0 +1,44 @@ +import type { FlattenedLocalizationResource, LocalizationResource } from '@clerk/shared/types'; +import { unflattenObject, validateLocalizationFormat } from '@clerk/shared/utils'; + +/** + * Converts a flattened localization object (with dot-notation keys) to a nested LocalizationResource. + * + * This utility function validates the input format and converts flattened keys like + * `"unstable__errors.passwordComplexity.maximumLength"` into nested objects that can be used + * with the `localization` prop in Clerk components. + * + * @example + * ```typescript + * const flattened = { + * "locale": "en-US", + * "formFieldLabel__emailAddress": "Email address", + * "unstable__errors.passwordComplexity.maximumLength": "Password is too long" + * }; + * + * const nested = flatLocalization(flattened); + * // Result: + * // { + * // locale: "en-US", + * // formFieldLabel__emailAddress: "Email address", + * // unstable__errors: { + * // passwordComplexity: { + * // maximumLength: "Password is too long" + * // } + * // } + * // } + * ``` + * + * @param input - A flattened localization object with dot-notation keys + * @returns A nested LocalizationResource that can be used with the `localization` prop + * @throws {Error} If the input format is invalid or mixes flattened and nested formats + */ +export function flatLocalization(input: FlattenedLocalizationResource): LocalizationResource { + validateLocalizationFormat(input as Record); + + if (!input || Object.keys(input).length === 0) { + return {} as LocalizationResource; + } + + return unflattenObject(input as Record); +} diff --git a/packages/localizations/src/utils/index.ts b/packages/localizations/src/utils/index.ts new file mode 100644 index 00000000000..0a487b48623 --- /dev/null +++ b/packages/localizations/src/utils/index.ts @@ -0,0 +1 @@ +export { flatLocalization } from './flatLocalization'; diff --git a/packages/localizations/tsconfig.test.json b/packages/localizations/tsconfig.test.json new file mode 100644 index 00000000000..155b9a1ef8b --- /dev/null +++ b/packages/localizations/tsconfig.test.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "sourceMap": true, + "noImplicitAny": false + }, + "include": ["src/**/*"] +} diff --git a/packages/localizations/tsup.config.ts b/packages/localizations/tsup.config.ts index 7e69e5b33c6..a83c3f3daa3 100644 --- a/packages/localizations/tsup.config.ts +++ b/packages/localizations/tsup.config.ts @@ -2,7 +2,7 @@ import { defineConfig } from 'tsup'; export default defineConfig(_overrideOptions => { return { - entry: ['src/*.ts'], + entry: ['src/*.ts', 'src/utils/*.ts'], format: ['cjs', 'esm'], bundle: true, clean: true, diff --git a/packages/localizations/vitest.config.mts b/packages/localizations/vitest.config.mts new file mode 100644 index 00000000000..a5da3abb1d2 --- /dev/null +++ b/packages/localizations/vitest.config.mts @@ -0,0 +1,20 @@ +import * as path from 'node:path'; + +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, + }, + test: { + watch: false, + typecheck: { + enabled: true, + tsconfig: './tsconfig.test.json', + include: ['**/*.type.{test,spec}.{ts,tsx}'], + }, + include: ['**/*.{test,spec}.{ts,tsx}'], + }, +}); diff --git a/packages/shared/src/types/localization.ts b/packages/shared/src/types/localization.ts index 7c3b5ae0fc0..8fefd7bedce 100644 --- a/packages/shared/src/types/localization.ts +++ b/packages/shared/src/types/localization.ts @@ -1,5 +1,5 @@ import type { FieldId } from './elementIds'; -import type { CamelToSnake, DeepPartial } from './utils'; +import type { CamelToSnake, DeepPartial, RecordToPath } from './utils'; /** * @internal @@ -65,6 +65,26 @@ type DeepLocalizationWithoutObjects = { export interface LocalizationResource extends DeepPartial> {} +/** + * A flattened representation of LocalizationResource where nested keys are represented + * using dot notation (e.g., "formFieldLabel__emailAddress" or "unstable__errors.passwordComplexity.maximumLength"). + * + * @example + * ```typescript + * const flattened: FlattenedLocalizationResource = { + * "locale": "en-US", + * "formFieldLabel__emailAddress": "Email address", + * "unstable__errors.passwordComplexity.maximumLength": "Password is too long" + * }; + * ``` + * + * This type provides type safety and autocomplete by restricting keys to valid localization paths. + * Note: This generates a large union type which may impact TypeScript performance in some cases. + */ +export type FlattenedLocalizationResource = { + [K in RecordToPath<__internal_LocalizationResource>]?: string; +}; + export type __internal_LocalizationResource = { locale: string; maintenanceMode: LocalizationValue; diff --git a/packages/shared/src/utils/index.ts b/packages/shared/src/utils/index.ts index 4c1e6ec6bef..2b6c01e4ba1 100644 --- a/packages/shared/src/utils/index.ts +++ b/packages/shared/src/utils/index.ts @@ -7,3 +7,5 @@ export * from './runtimeEnvironment'; export { handleValueOrFn } from './handleValueOrFn'; export { runIfFunctionOrReturn } from './runIfFunctionOrReturn'; export { fastDeepMergeAndReplace, fastDeepMergeAndKeep } from './fastDeepMerge'; +export { unflattenObject } from './unflattenObject'; +export { validateLocalizationFormat } from './validateLocalizationFormat'; diff --git a/packages/shared/src/utils/unflattenObject.ts b/packages/shared/src/utils/unflattenObject.ts new file mode 100644 index 00000000000..a4da3ec8d31 --- /dev/null +++ b/packages/shared/src/utils/unflattenObject.ts @@ -0,0 +1,56 @@ +const DISALLOWED_KEYS = new Set(['__proto__', 'prototype', 'constructor']); + +function isDisallowedKey(key: string): boolean { + return DISALLOWED_KEYS.has(key); +} + +/** + * Converts a flattened object with dot-notation keys into a nested object structure. + * + * @example + * ```typescript + * const flattened = { + * "a.b.c": "value1", + * "a.b.d": "value2", + * "e": "value3" + * }; + * const nested = unflattenObject(flattened); + * // Result: { a: { b: { c: "value1", d: "value2" } }, e: "value3" } + * ``` + * + * @param obj - The flattened object with dot-notation keys + * @returns A nested object structure + */ +export function unflattenObject>(obj: Record): T { + const result = {} as Record; + + for (const [key, value] of Object.entries(obj)) { + if (isDisallowedKey(key)) { + continue; + } + + const segments = key.split('.'); + let current: Record = result; + + for (let i = 0; i < segments.length - 1; i++) { + const segment = segments[i]; + + if (!segment || isDisallowedKey(segment)) { + continue; + } + + if (!(segment in current) || typeof current[segment] !== 'object' || current[segment] === null) { + current[segment] = {}; + } + + current = current[segment] as Record; + } + + const lastKey = segments[segments.length - 1]; + if (lastKey && !isDisallowedKey(lastKey)) { + current[lastKey] = value; + } + } + + return result as T; +} diff --git a/packages/shared/src/utils/validateLocalizationFormat.ts b/packages/shared/src/utils/validateLocalizationFormat.ts new file mode 100644 index 00000000000..6f042fc88e9 --- /dev/null +++ b/packages/shared/src/utils/validateLocalizationFormat.ts @@ -0,0 +1,65 @@ +/** + * Validates that a localization object uses a consistent format (either all flattened or all nested). + * Throws an error if the format is mixed or invalid. + * + * @param obj - The localization object to validate + * @throws {Error} If the object has mixed flattened and nested keys, or invalid key formats + */ +export function validateLocalizationFormat(obj: Record): void { + if (!obj || typeof obj !== 'object' || Array.isArray(obj)) { + throw new Error('Localization object must be a non-null object'); + } + + const keys = Object.keys(obj); + if (keys.length === 0) { + return; // Empty object is valid + } + + // Check if any keys contain dots (flattened format) + const hasFlattenedKeys = keys.some(key => key.includes('.')); + + // Check if any values are objects (nested format) + const hasNestedValues = keys.some(key => { + const value = obj[key]; + return value !== null && typeof value === 'object' && !Array.isArray(value); + }); + + // If we have both flattened keys and nested values, that's a conflict + if (hasFlattenedKeys && hasNestedValues) { + throw new Error( + 'Localization object cannot mix flattened (dot-notation) keys with nested object values. Use either all flattened keys or all nested objects.', + ); + } + + // Validate flattened key format + if (hasFlattenedKeys) { + for (const key of keys) { + // Only validate keys that contain dots (flattened format) + if (key.includes('.')) { + // Check for empty segments (consecutive dots, leading/trailing dots) + if (key.startsWith('.') || key.endsWith('.') || key.includes('..')) { + throw new Error( + `Invalid flattened key format: "${key}". Keys cannot start or end with dots, or contain consecutive dots.`, + ); + } + + // Check for prototype pollution attempts + const segments = key.split('.'); + if (segments.includes('__proto__') || segments.includes('constructor')) { + throw new Error( + `Invalid flattened key format: "${key}". Keys cannot contain "__proto__" or "constructor" segments.`, + ); + } + } + } + } + + // Validate nested format recursively + if (hasNestedValues && !hasFlattenedKeys) { + for (const value of Object.values(obj)) { + if (value !== null && typeof value === 'object' && !Array.isArray(value)) { + validateLocalizationFormat(value as Record); + } + } + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 10b45161c90..d43903bb061 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -154,7 +154,7 @@ importers: version: 4.7.0(vite@7.2.2(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1)) '@vitest/coverage-v8': specifier: 3.2.4 - version: 3.2.4(vitest@3.2.4(@edge-runtime/vm@5.0.0)(@types/debug@4.1.12)(@types/node@22.19.0)(jiti@2.6.1)(jsdom@27.0.0(bufferutil@4.0.9)(postcss@8.5.6)(utf-8-validate@5.0.10))(lightningcss@1.30.2)(msw@2.11.6(@types/node@22.19.0)(typescript@5.8.3))(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1)) + version: 3.2.4(vitest@3.2.4) chalk: specifier: 4.1.2 version: 4.1.2 @@ -331,7 +331,7 @@ importers: version: 6.1.6(typanion@3.14.0) vitest: specifier: 3.2.4 - version: 3.2.4(@edge-runtime/vm@5.0.0)(@types/debug@4.1.12)(@types/node@22.19.0)(jiti@2.6.1)(jsdom@27.0.0(bufferutil@4.0.9)(postcss@8.5.6)(utf-8-validate@5.0.10))(lightningcss@1.30.2)(msw@2.11.6(@types/node@22.19.0)(typescript@5.8.3))(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1) + version: 3.2.4(@edge-runtime/vm@5.0.0)(@types/debug@4.1.12)(@types/node@22.19.0)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.0.0(bufferutil@4.0.9)(postcss@8.5.6)(utf-8-validate@5.0.10))(lightningcss@1.30.2)(msw@2.11.6(@types/node@22.19.0)(typescript@5.8.3))(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1) yalc: specifier: 1.0.0-pre.53 version: 1.0.0-pre.53 @@ -418,7 +418,7 @@ importers: version: 9.0.2 vitest-environment-miniflare: specifier: 2.14.4 - version: 2.14.4(bufferutil@4.0.9)(utf-8-validate@5.0.10)(vitest@3.2.4(@edge-runtime/vm@5.0.0)(@types/debug@4.1.12)(@types/node@22.19.0)(jiti@2.6.1)(jsdom@27.0.0(bufferutil@4.0.9)(postcss@8.5.6)(utf-8-validate@5.0.10))(lightningcss@1.30.2)(msw@2.11.6(@types/node@22.19.0)(typescript@5.8.3))(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1)) + version: 2.14.4(bufferutil@4.0.9)(utf-8-validate@5.0.10)(vitest@3.2.4) packages/chrome-extension: dependencies: @@ -698,6 +698,13 @@ importers: '@clerk/shared': specifier: workspace:^ version: link:../shared + devDependencies: + '@vitest/ui': + specifier: 3.2.4 + version: 3.2.4(vitest@3.2.4) + vitest: + specifier: 3.2.4 + version: 3.2.4(@edge-runtime/vm@5.0.0)(@types/debug@4.1.12)(@types/node@22.19.0)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.0.0(bufferutil@4.0.9)(postcss@8.5.6)(utf-8-validate@5.0.10))(lightningcss@1.30.2)(msw@2.11.6(@types/node@22.19.0)(typescript@5.8.3))(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1) packages/msw: dependencies: @@ -5715,6 +5722,11 @@ packages: '@vitest/spy@3.2.4': resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==} + '@vitest/ui@3.2.4': + resolution: {integrity: sha512-hGISOaP18plkzbWEcP/QvtRW1xDXF2+96HbEX6byqQhAUbiS5oH6/9JwW+QsQCIYON2bI6QZBF+2PvOmrRZ9wA==} + peerDependencies: + vitest: 3.2.4 + '@vitest/utils@3.2.4': resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} @@ -21441,7 +21453,7 @@ snapshots: vite: 7.2.2(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1) vue: 3.5.24(typescript@5.8.3) - '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@edge-runtime/vm@5.0.0)(@types/debug@4.1.12)(@types/node@22.19.0)(jiti@2.6.1)(jsdom@27.0.0(bufferutil@4.0.9)(postcss@8.5.6)(utf-8-validate@5.0.10))(lightningcss@1.30.2)(msw@2.11.6(@types/node@22.19.0)(typescript@5.8.3))(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1))': + '@vitest/coverage-v8@3.2.4(vitest@3.2.4)': dependencies: '@ampproject/remapping': 2.3.0 '@bcoe/v8-coverage': 1.0.2 @@ -21456,7 +21468,7 @@ snapshots: std-env: 3.10.0 test-exclude: 7.0.1 tinyrainbow: 2.0.0 - vitest: 3.2.4(@edge-runtime/vm@5.0.0)(@types/debug@4.1.12)(@types/node@22.19.0)(jiti@2.6.1)(jsdom@27.0.0(bufferutil@4.0.9)(postcss@8.5.6)(utf-8-validate@5.0.10))(lightningcss@1.30.2)(msw@2.11.6(@types/node@22.19.0)(typescript@5.8.3))(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1) + vitest: 3.2.4(@edge-runtime/vm@5.0.0)(@types/debug@4.1.12)(@types/node@22.19.0)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.0.0(bufferutil@4.0.9)(postcss@8.5.6)(utf-8-validate@5.0.10))(lightningcss@1.30.2)(msw@2.11.6(@types/node@22.19.0)(typescript@5.8.3))(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1) transitivePeerDependencies: - supports-color @@ -21497,6 +21509,17 @@ snapshots: dependencies: tinyspy: 4.0.4 + '@vitest/ui@3.2.4(vitest@3.2.4)': + dependencies: + '@vitest/utils': 3.2.4 + fflate: 0.8.2 + flatted: 3.3.3 + pathe: 2.0.3 + sirv: 3.0.2 + tinyglobby: 0.2.15 + tinyrainbow: 2.0.0 + vitest: 3.2.4(@edge-runtime/vm@5.0.0)(@types/debug@4.1.12)(@types/node@22.19.0)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.0.0(bufferutil@4.0.9)(postcss@8.5.6)(utf-8-validate@5.0.10))(lightningcss@1.30.2)(msw@2.11.6(@types/node@22.19.0)(typescript@5.8.3))(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1) + '@vitest/utils@3.2.4': dependencies: '@vitest/pretty-format': 3.2.4 @@ -32561,19 +32584,19 @@ snapshots: optionalDependencies: vite: 7.2.2(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1) - vitest-environment-miniflare@2.14.4(bufferutil@4.0.9)(utf-8-validate@5.0.10)(vitest@3.2.4(@edge-runtime/vm@5.0.0)(@types/debug@4.1.12)(@types/node@22.19.0)(jiti@2.6.1)(jsdom@27.0.0(bufferutil@4.0.9)(postcss@8.5.6)(utf-8-validate@5.0.10))(lightningcss@1.30.2)(msw@2.11.6(@types/node@22.19.0)(typescript@5.8.3))(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1)): + vitest-environment-miniflare@2.14.4(bufferutil@4.0.9)(utf-8-validate@5.0.10)(vitest@3.2.4): dependencies: '@miniflare/queues': 2.14.4 '@miniflare/runner-vm': 2.14.4 '@miniflare/shared': 2.14.4 '@miniflare/shared-test-environment': 2.14.4(bufferutil@4.0.9)(utf-8-validate@5.0.10) undici: 5.28.4 - vitest: 3.2.4(@edge-runtime/vm@5.0.0)(@types/debug@4.1.12)(@types/node@22.19.0)(jiti@2.6.1)(jsdom@27.0.0(bufferutil@4.0.9)(postcss@8.5.6)(utf-8-validate@5.0.10))(lightningcss@1.30.2)(msw@2.11.6(@types/node@22.19.0)(typescript@5.8.3))(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1) + vitest: 3.2.4(@edge-runtime/vm@5.0.0)(@types/debug@4.1.12)(@types/node@22.19.0)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.0.0(bufferutil@4.0.9)(postcss@8.5.6)(utf-8-validate@5.0.10))(lightningcss@1.30.2)(msw@2.11.6(@types/node@22.19.0)(typescript@5.8.3))(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1) transitivePeerDependencies: - bufferutil - utf-8-validate - vitest@3.2.4(@edge-runtime/vm@5.0.0)(@types/debug@4.1.12)(@types/node@22.19.0)(jiti@2.6.1)(jsdom@27.0.0(bufferutil@4.0.9)(postcss@8.5.6)(utf-8-validate@5.0.10))(lightningcss@1.30.2)(msw@2.11.6(@types/node@22.19.0)(typescript@5.8.3))(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1): + vitest@3.2.4(@edge-runtime/vm@5.0.0)(@types/debug@4.1.12)(@types/node@22.19.0)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.0.0(bufferutil@4.0.9)(postcss@8.5.6)(utf-8-validate@5.0.10))(lightningcss@1.30.2)(msw@2.11.6(@types/node@22.19.0)(typescript@5.8.3))(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 @@ -32602,6 +32625,7 @@ snapshots: '@edge-runtime/vm': 5.0.0 '@types/debug': 4.1.12 '@types/node': 22.19.0 + '@vitest/ui': 3.2.4(vitest@3.2.4) jsdom: 27.0.0(bufferutil@4.0.9)(postcss@8.5.6)(utf-8-validate@5.0.10) transitivePeerDependencies: - jiti