e22m4u 7 месяцев назад
Сommit
a3d2968e73
20 измененных файлов с 1868 добавлено и 0 удалено
  1. 9 0
      .c8rc
  2. 5 0
      .commitlintrc
  3. 13 0
      .editorconfig
  4. 18 0
      .gitignore
  5. 1 0
      .husky/commit-msg
  6. 6 0
      .husky/pre-commit
  7. 4 0
      .mocharc.cjs
  8. 7 0
      .prettierrc
  9. 21 0
      LICENSE
  10. 402 0
      README.md
  11. 16 0
      build-cjs.js
  12. 184 0
      dist/cjs/index.cjs
  13. 28 0
      eslint.config.js
  14. 57 0
      package.json
  15. 185 0
      src/create-spy.d.ts
  16. 317 0
      src/create-spy.js
  17. 584 0
      src/create-spy.spec.js
  18. 1 0
      src/index.d.ts
  19. 1 0
      src/index.js
  20. 9 0
      tsconfig.json

+ 9 - 0
.c8rc

@@ -0,0 +1,9 @@
+{
+  "all": true,
+  "include": [
+    "src/**/*.js"
+  ],
+  "exclude": [
+    "src/**/*.spec.js"
+  ]
+}

+ 5 - 0
.commitlintrc

@@ -0,0 +1,5 @@
+{
+  "extends": [
+    "@commitlint/config-conventional"
+  ]
+}

+ 13 - 0
.editorconfig

@@ -0,0 +1,13 @@
+# EditorConfig is awesome: https://EditorConfig.org
+
+# top-most EditorConfig file
+root = true
+
+# Unix-style newlines with a newline ending every file
+[*]
+end_of_line = lf
+insert_final_newline = true
+charset = utf-8
+indent_style = space
+indent_size = 2
+max_line_length = 80

+ 18 - 0
.gitignore

@@ -0,0 +1,18 @@
+# OS
+.DS_Store
+
+# Editor
+.idea
+.vscode
+
+# Npm
+node_modules
+npm-debug.log
+package-lock.json
+
+# Yarn
+.yarn/
+.yarn*
+
+# c8
+coverage

+ 1 - 0
.husky/commit-msg

@@ -0,0 +1 @@
+npx --no -- commitlint --edit $1

+ 6 - 0
.husky/pre-commit

@@ -0,0 +1,6 @@
+npm run lint:fix
+npm run format
+npm run test
+npm run build:cjs
+
+git add -A

+ 4 - 0
.mocharc.cjs

@@ -0,0 +1,4 @@
+module.exports = {
+  extension: ['js'],
+  spec: 'src/**/*.spec.js',
+}

+ 7 - 0
.prettierrc

@@ -0,0 +1,7 @@
+{
+  "bracketSpacing": false,
+  "singleQuote": true,
+  "printWidth": 80,
+  "trailingComma": "all",
+  "arrowParens": "avoid"
+}

+ 21 - 0
LICENSE

@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2023 e22m4u@yandex.ru
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.

+ 402 - 0
README.md

