diff --git a/.github/workflows/android-build.yml b/.github/workflows/android-build.yml index ba7a9fc7..893fbac4 100644 --- a/.github/workflows/android-build.yml +++ b/.github/workflows/android-build.yml @@ -6,7 +6,7 @@ permissions: on: push: branches: - - main + - master paths: - '.github/workflows/android-build.yml' - 'example/android/**' diff --git a/.github/workflows/ios-build.yml b/.github/workflows/ios-build.yml index 2f558207..525ed959 100644 --- a/.github/workflows/ios-build.yml +++ b/.github/workflows/ios-build.yml @@ -6,7 +6,7 @@ permissions: on: push: branches: - - main + - master paths: - '.github/workflows/ios-build.yml' - 'example/ios/**' diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8c681cfd..3625036f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -6,7 +6,7 @@ permissions: on: push: branches: - - main + - master paths: - '.github/workflows/test.yml' - 'src/**' diff --git a/package.json b/package.json index 111c1cb7..f78a9302 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,6 @@ "sideEffects": false, "exports": { ".": { - "react-native": "./src/index.ts", "import": { "types": "./lib/typescript/module/src/index.d.ts", "default": "./lib/module/index.js" @@ -22,7 +21,6 @@ "default": "./lib/module/index.js" }, "./hooks": { - "react-native": "./src/hooks/index.ts", "import": { "types": "./lib/typescript/module/src/hooks/index.d.ts", "default": "./lib/module/hooks/index.js" @@ -34,7 +32,6 @@ "default": "./lib/module/hooks/index.js" }, "./errors": { - "react-native": "./src/errors.ts", "import": { "types": "./lib/typescript/module/src/errors.d.ts", "default": "./lib/module/errors.js" diff --git a/src/__tests__/errors.test.ts b/src/__tests__/errors.test.ts index 5853beb5..e7c569a0 100644 --- a/src/__tests__/errors.test.ts +++ b/src/__tests__/errors.test.ts @@ -61,6 +61,51 @@ describe('errors', () => { const err = new KeyInvalidatedError('invalid', { alias: 'rnsi.svc.v1' }) expect(err.alias).toBe('rnsi.svc.v1') }) + + describe('cause chaining', () => { + it('retains the provided cause on SensitiveInfoError', () => { + const cause = new Error('underlying') + const err = new SensitiveInfoError(ErrorCode.NotFound, 'wrapped', { + cause, + }) + expect(err.cause).toBe(cause) + }) + + it('keeps cause non-enumerable to match native ES2022 Error semantics', () => { + const cause = new Error('underlying') + const err = new SensitiveInfoError(ErrorCode.NotFound, 'wrapped', { + cause, + }) + const descriptor = Object.getOwnPropertyDescriptor(err, 'cause') + expect(descriptor).toBeDefined() + expect(descriptor?.enumerable).toBe(false) + expect(Object.keys(err)).not.toContain('cause') + expect(JSON.parse(JSON.stringify(err))).not.toHaveProperty('cause') + }) + + it('does not define cause when not provided', () => { + const err = new SensitiveInfoError(ErrorCode.NotFound, 'wrapped') + expect(Object.hasOwn(err, 'cause')).toBe(false) + }) + + it('defines a non-enumerable own cause when explicitly passed as undefined', () => { + const err = new SensitiveInfoError(ErrorCode.NotFound, 'wrapped', { + cause: undefined, + }) + const descriptor = Object.getOwnPropertyDescriptor(err, 'cause') + expect(Object.hasOwn(err, 'cause')).toBe(true) + expect(err.cause).toBeUndefined() + expect(descriptor).toBeDefined() + expect(descriptor?.enumerable).toBe(false) + expect(Object.keys(err)).not.toContain('cause') + }) + + it('propagates cause through subclasses (NotFoundError)', () => { + const cause = new Error('native miss') + const err = new NotFoundError('missing', { cause }) + expect(err.cause).toBe(cause) + }) + }) }) describe('toSensitiveInfoError', () => { diff --git a/src/__tests__/hooks.types.test.ts b/src/__tests__/hooks.types.test.ts index 3a9ff546..4df9e458 100644 --- a/src/__tests__/hooks.types.test.ts +++ b/src/__tests__/hooks.types.test.ts @@ -19,6 +19,32 @@ describe('hooks/types', () => { expect(error.hint).toBe('Check the key.') }) + it('keeps cause non-enumerable to match native ES2022 Error semantics', () => { + const cause = new Error('native failure') + const error = new HookError('Wrapper message', { cause }) + + const descriptor = Object.getOwnPropertyDescriptor(error, 'cause') + expect(descriptor).toBeDefined() + expect(descriptor?.enumerable).toBe(false) + expect(Object.keys(error)).not.toContain('cause') + expect(JSON.parse(JSON.stringify(error))).not.toHaveProperty('cause') + }) + + it('omits cause when not provided', () => { + const error = new HookError('Wrapper message') + expect(Object.hasOwn(error, 'cause')).toBe(false) + }) + + it('defines a non-enumerable own cause when explicitly passed as undefined', () => { + const error = new HookError('Wrapper message', { cause: undefined }) + const descriptor = Object.getOwnPropertyDescriptor(error, 'cause') + expect(Object.hasOwn(error, 'cause')).toBe(true) + expect(error.cause).toBeUndefined() + expect(descriptor).toBeDefined() + expect(descriptor?.enumerable).toBe(false) + expect(Object.keys(error)).not.toContain('cause') + }) + it('creates the initial async state', () => { const state = createInitialAsyncState() expect(state).toEqual({ diff --git a/src/errors.ts b/src/errors.ts index 4e0ff3d8..f9423912 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -62,6 +62,16 @@ export class SensitiveInfoError extends Error { /** Stable discriminant identifying the failure mode. */ readonly code: ErrorCodeValue + /** + * The underlying cause forwarded to {@link Error.cause}. + * + * Declared as a type-only member so the property type-checks under `tsconfig` + * `lib` targets that predate ES2022 (where {@link Error} did not yet expose + * `cause`). At runtime the value is installed by the constructor via + * {@link Object.defineProperty} so it stays non-enumerable. + */ + declare readonly cause?: unknown + /** * @param code - Stable {@link ErrorCodeValue} for the failure. * @param message - Human-readable description. @@ -75,11 +85,17 @@ export class SensitiveInfoError extends Error { super(message) this.name = 'SensitiveInfoError' this.code = code - // Assign `cause` directly instead of passing it to `super()` so this + // Define `cause` manually instead of passing it to `super()` so this // compiles cleanly under TS configs whose `lib` predates ES2022 (where - // the second `Error` constructor argument was introduced). + // the second `Error` constructor argument was introduced), while keeping + // the property non-enumerable to match the native ES2022 `Error` constructor. if (options && 'cause' in options) { - ;(this as { cause?: unknown }).cause = options.cause + Object.defineProperty(this, 'cause', { + value: options.cause, + writable: true, + configurable: true, + enumerable: false, + }) } } } diff --git a/src/hooks/types.ts b/src/hooks/types.ts index e927086f..bfe76440 100644 --- a/src/hooks/types.ts +++ b/src/hooks/types.ts @@ -30,23 +30,40 @@ export class HookError extends Error { /** UI-facing remediation hint (e.g. `'Ask the user to retry biometrics.'`). */ readonly hint?: string | undefined + /** + * The underlying cause forwarded to {@link Error.cause}. + * + * Declared as a type-only member so the property type-checks under `tsconfig` + * `lib` targets that predate ES2022 (where {@link Error} did not yet expose + * `cause`). At runtime the value is installed by the constructor via + * {@link Object.defineProperty} so it stays non-enumerable. + */ + declare readonly cause?: unknown + /** * @param message - Human-readable description of the failure. * @param options - Additional metadata; see {@link HookErrorOptions}. */ - constructor( - message: string, - { cause, operation, hint }: HookErrorOptions = {} - ) { + constructor(message: string, options: HookErrorOptions = {}) { super(message) this.name = 'HookError' - this.operation = operation - this.hint = hint - // Assign `cause` directly instead of passing it to `super()` so this + this.operation = options.operation + this.hint = options.hint + // Define `cause` manually instead of passing it to `super()` so this // compiles cleanly under TS configs whose `lib` predates ES2022 (where - // the second `Error` constructor argument was introduced). - if (cause !== undefined) { - ;(this as { cause?: unknown }).cause = cause + // the second `Error` constructor argument was introduced), while keeping + // the property non-enumerable to match the native ES2022 `Error` constructor. + // + // We deliberately use `'cause' in options` (rather than a value check) + // so that `new HookError(msg, { cause: undefined })` still installs the + // property — matching native `new Error(msg, { cause: undefined })`. + if ('cause' in options) { + Object.defineProperty(this, 'cause', { + value: options.cause, + writable: true, + configurable: true, + enumerable: false, + }) } } }