From 51d3a3418f40ca54bbe209cc0d1f0b7e9579224c Mon Sep 17 00:00:00 2001 From: Hiroki Osame Date: Sun, 30 Aug 2026 08:13:42 +0900 Subject: [PATCH 1/3] feat: support disposable spies --- src/internal.ts | 1 + src/spyOn.ts | 3 +++ test/index.test.ts | 33 +++++++++++++++++++++++++++++++++ 3 files changed, 37 insertions(+) diff --git a/src/internal.ts b/src/internal.ts index 511e85a..d3e4f18 100644 --- a/src/internal.ts +++ b/src/internal.ts @@ -68,6 +68,7 @@ export interface Spy } export interface SpyImpl extends Spy { + [Symbol.dispose](): void getOriginal(): (...args: A) => R willCall(cb: (...args: A) => R): this restore(): void diff --git a/src/spyOn.ts b/src/spyOn.ts index 2b6fa7c..7bc138e 100644 --- a/src/spyOn.ts +++ b/src/spyOn.ts @@ -261,6 +261,9 @@ export function spyOn( ;(['restore', 'getOriginal', 'willCall'] as const).forEach((method) => { defineValue(spy, method, spy[SYMBOL_STATE][method]) }) + if (Symbol.dispose) { + defineValue(spy, Symbol.dispose, spy[SYMBOL_STATE].restore) + } return spy as any as SpyImpl } diff --git a/test/index.test.ts b/test/index.test.ts index c39080c..b95fe5c 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -82,6 +82,39 @@ test('can spy on method', () => { expect(method.results).toEqual([ok('a!'), ok('b!'), ok('C!'), error(err)]) }) +test('restores mock on leaving a using scope', () => { + const obj = { + method: () => 'original', + } + const original = obj.method + + { + using method = spyOn(obj, 'method').willCall(() => 'mocked') + + expect(obj.method()).toBe('mocked') + expect(method.called).toBe(true) + } + + expect(obj.method).toBe(original) +}) + +test('restores mock when a using scope throws', () => { + const obj = { + method: () => 'original', + } + const original = obj.method + + expect(() => { + using method = spyOn(obj, 'method').willCall(() => 'mocked') + + expect(obj.method()).toBe('mocked') + expect(method.called).toBe(true) + throw new Error('test') + }).toThrowError('test') + + expect(obj.method).toBe(original) +}) + test('resets all spies', () => { let one = { method(arg: string) { From 4710013a7474a0451615f6996286bac3d80702b5 Mon Sep 17 00:00:00 2001 From: Hiroki Osame Date: Wed, 2 Sep 2026 17:36:47 +0900 Subject: [PATCH 2/3] fix: declare disposable symbol --- src/internal.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/internal.ts b/src/internal.ts index d3e4f18..1f91fc8 100644 --- a/src/internal.ts +++ b/src/internal.ts @@ -1,6 +1,13 @@ import { assert, define, defineValue, isPromise, isType } from './utils.js' import { SYMBOL_STATE } from './constants.js' +// Support consumers whose standard library predates explicit resource management. +declare global { + interface SymbolConstructor { + readonly dispose: unique symbol + } +} + interface GetState { (spy: SpyInternalImpl): SpyInternalImplState (spy: SpyInternal): SpyInternalState From 673ea72e1ea6ca3906a03c3e571dcf49f32a4a1b Mon Sep 17 00:00:00 2001 From: Hiroki Osame Date: Wed, 2 Sep 2026 18:02:20 +0900 Subject: [PATCH 3/3] fix: preserve disposable type compatibility --- optional-types.d.ts | 4 ++++ package.json | 3 ++- src/internal.ts | 13 ++++--------- tsup.config.ts | 1 + 4 files changed, 11 insertions(+), 10 deletions(-) create mode 100644 optional-types.d.ts diff --git a/optional-types.d.ts b/optional-types.d.ts new file mode 100644 index 0000000..0e727b1 --- /dev/null +++ b/optional-types.d.ts @@ -0,0 +1,4 @@ +export interface Disposable { + // @ts-ignore -- Symbol.dispose might not be in user types + [Symbol.dispose]: () => void +} diff --git a/package.json b/package.json index 6c2fdb5..1ad1e29 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,8 @@ "module": "./dist/index.js", "types": "./dist/index.d.ts", "files": [ - "dist" + "dist", + "optional-types.d.ts" ], "engines": { "node": ">=14.0.0" diff --git a/src/internal.ts b/src/internal.ts index 1f91fc8..c886661 100644 --- a/src/internal.ts +++ b/src/internal.ts @@ -1,12 +1,6 @@ import { assert, define, defineValue, isPromise, isType } from './utils.js' import { SYMBOL_STATE } from './constants.js' - -// Support consumers whose standard library predates explicit resource management. -declare global { - interface SymbolConstructor { - readonly dispose: unique symbol - } -} +import type { Disposable } from '../optional-types.js' interface GetState { (spy: SpyInternalImpl): SpyInternalImplState @@ -74,8 +68,9 @@ export interface Spy nextResult(result: R): this } -export interface SpyImpl extends Spy { - [Symbol.dispose](): void +export interface SpyImpl + extends Spy, + Disposable { getOriginal(): (...args: A) => R willCall(cb: (...args: A) => R): this restore(): void diff --git a/tsup.config.ts b/tsup.config.ts index eb741a2..48d532b 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -2,6 +2,7 @@ import { defineConfig } from 'tsup' export default defineConfig({ entryPoints: ['src/index.ts'], + external: ['../optional-types.js'], outDir: 'dist', format: ['esm'], tsconfig: './tsconfig.json',