@@ -0,0 +1,402 @@
+# @e22m4u/js-spy
+
+Утилита слежения за вызовом функций и методов для JavaScript.
+
+## Содержание
+
+- [Установка](#установка)
+- [Использование](#использование)
+  - [Отслеживание вызова функции](#отслеживание-вызова-функции)
+  - [Отслеживание вызова метода](#отслеживание-вызова-метода)
+- [API](#api)
+  - [createSpy(target, [methodNameOrImpl], [customImplForMethod])](#createspytarget-methodnameorimpl-customimplformethod)
+  - [Свойства и методы шпиона](#свойства-и-методы-шпиона)
+    - [spy(...args)](#spyargs)
+    - [spy.called](#spycalled)
+    - [spy.callCount](#spycallcount)
+    - [spy.getCall(n)](#spygetcalln)
+    - [spy.calledWith(...expectedArgs)](#spycalledwithexpectedargs)
+    - [spy.nthCalledWith(n, ...expectedArgs)](#spynthcalledwithn-expectedargs)
+    - [spy.nthCallReturned(n, expectedReturnValue)](#spynthcallreturnedn-expectedreturnvalue)
+    - [spy.nthCallThrew(n, [expectedError])](#spynthcallthrewn-expectederror)
+    - [spy.restore()](#spyrestore)
+- [Тесты](#тесты)
+- [Лицензия](#лицензия)
+
+## Установка
+
+```bash
+npm install @e22m4u/js-spy
+```
+
+Поддержка ESM и CommonJS стандартов.
+
+*ESM*
+```js
+import {createSpy} from '@e22m4u/js-spy';
+```
+
+*CommonJS*
+```js
+const {createSpy} = require('@e22m4u/js-spy');
+```
+
+## Использование
+
+Отслеживание вызова функции.
+
+```js
+import {createSpy} from '@e22m4u/js-spy';
+
+function greet(name) {
+  return `Hello, ${name}!`;
+}
+
+const greetSpy = createSpy(greet);
+
+greetSpy('World');
+greetSpy('JavaScript');
+
+console.log(greetSpy.called);                   // true
+console.log(greetSpy.callCount);                // 2
+console.log(greetSpy.getCall(0).args);          // ['World']
+console.log(greetSpy.getCall(0).returnValue);   // 'Hello, World!'
+console.log(greetSpy.calledWith('JavaScript')); // true
+
+try {
+  greetSpy.getCall(5); // Попытка получить несуществующий вызов
+} catch (e) {
+  // Ожидаемая ошибка, например:
+  // "Invalid call index 5. Spy has 2 call(s)."
+  console.error(e.message);
+}
+```
+
+Отслеживание вызова метода.
+
+```js
+import {createSpy} from '@e22m4u/js-spy';
+
+const calculator = {
+  value: 0,
+  add(a, b) {
+    this.value = a + b;
+    return this.value;
+  },
+};
+
+const addSpy = createSpy(calculator, 'add');
+
+calculator.add(5, 3);
+console.log(addSpy.called);                            // true
+console.log(calculator.value);                         // 8
+console.log(addSpy.getCall(0).thisArg === calculator); // true
+console.log(addSpy.getCall(0).returnValue);            // 8
+
+// восстановление оригинального метода
+addSpy.restore();
+// calculator.add теперь снова оригинальный метод
+```
+
+## API
+
+### createSpy(target, [methodNameOrImpl], [customImplForMethod])
+
+Основная функция для создания шпиона.
+
+**Сигнатуры вызова и аргументы:**
+
+1.  **Отслеживание отдельной функции:**  
+    `createSpy(targetFn, [customImplementation])`
+    - `targetFn`: Функция, которую требуется отслеживать.
+    - `customImplementation` (необязательно): Пользовательская функция,
+      которая будет вызываться вместо `targetFn`. Должна иметь ту же
+      сигнатуру.
+
+2.  **Отслеживание метода объекта:**  
+    `createSpy(targetObject, methodName, [customImplementation])`
+    - `targetObject`: Объект, метод которого будет отслеживаться.
+    - `methodName`: Имя метода в `targetObject`, который требуется
+      отслеживать.
+    - `customImplementation` (необязательно): Пользовательская функция,
+      которая будет вызываться вместо оригинального метода. Должна
+      иметь ту же сигнатуру.
+
+**Возвращает:**
+- Функция-шпион с дополнительными свойствами и методами для инспекции.
+
+### Свойства и методы шпиона
+
+Каждая функция-шпион, возвращаемая `createSpy`, обладает следующими
+свойствами и методами:
+
+#### spy(...args)
+
+Сам шпион является функцией. При вызове он выполняет либо оригинальную
+функцию/метод (или пользовательскую реализацию, если предоставлена),
+записывает информацию о вызове и возвращает результат (или пробрасывает
+ошибку).
+
+```javascript
+const fn = (x) => x * 2;
+const spy = createSpy(fn);
+
+const result = spy(5);      // result будет 10
+console.log(spy.callCount); // 1
+```
+
+#### spy.called
+
+- **Тип:** `boolean` (только для чтения)
+- **Описание:** Указывает, был ли шпион вызван хотя бы один раз.
+
+```javascript
+const spy = createSpy(() => {});
+console.log(spy.called); // false
+spy();
+console.log(spy.called); // true
+```
+
+#### spy.callCount
+
+- **Тип:** `number` (только для чтения)
+- **Описание:** Количество раз, которое шпион был вызван.
+
+```javascript
+const spy = createSpy(() => {});
+console.log(spy.callCount); // 0
+spy();
+spy();
+console.log(spy.callCount); // 2
+```
+
+#### spy.getCall(n)
+
+Аргументы:
+- `n`: Число, индекс вызова (начиная с 0).
+
+Возвращает: Объект `CallInfo` со свойствами:
+
+- `args`: Массив аргументов, с которыми был совершен вызов.
+- `thisArg`: Контекст `this` вызова.
+- `returnValue`: Значение, возвращенное функцией (или `undefined`,
+  если функция бросила ошибку).
+- `error`: Ошибка, выброшенная функцией (или `undefined`, если ошибки
+  не было).
+
+Выбрасывает:
+
+- `RangeError`: Если `n` не является допустимым индексом вызова.
+
+Пример:
+
+```javascript
+const spy = createSpy((a, b) => a + b);
+spy.call({ id: 1 }, 10, 20); // 0-й вызов
+
+const firstCall = spy.getCall(0);
+console.log(firstCall.args); // [10, 20]
+
+try {
+  spy.getCall(1); // Попытка получить несуществующий вызов
+} catch (e) {
+  // Ожидаемая ошибка, например:
+  // "Invalid call index 1. Spy has 1 call(s)."
+  console.error(e.message);
+}
+```
+
+#### spy.calledWith(...expectedArgs)
+
+Аргументы:
+
+- `...expectedArgs`: Аргументы, с которыми, как ожидается, был вызван
+    шпион.
+
+Возвращает: `boolean`
+- `true`, если шпион был хотя бы раз вызван с точно таким же набором
+  аргументов (сравнение с использованием `Object.is`).
+- `false` в противном случае.
+
+Пример:
+
+```javascript
+const spy = createSpy(() => {});
+spy(1, 'a', true);
+spy(2, 'b');
+
+console.log(spy.calledWith(1, 'a', true)); // true
+console.log(spy.calledWith(1, 'a'));       // false
+console.log(spy.calledWith(2, 'c'));       // false
+```
+
+#### spy.nthCalledWith(n, ...expectedArgs)
+
+Аргументы:
+
+- `n`: Число, индекс вызова (начиная с 0).
+- `...expectedArgs`: Аргументы, с которыми, как ожидается, был
+  совершен n-ый вызов.
+
+Возвращает: `boolean`
+
+- `true`, если n-ый вызов шпиона был совершен с точно таким же набором
+  аргументов.
+- `false` в противном случае.
+
+Выбрасывает:
+
+- `RangeError`: Если `n` не является допустимым индексом вызова
+  (унаследовано от `getCall`).
+
+Пример:
+
+```javascript
+const spy = createSpy(() => {});
+spy('first call');
+spy('second call', 123);
+
+console.log(spy.nthCalledWith(0, 'first call'));       // true
+console.log(spy.nthCalledWith(1, 'second call', 123)); // true
+console.log(spy.nthCalledWith(0, 'another'));          // false
+
+try {
+  spy.nthCalledWith(2, 'anything'); // Несуществующий вызов
+} catch (e) {
+  // Ожидаемая ошибка, например:
+  // "Invalid call index 2. Spy has 2 call(s)."
+  console.error(e.message);
+}
+```
+
+#### spy.nthCallReturned(n, expectedReturnValue)
+
+Аргументы:
+
+- `n`: Число, индекс вызова (начиная с 0).
+- `expectedReturnValue`: Ожидаемое возвращаемое значение для n-го
+  вызова.
+
+Возвращает: `boolean`
+- `true`, если n-ый вызов шпиона вернул `expectedReturnValue` (сравнение
+  с помощью `Object.is`).
+- `false`, если значение не совпало или вызов выбросил ошибку.
+
+Выбрасывает:
+
+- `RangeError`: Если `n` не является допустимым индексом вызова
+  (унаследовано от `getCall`).
+
+Пример:
+
+```javascript
+const spy = createSpy(val => {
+  if (val === 0) throw new Error('zero');
+  return val * 10;
+});
+spy(5); // 0-й вызов, возвращает 50
+try { spy(0); } catch(e) {} // 1-й вызов, бросает ошибку
+spy(2); // 2-й вызов, возвращает 20
+
+console.log(spy.nthCallReturned(0, 50)); // true
+console.log(spy.nthCallReturned(1, 10)); // false (вызов 1 бросил ошибку)
+console.log(spy.nthCallReturned(2, 20)); // true
+
+try {
+  spy.nthCallReturned(3, 30); // Несуществующий вызов
+} catch (e) {
+  // Ожидаемая ошибка, например:
+  // "Invalid call index 3. Spy has 3 call(s)."
+  console.error(e.message);
+}
+```
+
+#### spy.nthCallThrew(n, [expectedError])
+
+Аргументы:
+
+- `n`: Число, индекс вызова (начиная с 0).
+- `expectedError` (необязательно): Матчер для ошибки. Возможные
+  варианты:
+  - `undefined`: Проверяет, что n-ый вызов просто выбросил любую
+    ошибку.
+  - Строка: Проверяет, что сообщение выброшенной ошибки
+    (`error.message`) совпадает со строкой.
+  - Конструктор ошибки (например, `Error`, `TypeError`): Проверяет,
+    что выброшенная ошибка является экземпляром (`instanceof`) этого
+    конструктора.
+  - Экземпляр ошибки: Проверяет, что имя (`error.name`) и сообщение
+    (`error.message`) выброшенной ошибки совпадают с полями
+    `expectedError`.
+  - Любое другое значение: Проверяется прямое совпадение выброшенной
+    ошибки с `expectedError` через `Object.is`.
+
+Возвращает: `boolean`
+
+- `true`, если n-ый вызов шпиона выбросил ошибку
+  или ошибка соответствует `expectedError`.
+- `false`, если вызов не выбросил ошибку (или выбросил не ту ошибку).
+
+Выбрасывает:
+- `RangeError`: Если `n` не является допустимым индексом вызова
+  (унаследовано от `getCall`).
+
+Пример:
+
+```javascript
+const mightThrow = (val) => {
+  if (val === 0) throw new TypeError('Zero is not allowed');
+  if (val < 0) throw new Error('Negative value');
+  return val;
+};
+const spy = createSpy(mightThrow);
+
+try { spy(0); } catch (e) {}  // 0-й вызов
+try { spy(-5); } catch (e) {} // 1-й вызов
+spy(10);                      // 2-й вызов
+
+console.log(spy.nthCallThrew(0));                        // true
+console.log(spy.nthCallThrew(0, 'Zero is not allowed')); // true
+console.log(spy.nthCallThrew(2));                        // false (2-й вызов не бросил ошибку)
+
+try {
+  spy.nthCallThrew(3); // Несуществующий вызов
+} catch (e) {
+  // Ожидаемая ошибка, например:
+  // "Invalid call index 3. Spy has 3 call(s)."
+  console.error(e.message);
+}
+```
+
+#### spy.restore()
+
+Описание:
+
+- Если шпион был создан для метода объекта, этот метод
+  восстанавливает оригинальный метод на объекте.
+- Если шпион был создан для отдельной функции, вызов этого метода
+  ничего не делает.
+
+```javascript
+const myObject = {
+  doSomething() {
+    return 'original';
+  }
+};
+
+const spy = createSpy(myObject, 'doSomething');
+// myObject.doSomething(); // может быть вызван шпион
+
+spy.restore();
+console.log(myObject.doSomething()); // 'original'
+```
+
+## Тесты
+
+```bash
+npm run test
+```
+
+## Лицензия
+
+MIT

+ 16 - 0
build-cjs.js

@@ -0,0 +1,16 @@
+import * as esbuild from 'esbuild';
+import packageJson from './package.json' with {type: 'json'};
+
+await esbuild.build({
+  entryPoints: ['src/index.js'],
+  outfile: 'dist/cjs/index.cjs',
+  format: 'cjs',
+  platform: 'node',
+  target: ['node12'],
+  bundle: true,
+  keepNames: true,
+  external: [
+    ...Object.keys(packageJson.peerDependencies || {}),
+    ...Object.keys(packageJson.dependencies || {}),
+  ],
+});

+ 184 - 0
dist/cjs/index.cjs

@@ -0,0 +1,184 @@
+var __defProp = Object.defineProperty;
+var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
+var __getOwnPropNames = Object.getOwnPropertyNames;
+var __hasOwnProp = Object.prototype.hasOwnProperty;
+var __name = (target, value) => __defProp(target, "name", { value, configurable: true });
+var __export = (target, all) => {
+  for (var name in all)
+    __defProp(target, name, { get: all[name], enumerable: true });
+};
+var __copyProps = (to, from, except, desc) => {
+  if (from && typeof from === "object" || typeof from === "function") {
+    for (let key of __getOwnPropNames(from))
+      if (!__hasOwnProp.call(to, key) && key !== except)
+        __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
+  }
+  return to;
+};
+var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
+
+// src/index.js
+var index_exports = {};
+__export(index_exports, {
+  createSpy: () => createSpy
+});
+module.exports = __toCommonJS(index_exports);
+
+// src/create-spy.js
+function _parseSpyArgs(target, methodNameOrImplFromSpy, customImplForMethodFromSpy) {
+  let originalFn;
+  let customImplementation;
+  let isMethodSpy = false;
+  let objToSpyOn;
+  let methodName;
+  const isLikelyFunctionSpy = typeof target === "function" && customImplForMethodFromSpy === void 0;
+  const isLikelyMethodSpy = typeof target === "object" && target !== null && typeof methodNameOrImplFromSpy === "string";
+  if (isLikelyFunctionSpy) {
+    originalFn = target;
+    if (methodNameOrImplFromSpy !== void 0) {
+      if (typeof methodNameOrImplFromSpy !== "function") {
+        throw new TypeError(
+          "When spying on a function, the second argument (custom implementation) must be a function if provided."
+        );
+      }
+      customImplementation = methodNameOrImplFromSpy;
+    }
+  } else if (isLikelyMethodSpy) {
+    methodName = methodNameOrImplFromSpy;
+    objToSpyOn = target;
+    isMethodSpy = true;
+    if (!(methodName in target)) {
+      throw new TypeError(
+        `Attempted to spy on a non-existent property: "${methodName}"`
+      );
+    }
+    const propertyToSpyOn = target[methodName];
+    if (typeof propertyToSpyOn !== "function") {
+      throw new TypeError(
+        `Attempted to spy on "${methodName}" which is not a function. It is a "${typeof propertyToSpyOn}".`
+      );
+    }
+    originalFn = propertyToSpyOn;
+    if (customImplForMethodFromSpy !== void 0) {
+      if (typeof customImplForMethodFromSpy !== "function") {
+        throw new TypeError(
+          "When spying on a method, the third argument (custom implementation) must be a function if provided."
+        );
+      }
+      customImplementation = customImplForMethodFromSpy;
+    }
+  } else {
+    if (target === null && methodNameOrImplFromSpy === void 0 && customImplForMethodFromSpy === void 0) {
+      throw new TypeError("Attempted to spy on null.");
+    }
+    if (methodNameOrImplFromSpy === void 0 && typeof target !== "function") {
+      throw new TypeError(
+        `Attempted to spy on a ${typeof target} which is not a function.`
+      );
+    }
+    throw new Error(
+      "Invalid arguments. Valid signatures:\n  createSpy(function, [customImplementationFunction])\n  createSpy(object, methodNameString, [customImplementationFunction])"
+    );
+  }
+  return {
+    originalFn,
+    // определение функции для выполнения шпионом: либо
+    // пользовательская реализация, либо оригинальная функция
+    fnToExecute: customImplementation || originalFn,
+    isMethodSpy,
+    objToSpyOn,
+    methodName
+  };
+}
+__name(_parseSpyArgs, "_parseSpyArgs");
+function createSpy(target, methodNameOrImpl, customImplForMethod) {
+  const { originalFn, fnToExecute, isMethodSpy, objToSpyOn, methodName } = _parseSpyArgs(target, methodNameOrImpl, customImplForMethod);
+  const callLog = {
+    count: 0,
+    calls: []
+  };
+  const spy = /* @__PURE__ */ __name(function(...args) {
+    callLog.count++;
+    const callInfo = {
+      // сохранение аргументов, с которыми
+      // был вызван шпион
+      args: [...args],
+      // сохранение контекста (this)
+      // вызова шпиона
+      thisArg: this,
+      returnValue: void 0,
+      error: void 0
+    };
+    try {
+      callInfo.returnValue = fnToExecute.apply(this, args);
+      callLog.calls.push(callInfo);
+      return callInfo.returnValue;
+    } catch (e) {
+      callInfo.error = e;
+      callLog.calls.push(callInfo);
+      throw e;
+    }
+  }, "spy");
+  Object.defineProperty(spy, "callCount", {
+    get: /* @__PURE__ */ __name(() => callLog.count, "get"),
+    enumerable: true,
+    configurable: false
+  });
+  Object.defineProperty(spy, "called", {
+    get: /* @__PURE__ */ __name(() => callLog.count > 0, "get"),
+    enumerable: true,
+    configurable: false
+  });
+  spy.getCall = (n) => {
+    if (typeof n !== "number" || n < 0 || n >= callLog.calls.length) {
+      throw new RangeError(
+        `Invalid call index ${n}. Spy has ${callLog.calls.length} call(s).`
+      );
+    }
+    return callLog.calls[n];
+  };
+  spy.calledWith = (...expectedArgs) => {
+    return callLog.calls.some(
+      (call) => call.args.length === expectedArgs.length && call.args.every((arg, i) => Object.is(arg, expectedArgs[i]))
+    );
+  };
+  spy.nthCalledWith = (n, ...expectedArgs) => {
+    const call = spy.getCall(n);
+    return call.args.length === expectedArgs.length && call.args.every((arg, i) => Object.is(arg, expectedArgs[i]));
+  };
+  spy.nthCallReturned = (n, expectedReturnValue) => {
+    const call = spy.getCall(n);
+    if (call.error) return false;
+    return Object.is(call.returnValue, expectedReturnValue);
+  };
+  spy.nthCallThrew = (n, expectedError) => {
+    const call = spy.getCall(n);
+    if (call.error === void 0) return false;
+    if (expectedError === void 0) return true;
+    if (call.error === expectedError) return true;
+    if (typeof expectedError === "string") {
+      return call.error && typeof call.error.message === "string" && call.error.message === expectedError;
+    }
+    if (typeof expectedError === "function" && call.error instanceof expectedError) {
+      return true;
+    }
+    if (expectedError instanceof Error && call.error instanceof Error) {
+      return call.error.name === expectedError.name && call.error.message === expectedError.message;
+    }
+    return Object.is(call.error, expectedError);
+  };
+  spy.restore = () => {
+    if (isMethodSpy && objToSpyOn) {
+      objToSpyOn[methodName] = originalFn;
+    }
+  };
+  if (isMethodSpy && objToSpyOn) {
+    objToSpyOn[methodName] = spy;
+  }
+  return spy;
+}
+__name(createSpy, "createSpy");
+// Annotate the CommonJS export names for ESM import in node:
+0 && (module.exports = {
+  createSpy
+});

+ 28 - 0
eslint.config.js

@@ -0,0 +1,28 @@
+import globals from 'globals';
+import eslintJs from '@eslint/js';
+import eslintMochaPlugin from 'eslint-plugin-mocha';
+import eslintPrettierConfig from 'eslint-config-prettier';
+import eslintChaiExpectPlugin from 'eslint-plugin-chai-expect';
+
+export default [{
+  languageOptions: {
+    globals: {
+      ...globals.node,
+      ...globals.browser,
+      ...globals.es2021,
+      ...globals.mocha,
+    },
+  },
+  plugins: {
+    'mocha': eslintMochaPlugin,
+    'chai-expect': eslintChaiExpectPlugin,
+  },
+  rules: {
+    ...eslintJs.configs.recommended.rules,
+    ...eslintPrettierConfig.rules,
+    ...eslintMochaPlugin.configs.recommended.rules,
+    ...eslintChaiExpectPlugin.configs['recommended-flat'].rules,
+    "no-unused-vars": ["error", {caughtErrors: "none"}],
+  },
+  files: ['src/**/*.js'],
+}];

+ 57 - 0
package.json

@@ -0,0 +1,57 @@
+{
+  "name": "@e22m4u/js-spy",
+  "version": "0.0.1",
+  "description": "Утилита слежения за вызовом функций и методов для JavaScript",
+  "author": "e22m4u <e22m4u@yandex.ru>",
+  "license": "MIT",
+  "keywords": [
+    "spy",
+    "call",
+    "test",
+    "unit"
+  ],
+  "homepage": "https://github.com/e22m4u/js-spy",
+  "repository": {
+    "type": "git",
+    "url": "git+https://github.com/e22m4u/js-spy.git"
+  },
+  "type": "module",
+  "types": "./src/index.d.ts",
+  "module": "./src/index.js",
+  "main": "./dist/cjs/index.cjs",
+  "exports": {
+    "types": "./src/index.d.ts",
+    "import": "./src/index.js",
+    "require": "./dist/cjs/index.cjs"
+  },
+  "engines": {
+    "node": ">=12"
+  },
+  "scripts": {
+    "lint": "tsc && eslint ./src",
+    "lint:fix": "tsc && eslint ./src --fix",
+    "format": "prettier --write \"./src/**/*.js\"",
+    "test": "npm run lint && c8 --reporter=text-summary mocha",
+    "test:coverage": "npm run lint && c8 --reporter=text mocha",
+    "build:cjs": "rimraf ./dist/cjs && node --no-warnings=ExperimentalWarning build-cjs.js",
+    "prepare": "husky"
+  },
+  "devDependencies": {
+    "@commitlint/cli": "~19.8.1",
+    "@commitlint/config-conventional": "~19.8.1",
+    "@eslint/js": "~9.26.0",
+    "c8": "~10.1.3",
+    "chai": "~5.2.0",
+    "esbuild": "~0.25.4",
+    "eslint": "~9.26.0",
+    "eslint-config-prettier": "~10.1.3",
+    "eslint-plugin-chai-expect": "~3.1.0",
+    "eslint-plugin-mocha": "~11.0.0",
+    "globals": "~16.1.0",
+    "husky": "~9.1.7",
+    "mocha": "~11.2.2",
+    "prettier": "~3.5.3",
+    "rimraf": "~6.0.1",
+    "typescript": "~5.8.3"
+  }
+}

+ 185 - 0
src/create-spy.d.ts

@@ -0,0 +1,185 @@
+/**
+ * Тип любой функции.
+ */
+type AnyCallable = (...args: any[]) => any;
+
+/**
+ * Конструктор любой ошибки.
+ */
+type AnyErrorCtor = (new (...args: any[]) => Error);
+
+/**
+ * Ключ любого метода в объекте.
+ * Должен быть ключом `TObj`, значение которого является функцией.
+ */
+type MethodKey<TObj extends object> = {
+  [P in keyof TObj]: TObj[P] extends AnyCallable ? P : never
+}[keyof TObj];
+
+/**
+ * Информация о единичном вызове отслеживаемой функции.
+ *
+ * @template Args Кортеж, представляющий типы аргументов вызова.
+ * @template Return Тип возвращаемого значения отслеживаемой функции.
+ */
+export interface CallInfo<Args extends any[] = any[], Return = any> {
+  /**
+   * Аргументы, с которыми был вызван шпион.
+   */
+  readonly args: Args;
+
+  /**
+   * Контекст `this`, с которым был вызван шпион.
+   */
+  readonly thisArg: any;
+
+  /**
+   * Значение, возвращенное шпионом.
+   * (`undefined`, если шпион выбросил ошибку)
+   */
+  readonly returnValue: Return | undefined;
+
+  /**
+   * Ошибка, выброшенная шпионом.
+   * (`undefined`, если шпион не выбросил ошибку)
+   */
+  readonly error: unknown | undefined;
+}
+
+/**
+ * Представляет функцию-шпиона, созданную `createSpy`.
+ * Это вызываемая функция, которая также имеет свойства
+ * и методы для инспекции вызовов.
+ *
+ * @template TFunc Тип отслеживаемой функции.
+ */
+export interface Spy<TFunc extends AnyCallable = AnyCallable> {
+  /**
+   * Сама функция-шпион.
+   */
+  (...args: Parameters<TFunc>): ReturnType<TFunc>;
+
+  /**
+   * Количество вызовов шпиона.
+   * @readonly
+   */
+  readonly callCount: number;
+
+  /**
+   * Булево значение, указывающее, был ли шпион вызван хотя бы один раз.
+   *
+   * @readonly
+   */
+  readonly called: boolean;
+
+  /**
+   * Получает детали n-го вызова шпиона.
+   * Если вызов с указанным индексом `n` не существует, выбрасывает `RangeError`.
+   * @param n Индекс вызова (начиная с нуля).
+   *
+   * @returns Объект `CallInfo` для n-го вызова.
+   * @throws `RangeError` если индекс `n` невалиден.
+   */
+  getCall(n: number): CallInfo<Parameters<TFunc>, ReturnType<TFunc>>;
+
+  /**
+   * Проверяет, был ли шпион когда-либо вызван с предоставленными аргументами.
+   * Использует `Object.is` для сравнения аргументов.
+   *
+   * @param expectedArgs Ожидаемые аргументы для проверки.
+   * @returns `true`, если шпион был вызван с совпадающими
+   *   аргументами, иначе `false`.
+   */
+  calledWith(...expectedArgs: Parameters<TFunc>): boolean;
+
+  /**
+   * Проверяет, был ли n-ый вызов шпиона совершен с предоставленными аргументами.
+   * Использует `Object.is` для сравнения аргументов.
+   * Если вызов с указанным индексом `n` не существует, выбрасывает `RangeError`.
+   *
+   * @param n Индекс вызова (начиная с нуля).
+   * @param expectedArgs Ожидаемые аргументы для проверки.
+   * @returns `true`, если n-ый вызов имел совпадающие
+   *   аргументы, иначе `false`.
+   * @throws `RangeError` если индекс `n` невалиден (унаследовано от `getCall`).
+   */
+  nthCalledWith(n: number, ...expectedArgs: Parameters<TFunc>): boolean;
+
+  /**
+   * Проверяет, вернул ли n-ый вызов шпиона ожидаемое значение.
+   * Использует `Object.is` для сравнения значений.
+   * Если вызов с указанным индексом `n` не существует, выбрасывает `RangeError`.
+   *
+   * @param n Индекс вызова (начиная с нуля).
+   * @param expectedReturnValue Ожидаемое возвращаемое значение.
+   * @returns `true`, если n-ый вызов вернул ожидаемое значение, иначе `false`
+   *   (включая случаи, когда он выбросил ошибку).
+   * @throws `RangeError` если индекс `n` невалиден (унаследовано от `getCall`).
+   */
+  nthCallReturned(n: number, expectedReturnValue: ReturnType<TFunc>): boolean;
+
+  /**
+   * Проверяет, выбросил ли n-ый вызов шпиона ошибку.
+   * Если вызов с указанным индексом `n` не существует, выбрасывает `RangeError`.
+   *
+   * @param n Индекс вызова (начиная с нуля).
+   * @param expectedError Необязательно.
+   *   Если предоставлено, проверяет соответствие выброшенной ошибки:
+   *     - `string`: совпадение по сообщению ошибки.
+   *     - Конструктор `Error`: совпадение через `instanceof`.
+   *     - Экземпляр `Error`: совпадение по имени и сообщению ошибки.
+   *     - Прямое сравнение объектов с использованием `Object.is`.
+   * @returns `true`, если n-ый вызов выбросил совпадающую ошибку
+   *   (или любую ошибку, если матчер не предоставлен), иначе `false` (если вызов не бросил ошибку).
+   * @throws `RangeError` если индекс `n` невалиден (унаследовано от `getCall`).
+   */
+  nthCallThrew(
+    n: number,
+    expectedError?: string | AnyErrorCtor | Error
+  ): boolean;
+
+  /**
+   * Восстанавливает оригинальный метод, если шпион был создан
+   * для метода объекта. Ничего не делает, если шпион был создан
+   * для отдельной функции.
+   */
+  restore(): void;
+}
+
+/**
+ * Создает шпиона для отдельной функции.
+ *
+ * @template TFunc Тип функции, для которой создается шпион.
+ * @param targetFn Функция, для которой создается шпион.
+ * @param customImplementation Необязательно. Функция для замены поведения
+ *   оригинальной функции. Должна иметь ту же сигнатуру, что и `targetFn`.
+ * @returns Функция-шпион.
+ */
+export function createSpy<TFunc extends AnyCallable>(
+  targetFn: TFunc,
+  customImplementation?: TFunc
+): Spy<TFunc>;
+
+/**
+ * Создает шпиона для метода объекта.
+ * Оригинальный метод объекта будет заменен шпионом.
+ * Используйте `spy.restore()` для восстановления оригинального метода.
+ *
+ * @template TObj Тип объекта.
+ * @template K Ключ метода, для которого создается шпион.
+ *   Должен быть ключом `TObj`, значение которого является функцией.
+ * @param targetObject Объект, метод которого отслеживается.
+ * @param methodName Имя отслеживаемого метода.
+ * @param customImplementation Необязательно. Функция для замены поведения
+ *   оригинального метода. Должна иметь ту же сигнатуру, что и оригинальный
+ *   метод `TObj[K]`.
+ * @returns Функция-шпион для метода.
+ */
+export function createSpy<
+  TObj extends object,
+  K extends MethodKey<TObj>
+>(
+  targetObject: TObj,
+  methodName: K,
+  customImplementation?: TObj[K]
+): Spy<Extract<TObj[K], AnyCallable>>;

+ 317 - 0
src/create-spy.js

@@ -0,0 +1,317 @@
+/**
+ * Вспомогательная функция для разбора аргументов createSpy.
+ *
+ * @private
+ */
+function _parseSpyArgs(
+  target,
+  methodNameOrImplFromSpy,
+  customImplForMethodFromSpy,
+) {
+  // объявление переменных для хранения
+  // состояния и результатов разбора аргументов
+  let originalFn;
+  let customImplementation;
+  let isMethodSpy = false;
+  let objToSpyOn;
+  let methodName;
+  // определение вероятности того, что
+  // создается шпион для отдельной функции
+  const isLikelyFunctionSpy =
+    typeof target === 'function' && customImplForMethodFromSpy === undefined;
+  // определение вероятности того, что
+  // создается шпион для метода объекта
+  const isLikelyMethodSpy =
+    typeof target === 'object' &&
+    target !== null &&
+    typeof methodNameOrImplFromSpy === 'string';
+  // обработка сценария шпионажа
+  // за отдельной функцией
+  if (isLikelyFunctionSpy) {
+    // исходная функция - это первый аргумент
+    originalFn = target;
+    // проверка наличия второго аргумента, который
+    // может быть пользовательской реализацией
+    if (methodNameOrImplFromSpy !== undefined) {
+      // генерация ошибки, если второй аргумент
+      // (пользовательская реализация) не является функцией
+      if (typeof methodNameOrImplFromSpy !== 'function') {
+        throw new TypeError(
+          'When spying on a function, the second argument (custom ' +
+            'implementation) must be a function if provided.',
+        );
+      }
+      // пользовательская реализация присваивается,
+      // если она предоставлена и является функцией
+      customImplementation = methodNameOrImplFromSpy;
+    }
+    // обработка сценария шпионажа
+    // за методом объекта
+  } else if (isLikelyMethodSpy) {
+    // установка параметров для
+    // шпионажа за методом
+    methodName = methodNameOrImplFromSpy;
+    objToSpyOn = target;
+    isMethodSpy = true;
+    // генерация ошибки, если метод
+    // с указанным именем отсутствует на объекте
+    if (!(methodName in target)) {
+      throw new TypeError(
+        `Attempted to spy on a non-existent property: "${methodName}"`,
+      );
+    }
+    // получение свойства объекта,
+    // за которым предполагается шпионаж
+    const propertyToSpyOn = target[methodName];
+    // генерация ошибки, если свойство,
+    // за которым шпионят, не является функцией
+    if (typeof propertyToSpyOn !== 'function') {
+      throw new TypeError(
+        `Attempted to spy on "${methodName}" which is not a function. ` +
+          `It is a "${typeof propertyToSpyOn}".`,
+      );
+    }
+    // исходная функция - это
+    // метод объекта
+    originalFn = propertyToSpyOn;
+    // проверка наличия третьего аргумента, который может
+    // быть пользовательской реализацией для метода
+    if (customImplForMethodFromSpy !== undefined) {
+      // генерация ошибки, если третья (пользовательская
+      // реализация метода) не является функцией
+      if (typeof customImplForMethodFromSpy !== 'function') {
+        throw new TypeError(
+          'When spying on a method, the third argument (custom ' +
+            'implementation) must be a function if provided.',
+        );
+      }
+      // пользовательская реализация метода присваивается,
+      // если она предоставлена и является функцией
+      customImplementation = customImplForMethodFromSpy;
+    }
+    // обработка невалидных
+    // комбинаций аргументов
+  } else {
+    // специальная проверка и генерация ошибки
+    // для попытки шпионить за null
+    if (
+      target === null &&
+      methodNameOrImplFromSpy === undefined &&
+      customImplForMethodFromSpy === undefined
+    ) {
+      throw new TypeError('Attempted to spy on null.');
+    }
+    // генерация ошибки, если target не функция
+    // и имя метода не предоставлено
+    if (methodNameOrImplFromSpy === undefined && typeof target !== 'function') {
+      throw new TypeError(
+        `Attempted to spy on a ${typeof target} which is not a function.`,
+      );
+    }
+    // генерация общей ошибки для
+    // остальных невалидных сигнатур вызова
+    throw new Error(
+      'Invalid arguments. Valid signatures:\n' +
+        '  createSpy(function, [customImplementationFunction])\n' +
+        '  createSpy(object, methodNameString, [customImplementationFunction])',
+    );
+  }
+  // формирование и возврат объекта
+  // с конфигурацией для создания шпиона
+  return {
+    originalFn,
+    // определение функции для выполнения шпионом: либо
+    // пользовательская реализация, либо оригинальная функция
+    fnToExecute: customImplementation || originalFn,
+    isMethodSpy,
+    objToSpyOn,
+    methodName,
+  };
+}
+
+/**
+ * Создает шпиона для функции или метода объекта,
+ * с возможностью подмены реализации.
+ *
+ * Шпионить за отдельной функцией:
+ * createSpy(targetFn, [customImplementation])
+ *
+ * Шпионить за методом объекта:
+ * createSpy(targetObject, methodName, [customImplementation])
+ *
+ * @param target - Функция для шпионажа или объект, на методе которого ставится шпион.
+ * @param methodNameOrImpl - Имя метода (строка) если target - объект,
+ *                           или кастомная реализация (функция) если target - функция.
+ * @param customImplForMethod - Кастомная реализация (функция) если target - объект и указан methodName.
+ * @returns {(function(...[*]): (*|undefined))|*} Шпион-функция.
+ */
+export function createSpy(target, methodNameOrImpl, customImplForMethod) {
+  // получение конфигурации шпиона
+  // путем разбора входных аргументов
+  const {originalFn, fnToExecute, isMethodSpy, objToSpyOn, methodName} =
+    _parseSpyArgs(target, methodNameOrImpl, customImplForMethod);
+  // инициализация объекта для хранения
+  // информации о вызовах шпиона
+  const callLog = {
+    count: 0,
+    calls: [],
+  };
+  // определение основной
+  // функции-шпиона
+  const spy = function (...args) {
+    // увеличение счетчика вызовов
+    // при каждом запуске шпиона
+    callLog.count++;
+    // создание объекта для записи
+    // деталей текущего вызова
+    const callInfo = {
+      // сохранение аргументов, с которыми
+      // был вызван шпион
+      args: [...args],
+      // сохранение контекста (this)
+      // вызова шпиона
+      thisArg: this,
+      returnValue: undefined,
+      error: undefined,
+    };
+    // попытка выполнения целевой функции
+    // (оригинальной или пользовательской)
+    try {
+      // выполнение функции и сохранение
+      // возвращенного значения
+      callInfo.returnValue = fnToExecute.apply(this, args);
+      // добавление информации об успешном
+      // вызове в лог
+      callLog.calls.push(callInfo);
+      // возврат результата выполнения
+      // целевой функции
+      return callInfo.returnValue;
+    } catch (e) {
+      // обработка ошибки, если выполнение целевой
+      // функции привело к исключению
+      // сохранение информации
+      // о произошедшей ошибке
+      callInfo.error = e;
+      // добавление информации о вызове
+      // с ошибкой в лог
+      callLog.calls.push(callInfo);
+      // проброс оригинальной
+      // ошибки дальше
+      throw e;
+    }
+  };
+  // определение свойства `callCount` на шпионе
+  // для получения количества вызовов
+  Object.defineProperty(spy, 'callCount', {
+    get: () => callLog.count,
+    enumerable: true,
+    configurable: false,
+  });
+  // определение свойства `called` на шпионе,
+  // указывающего, был ли шпион вызван
+  Object.defineProperty(spy, 'called', {
+    get: () => callLog.count > 0,
+    enumerable: true,
+    configurable: false,
+  });
+  // определение метода `getCall` для получения
+  // информации о конкретном вызове по его индексу
+  spy.getCall = n => {
+    // проверка корректности индекса вызова,
+    // выбрасывание ошибки при выходе за границы
+    if (typeof n !== 'number' || n < 0 || n >= callLog.calls.length) {
+      throw new RangeError(
+        `Invalid call index ${n}. Spy has ${callLog.calls.length} call(s).`,
+      );
+    }
+    return callLog.calls[n];
+  };
+  // определение метода `calledWith` для проверки,
+  // был ли шпион вызван с определенным набором аргументов
+  spy.calledWith = (...expectedArgs) => {
+    return callLog.calls.some(
+      call =>
+        call.args.length === expectedArgs.length &&
+        call.args.every((arg, i) => Object.is(arg, expectedArgs[i])),
+    );
+  };
+  // определение метода `nthCalledWith` для проверки
+  // аргументов n-го вызова шпиона
+  spy.nthCalledWith = (n, ...expectedArgs) => {
+    // getCall(n) выбросит ошибку, если индекс n невалиден
+    const call = spy.getCall(n);
+    return (
+      call.args.length === expectedArgs.length &&
+      call.args.every((arg, i) => Object.is(arg, expectedArgs[i]))
+    );
+  };
+  // определение метода `nthCallReturned` для проверки
+  // значения, возвращенного n-ым вызовом шпиона
+  spy.nthCallReturned = (n, expectedReturnValue) => {
+    // getCall(n) выбросит ошибку, если индекс n невалиден
+    const call = spy.getCall(n);
+    // возврат false, если вызов завершился ошибкой
+    if (call.error) return false;
+    return Object.is(call.returnValue, expectedReturnValue);
+  };
+  // определение метода `nthCallThrew` для проверки,
+  // выбросил ли n-ый вызов шпиона ошибку
+  spy.nthCallThrew = (n, expectedError) => {
+    // getCall(n) выбросит ошибку, если индекс n невалиден
+    const call = spy.getCall(n);
+    // возврат false, если вызов не выбросил ошибку
+    if (call.error === undefined) return false;
+    // если тип ожидаемой ошибки не указан,
+    // любая ошибка считается совпадением
+    if (expectedError === undefined) return true;
+    // проверка строгого равенства
+    // ожидаемой ошибки с выброшенной
+    if (call.error === expectedError) return true;
+    // проверка совпадения ошибки по сообщению,
+    // если ожидаемая ошибка - строка
+    if (typeof expectedError === 'string') {
+      // убедимся, что call.error существует и имеет свойство message
+      return (
+        call.error &&
+        typeof call.error.message === 'string' &&
+        call.error.message === expectedError
+      );
+    }
+    // проверка совпадения ошибки по типу (конструктору),
+    // если ожидаемая ошибка - функция-конструктор
+    if (
+      typeof expectedError === 'function' &&
+      call.error instanceof expectedError
+    ) {
+      return true;
+    }
+    // проверка совпадения ошибки по имени и сообщению,
+    // если ожидаемая ошибка - экземпляр Error
+    if (expectedError instanceof Error && call.error instanceof Error) {
+      return (
+        call.error.name === expectedError.name &&
+        call.error.message === expectedError.message
+      );
+    }
+    // прямое сравнение объектов ошибок
+    // как крайний случай
+    return Object.is(call.error, expectedError);
+  };
+  // определение метода `restore` для восстановления
+  // оригинального метода объекта, если шпионили за ним
+  spy.restore = () => {
+    // восстановление происходит только
+    // для шпионов методов объектов
+    if (isMethodSpy && objToSpyOn) {
+      objToSpyOn[methodName] = originalFn;
+    }
+  };
+  // если создается шпион для метода объекта,
+  // оригинальный метод немедленно заменяется шпионом
+  if (isMethodSpy && objToSpyOn) {
+    objToSpyOn[methodName] = spy;
+  }
+  // возврат созданной и настроенной
+  // функции-шпиона
+  return spy;
+}

+ 584 - 0
src/create-spy.spec.js

@@ -0,0 +1,584 @@
+import {expect} from 'chai';
+import {createSpy} from './create-spy.js';
+
+describe('createSpy', function () {
+  describe('argument validation', function () {
+    it('should throw when trying to spy on null', function () {
+      // проверка генерации ошибки при попытке
+      // шпионить за значением null
+      expect(() => createSpy(null)).to.throw(
+        TypeError,
+        'Attempted to spy on null.',
+      );
+    });
+
+    it('should throw if target is not a function and no method name is given', function () {
+      // проверка генерации ошибки для не-функции
+      // без указания имени метода
+      expect(() => createSpy({})).to.throw(
+        TypeError,
+        'Attempted to spy on a object which is not a function.',
+      );
+      expect(() => createSpy(123)).to.throw(
+        TypeError,
+        'Attempted to spy on a number which is not a function.',
+      );
+    });
+
+    it('should throw if custom implementation for a function spy is not a function', function () {
+      // проверка генерации ошибки, если кастомная
+      // реализация для шпиона функции не является функцией
+      const targetFn = () => {};
+      expect(() => createSpy(targetFn, 'not a function')).to.throw(
+        TypeError,
+        'When spying on a function, the second argument (custom implementation) must be a function if provided.',
+      );
+    });
+
+    it('should throw if trying to spy on a non-existent method', function () {
+      // проверка генерации ошибки при попытке
+      // шпионить за несуществующим методом объекта
+      const obj = {};
+      expect(() => createSpy(obj, 'nonExistentMethod')).to.throw(
+        TypeError,
+        'Attempted to spy on a non-existent property: "nonExistentMethod"',
+      );
+    });
+
+    it('should throw if trying to spy on a non-function property of an object', function () {
+      // проверка генерации ошибки при попытке
+      // шпионить за свойством объекта, не являющимся функцией
+      const obj = {prop: 123};
+      expect(() => createSpy(obj, 'prop')).to.throw(
+        TypeError,
+        'Attempted to spy on "prop" which is not a function. It is a "number".',
+      );
+    });
+
+    it('should throw if custom implementation for a method spy is not a function', function () {
+      // проверка генерации ошибки, если кастомная реализация
+      // для шпиона метода не является функцией
+      const obj = {method: () => {}};
+      expect(() => createSpy(obj, 'method', 'not a function')).to.throw(
+        TypeError,
+        'When spying on a method, the third argument (custom implementation) must be a function if provided.',
+      );
+    });
+  });
+
+  describe('when spying on a standalone function', function () {
+    it('should return a function that is the spy', function () {
+      // создание шпиона для пустой функции
+      const targetFn = () => {};
+      const spy = createSpy(targetFn);
+      // проверка того, что шпион
+      // является функцией
+      expect(spy).to.be.a('function');
+    });
+
+    it('should not be called initially', function () {
+      // создание шпиона
+      const spy = createSpy(function () {});
+      // первоначальное состояние свойства called
+      expect(spy.called).to.be.false;
+      // первоначальное значение счетчика вызовов
+      expect(spy.callCount).to.equal(0);
+    });
+
+    it('should track calls and arguments', function () {
+      // создание шпиона для функции,
+      // возвращающей сумму аргументов
+      const sum = (a, b) => a + b;
+      const spy = createSpy(sum);
+      // первый вызов шпиона
+      spy(1, 2);
+      // второй вызов шпиона
+      spy(3, 4);
+
+      // состояние свойства called
+      // после вызовов
+      expect(spy.called).to.be.true;
+      // значение счетчика вызовов
+      expect(spy.callCount).to.equal(2);
+
+      // проверка аргументов первого вызова
+      expect(spy.getCall(0).args).to.deep.equal([1, 2]);
+      // проверка аргументов второго вызова
+      expect(spy.getCall(1).args).to.deep.equal([3, 4]);
+    });
+
+    it('should call the original function and return its value by default', function () {
+      // создание функции, возвращающей
+      // определенное значение
+      const originalFn = () => 'original value';
+      const spy = createSpy(originalFn);
+      // вызов шпиона и сохранение результата
+      const result = spy();
+
+      // проверка возвращенного значения
+      expect(result).to.equal('original value');
+      // проверка того, что шпион был вызван
+      expect(spy.called).to.be.true;
+    });
+
+    it('should use the custom implementation if provided', function () {
+      // создание оригинальной функции и
+      // пользовательской реализации
+      const originalFn = () => 'original';
+      const customImpl = () => 'custom';
+      const spy = createSpy(originalFn, customImpl);
+      // вызов шпиона и сохранение результата
+      const result = spy();
+
+      // проверка того, что возвращено значение
+      // из пользовательской реализации
+      expect(result).to.equal('custom');
+      // проверка свойства called
+      expect(spy.called).to.be.true;
+    });
+
+    it('should preserve `this` context for the original function', function () {
+      // определение объекта с методом, который
+      // будет использоваться как target функция
+      const contextObj = {val: 10};
+      function originalFn() {
+        return this.val;
+      }
+      const spy = createSpy(originalFn);
+      // вызов шпиона с привязкой контекста
+      const result = spy.call(contextObj);
+
+      // проверка возвращенного значения, которое
+      // зависит от контекста this
+      expect(result).to.equal(10);
+      // проверка сохраненного контекста
+      // в информации о вызове
+      expect(spy.getCall(0).thisArg).to.equal(contextObj);
+    });
+
+    it('should preserve `this` context for the custom implementation', function () {
+      // определение объекта и пользовательской реализации,
+      // использующей контекст this
+      const contextObj = {val: 20};
+      const originalFn = () => {};
+      function customImpl() {
+        return this.val;
+      }
+      const spy = createSpy(originalFn, customImpl);
+      // вызов шпиона с привязкой контекста
+      const result = spy.call(contextObj);
+
+      // проверка возвращенного значения из
+      // пользовательской реализации
+      expect(result).to.equal(20);
+      // проверка сохраненного контекста
+      expect(spy.getCall(0).thisArg).to.equal(contextObj);
+    });
+  });
+
+  describe('when spying on an object method', function () {
+    // определение объекта и его метода
+    // для каждого теста в этом блоке
+    let obj;
+    let originalMethodImpl; // для проверки восстановления
+
+    beforeEach(function () {
+      // это функция, которая будет телом
+      // beforeEach, поэтому комментарии здесь уместны
+      // инициализация объекта с методом
+      // перед каждым тестом
+      originalMethodImpl = function (val) {
+        return `original: ${this.name} ${val}`;
+      };
+      obj = {
+        name: 'TestObj',
+        method: originalMethodImpl,
+      };
+    });
+
+    it('should replace the original method with the spy', function () {
+      // создание шпиона для метода объекта
+      const spy = createSpy(obj, 'method');
+      // проверка того, что свойство объекта
+      // теперь является шпионом
+      expect(obj.method).to.equal(spy);
+      // проверка того, что шпион является функцией
+      expect(obj.method).to.be.a('function');
+    });
+
+    it('should call the original method with object context and return its value', function () {
+      // создание шпиона для метода
+      const spy = createSpy(obj, 'method');
+      // вызов подмененного метода
+      const result = obj.method('arg1');
+
+      // проверка возвращенного значения
+      // от оригинального метода
+      expect(result).to.equal('original: TestObj arg1');
+      // проверка счетчика вызовов
+      expect(spy.callCount).to.equal(1);
+      // проверка сохраненного контекста
+      expect(spy.getCall(0).thisArg).to.equal(obj);
+      // проверка сохраненных аргументов
+      expect(spy.getCall(0).args).to.deep.equal(['arg1']);
+    });
+
+    it('should use custom implementation with object context if provided', function () {
+      // определение пользовательской реализации
+      const customImpl = function (val) {
+        return `custom: ${this.name} ${val}`;
+      };
+      // создание шпиона с пользовательской реализацией
+      const spy = createSpy(obj, 'method', customImpl);
+      // вызов подмененного метода
+      const result = obj.method('argCustom');
+
+      // проверка возвращенного значения
+      // от пользовательской реализации
+      expect(result).to.equal('custom: TestObj argCustom');
+      // проверка счетчика вызовов
+      expect(spy.callCount).to.equal(1);
+      // проверка сохраненного контекста
+      expect(spy.getCall(0).thisArg).to.equal(obj);
+    });
+
+    it('restore() should put the original method back', function () {
+      // создание шпиона для метода
+      const spy = createSpy(obj, 'method');
+      // проверка, что метод заменен
+      expect(obj.method).to.equal(spy);
+      // вызов метода restore на шпионе
+      spy.restore();
+      // проверка, что оригинальный метод восстановлен
+      expect(obj.method).to.equal(originalMethodImpl);
+      // вызов восстановленного метода
+      // для проверки его работоспособности
+      const result = obj.method('after restore');
+      // проверка результата вызова
+      // оригинального метода
+      expect(result).to.equal('original: TestObj after restore');
+    });
+
+    it('restore() on a function spy should not throw and do nothing to objects', function () {
+      // создание шпиона для отдельной функции
+      const fnSpy = createSpy(function () {});
+      // проверка, что вызов restore
+      // не вызывает ошибок
+      expect(() => fnSpy.restore()).to.not.throw();
+      // проверка, что метод объекта не был изменен,
+      // так как шпион не был на нем установлен
+      expect(obj.method).to.equal(originalMethodImpl);
+    });
+  });
+
+  describe('spy properties and methods', function () {
+    // объявление шпиона для использования в тестах
+    let spy;
+    // определение функции, которая
+    // будет объектом шпионажа
+    const targetFn = (a, b) => {
+      if (a === 0) throw new Error('zero error');
+      return a + b;
+    };
+
+    beforeEach(function () {
+      // это функция, которая будет телом
+      // beforeEach, поэтому комментарии здесь уместны
+      // создание нового шпиона перед
+      // каждым тестом в этом блоке
+      spy = createSpy(targetFn);
+    });
+
+    describe('.callCount and .called', function () {
+      it('should have callCount = 0 and called = false initially', function () {
+        // начальное состояние счетчика вызовов
+        expect(spy.callCount).to.equal(0);
+        // начальное состояние флага called
+        expect(spy.called).to.be.false;
+      });
+
+      it('should update after calls', function () {
+        // первый вызов шпиона
+        spy(1, 1);
+        // состояние после первого вызова
+        expect(spy.callCount).to.equal(1);
+        expect(spy.called).to.be.true;
+        // второй вызов шпиона
+        spy(2, 2);
+        // состояние после второго вызова
+        expect(spy.callCount).to.equal(2);
+      });
+    });
+
+    describe('.getCall(n)', function () {
+      it('should throw RangeError for out-of-bounds index', function () {
+        // проверка для отрицательного индекса
+        expect(() => spy.getCall(-1)).to.throw(
+          RangeError,
+          /Invalid call index -1/,
+        );
+        // проверка для индекса, равного количеству вызовов (когда вызовов нет)
+        expect(() => spy.getCall(0)).to.throw(
+          RangeError,
+          /Invalid call index 0. Spy has 0 call\(s\)\./,
+        );
+        spy(1, 1);
+        // проверка для индекса, равного количеству вызовов (когда есть один вызов)
+        expect(() => spy.getCall(1)).to.throw(
+          RangeError,
+          /Invalid call index 1. Spy has 1 call\(s\)\./,
+        );
+        // проверка для индекса, большего количества вызовов
+        expect(() => spy.getCall(10)).to.throw(
+          RangeError,
+          /Invalid call index 10. Spy has 1 call\(s\)\./,
+        );
+      });
+
+      it('should throw RangeError if index is not a number', function () {
+        // проверка для нечислового индекса
+        expect(() => spy.getCall('a')).to.throw(
+          RangeError,
+          /Invalid call index a/,
+        );
+        expect(() => spy.getCall(null)).to.throw(
+          RangeError,
+          /Invalid call index null/,
+        );
+        expect(() => spy.getCall(undefined)).to.throw(
+          RangeError,
+          /Invalid call index undefined/,
+        );
+      });
+
+      it('should return call details for a valid index', function () {
+        // вызов шпиона с определенными аргументами
+        // и контекстом
+        const context = {id: 1};
+        spy.call(context, 10, 20);
+        // получение информации о первом вызове (индекс 0)
+        const callInfo = spy.getCall(0);
+        // проверка наличия информации о вызове
+        expect(callInfo).to.exist;
+        // проверка аргументов вызова
+        expect(callInfo.args).to.deep.equal([10, 20]);
+        // проверка контекста вызова
+        expect(callInfo.thisArg).to.equal(context);
+        // проверка возвращенного значения
+        expect(callInfo.returnValue).to.equal(30);
+        // проверка отсутствия ошибки
+        expect(callInfo.error).to.be.undefined;
+      });
+
+      it('should record error if thrown', function () {
+        // попытка вызова, который приведет к ошибке
+        try {
+          spy(0, 1);
+        } catch (e) {
+          // ошибка ожидаема
+        }
+        // получение информации о вызове,
+        // завершившемся ошибкой
+        const callInfo = spy.getCall(0);
+        // проверка наличия ошибки в информации о вызове
+        expect(callInfo.error).to.be.instanceOf(Error);
+        // проверка сообщения ошибки
+        expect(callInfo.error.message).to.equal('zero error');
+        // проверка отсутствия возвращенного значения
+        expect(callInfo.returnValue).to.be.undefined;
+      });
+    });
+
+    describe('.calledWith(...args)', function () {
+      it('should return true if called with matching arguments (Object.is comparison)', function () {
+        // вызов шпиона с разными наборами аргументов
+        spy(1, 2);
+        spy('a', 'b');
+        const objArg = {};
+        spy(objArg, null, undefined);
+        // проверка совпадения аргументов
+        expect(spy.calledWith(1, 2)).to.be.true;
+        expect(spy.calledWith('a', 'b')).to.be.true;
+        expect(spy.calledWith(objArg, null, undefined)).to.be.true;
+      });
+
+      it('should return false if not called with matching arguments', function () {
+        // вызов шпиона
+        spy(1, 2);
+        // проверка с несовпадающими аргументами
+        expect(spy.calledWith(1, 3)).to.be.false;
+        expect(spy.calledWith(1)).to.be.false;
+        expect(spy.calledWith()).to.be.false;
+        expect(spy.calledWith(1, 2, 3)).to.be.false;
+      });
+
+      it('should return false if never called', function () {
+        // проверка для шпиона,
+        // который не был вызван
+        expect(spy.calledWith(1, 2)).to.be.false;
+      });
+    });
+
+    describe('.nthCalledWith(n, ...args)', function () {
+      it('should return true if nth call had matching arguments', function () {
+        // серия вызовов шпиона
+        spy(1, 2); // 0-й вызов
+        spy('x', 'y'); // 1-й вызов
+        // проверка аргументов конкретных вызовов
+        expect(spy.nthCalledWith(0, 1, 2)).to.be.true;
+        expect(spy.nthCalledWith(1, 'x', 'y')).to.be.true;
+      });
+
+      it('should return false if nth call had different arguments', function () {
+        // вызов шпиона
+        spy(1, 2);
+        // проверки для несовпадающих аргументов
+        expect(spy.nthCalledWith(0, 1, 3)).to.be.false;
+        expect(spy.nthCalledWith(0)).to.be.false;
+      });
+
+      it('should throw RangeError if call index is out of bounds', function () {
+        spy(1, 2);
+        // проверка для несуществующего вызова
+        expect(() => spy.nthCalledWith(1, 1, 2)).to.throw(
+          RangeError,
+          /Invalid call index 1/,
+        );
+        expect(() => spy.nthCalledWith(-1, 1, 2)).to.throw(
+          RangeError,
+          /Invalid call index -1/,
+        );
+      });
+    });
+
+    describe('.nthCallReturned(n, returnValue)', function () {
+      it('should return true if nth call returned the expected value (Object.is comparison)', function () {
+        // серия вызовов
+        spy(1, 2); // возвращает 3
+        spy(5, 5); // возвращает 10
+        const objRet = {};
+        const fnWithObjRet = () => objRet;
+        const spy2 = createSpy(fnWithObjRet);
+        spy2();
+
+        // проверка возвращаемых значений
+        expect(spy.nthCallReturned(0, 3)).to.be.true;
+        expect(spy.nthCallReturned(1, 10)).to.be.true;
+        expect(spy2.nthCallReturned(0, objRet)).to.be.true;
+      });
+
+      it('should return false if nth call returned different value or threw', function () {
+        // вызов, который вернет значение
+        spy(1, 2); // возвращает 3 (0-й вызов)
+        // вызов, который вызовет ошибку
+        try {
+          spy(0, 1); // 1-й вызов
+        } catch (e) {
+          // бросает ошибку
+        }
+
+        // проверки для различных сценариев
+        expect(spy.nthCallReturned(0, 4)).to.be.false;
+        expect(spy.nthCallReturned(1, undefined)).to.be.false;
+      });
+
+      it('should throw RangeError if call index is out of bounds', function () {
+        spy(1, 2);
+        // проверка для несуществующего вызова
+        expect(() => spy.nthCallReturned(1, 3)).to.throw(
+          RangeError,
+          /Invalid call index 1/,
+        );
+      });
+    });
+
+    describe('.nthCallThrew(n, errorMatcher)', function () {
+      it('should return true if nth call threw any error (no matcher)', function () {
+        // вызов, приводящий к ошибке
+        try {
+          spy(0, 1);
+        } catch (e) {
+          // бросает ошибку
+        }
+        // проверка факта ошибки
+        expect(spy.nthCallThrew(0)).to.be.true;
+      });
+
+      it('should return false if nth call did not throw', function () {
+        // успешный вызов
+        spy(1, 2);
+        // проверки
+        expect(spy.nthCallThrew(0)).to.be.false;
+      });
+
+      it('should throw RangeError if call index is out of bounds', function () {
+        spy(1, 2);
+        // проверка для несуществующего вызова
+        expect(() => spy.nthCallThrew(1)).to.throw(
+          RangeError,
+          /Invalid call index 1/,
+        );
+      });
+
+      it('should match error by message (string)', function () {
+        // вызов, приводящий к ошибке
+        try {
+          spy(0, 1);
+        } catch (e) {
+          // бросает ошибку
+        }
+        // проверка по сообщению ошибки
+        expect(spy.nthCallThrew(0, 'zero error')).to.be.true;
+        expect(spy.nthCallThrew(0, 'other error')).to.be.false;
+      });
+
+      it('should match error by type (constructor)', function () {
+        // вызов, приводящий к ошибке
+        try {
+          spy(0, 1);
+        } catch (e) {
+          // бросает ошибку
+        }
+        // проверка по типу ошибки
+        expect(spy.nthCallThrew(0, Error)).to.be.true;
+        expect(spy.nthCallThrew(0, TypeError)).to.be.false;
+      });
+
+      it('should match error by instance (name and message)', function () {
+        // вызов, приводящий к ошибке
+        try {
+          spy(0, 1);
+        } catch (e) {
+          // бросает ошибку
+        }
+        // создание экземпляра ошибки для сравнения
+        const expectedError = new Error('zero error');
+        const wrongError = new Error('another error');
+        const differentType = new TypeError('zero error');
+        // проверки
+        expect(spy.nthCallThrew(0, expectedError)).to.be.true;
+        expect(spy.nthCallThrew(0, wrongError)).to.be.false;
+        expect(spy.nthCallThrew(0, differentType)).to.be.false;
+      });
+
+      it('should match error by Object.is for direct error object comparison', function () {
+        // создание специфической ошибки
+        const specificError = new RangeError('specific');
+        const fnThrowsSpecific = () => {
+          throw specificError;
+        };
+        const specificSpy = createSpy(fnThrowsSpecific);
+        try {
+          specificSpy();
+        } catch (e) {
+          // бросает ошибку
+        }
+        // проверка по прямому совпадению объекта ошибки
+        expect(specificSpy.nthCallThrew(0, specificError)).to.be.true;
+        // новый экземпляр не тот же объект, но совпадет по name и message
+        expect(specificSpy.nthCallThrew(0, new RangeError('specific'))).to.be
+          .true;
+      });
+    });
+  });
+});

+ 1 - 0
src/index.d.ts

@@ -0,0 +1 @@
+export * from './create-spy.js';

+ 1 - 0
src/index.js

@@ -0,0 +1 @@
+export * from './create-spy.js';

+ 9 - 0
tsconfig.json

@@ -0,0 +1,9 @@
+{
+  "compilerOptions": {
+    "rootDir": "src",
+    "noEmit": true,
+    "target": "es2022",
+    "module": "NodeNext",
+    "moduleResolution": "NodeNext"
+  }
+}