diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 0000000..21f5352 --- /dev/null +++ b/jest.config.js @@ -0,0 +1,46 @@ +/** + * Jest 配置 + * + * 仅对消息状态核心逻辑(store 幂等性 / 实时事件管道 / 未读计数原子性)做单测, + * 不覆盖 RN / Expo UI。所有平台依赖(@/services/core、@/database、@/core/events、 + * @/stores/auth、@/services/message)在测试中通过 moduleNameMapper 映射到 mock。 + */ +const { pathsToModuleNameMapper } = require('ts-jest'); + +const tsconfig = require('./tsconfig.json'); +const tsconfigBase = require('expo/tsconfig.base.json'); + +// 合并 base + 项目 paths +const paths = { + ...(tsconfigBase.compilerOptions.paths || {}), + ...(tsconfig.compilerOptions.paths || {}), +}; + +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + roots: ['/src'], + testMatch: ['**/__tests__/**/*.test.ts'], + moduleNameMapper: { + // 平台依赖统一映射到 mock(必须放在 @/* 通配之前,否则会被通配吞掉) + '^@/services/core$': '/src/stores/message/__tests__/mocks/coreMock.ts', + '^@/services/message$': '/src/stores/message/__tests__/mocks/messageServiceMock.ts', + '^@/database$': '/src/stores/message/__tests__/mocks/databaseMock.ts', + '^@/core/events/EventBus$': '/src/stores/message/__tests__/mocks/eventBusMock.ts', + '^@/stores/auth/authStore$': '/src/stores/message/__tests__/mocks/authStoreMock.ts', + '^@react-native-async-storage/async-storage$': + '/src/stores/message/__tests__/mocks/asyncStorageMock.ts', + // @/* 路径别名 → 真实 src(通配兜底,放最后) + ...pathsToModuleNameMapper(paths, { prefix: '/' }), + }, + transform: { + '^.+\\.tsx?$': [ + 'ts-jest', + { + tsconfig: '/src/stores/message/__tests__/tsconfig.json', + isolatedModules: true, + }, + ], + }, + clearMocks: true, +}; diff --git a/package-lock.json b/package-lock.json index c3f9606..54b1856 100644 --- a/package-lock.json +++ b/package-lock.json @@ -69,9 +69,12 @@ }, "devDependencies": { "@react-native-community/cli": "^20.1.3", + "@types/jest": "^29.5.12", "@types/pako": "^2.0.4", "@types/react": "~19.2.16", "@types/react-native-vector-icons": "^6.4.18", + "jest": "^29.7.0", + "ts-jest": "^29.1.2", "typescript": "~6.0.3" } }, @@ -453,6 +456,61 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/plugin-syntax-async-generators": { + "version": "7.8.4", + "resolved": "https://registry.npmmirror.com/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", + "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-bigint": { + "version": "7.8.3", + "resolved": "https://registry.npmmirror.com/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz", + "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-properties": { + "version": "7.12.13", + "resolved": "https://registry.npmmirror.com/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", + "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.12.13" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-static-block": { + "version": "7.14.5", + "resolved": "https://registry.npmmirror.com/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz", + "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, "node_modules/@babel/plugin-syntax-decorators": { "version": "7.29.7", "resolved": "https://registry.npmmirror.com/@babel/plugin-syntax-decorators/-/plugin-syntax-decorators-7.29.7.tgz", @@ -510,6 +568,48 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/plugin-syntax-import-attributes": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.29.7.tgz", + "integrity": "sha512-zGYcYfq/WmZ4V+kBIXQon9dSSc8ircGZqw9ZaNhhGj9nZkeBu1jHLBDQqYYi5WA9uawvA2sIMbry2nCFhf5Djg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-meta": { + "version": "7.10.4", + "resolved": "https://registry.npmmirror.com/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", + "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-json-strings": { + "version": "7.8.3", + "resolved": "https://registry.npmmirror.com/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", + "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, "node_modules/@babel/plugin-syntax-jsx": { "version": "7.29.7", "resolved": "https://registry.npmmirror.com/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.29.7.tgz", @@ -525,6 +625,19 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/plugin-syntax-logical-assignment-operators": { + "version": "7.10.4", + "resolved": "https://registry.npmmirror.com/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", + "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { "version": "7.8.3", "resolved": "https://registry.npmmirror.com/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", @@ -537,6 +650,45 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/plugin-syntax-numeric-separator": { + "version": "7.10.4", + "resolved": "https://registry.npmmirror.com/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", + "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-object-rest-spread": { + "version": "7.8.3", + "resolved": "https://registry.npmmirror.com/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", + "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-catch-binding": { + "version": "7.8.3", + "resolved": "https://registry.npmmirror.com/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", + "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, "node_modules/@babel/plugin-syntax-optional-chaining": { "version": "7.8.3", "resolved": "https://registry.npmmirror.com/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", @@ -549,6 +701,38 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/plugin-syntax-private-property-in-object": { + "version": "7.14.5", + "resolved": "https://registry.npmmirror.com/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz", + "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-top-level-await": { + "version": "7.14.5", + "resolved": "https://registry.npmmirror.com/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", + "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, "node_modules/@babel/plugin-syntax-typescript": { "version": "7.29.7", "resolved": "https://registry.npmmirror.com/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.29.7.tgz", @@ -1174,6 +1358,13 @@ "node": ">=6.9.0" } }, + "node_modules/@bcoe/v8-coverage": { + "version": "0.2.3", + "resolved": "https://registry.npmmirror.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", + "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", + "dev": true, + "license": "MIT" + }, "node_modules/@bufbuild/protobuf": { "version": "1.10.1", "resolved": "https://registry.npmmirror.com/@bufbuild/protobuf/-/protobuf-1.10.1.tgz", @@ -1949,6 +2140,405 @@ "node": ">=12" } }, + "node_modules/@istanbuljs/load-nyc-config": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", + "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "camelcase": "^5.3.1", + "find-up": "^4.1.0", + "get-package-type": "^0.1.0", + "js-yaml": "^3.13.1", + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmmirror.com/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmmirror.com/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { + "version": "3.14.2", + "resolved": "https://registry.npmmirror.com/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.6", + "resolved": "https://registry.npmmirror.com/@istanbuljs/schema/-/schema-0.1.6.tgz", + "integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/console": { + "version": "29.7.0", + "resolved": "https://registry.npmmirror.com/@jest/console/-/console-29.7.0.tgz", + "integrity": "sha512-5Ni4CU7XHQi32IJ398EEP4RrB8eV09sXP2ROqD4bksHrnTree52PsxvX8tpL8LvTZ3pFzXyPbNQReSN41CAhOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/core": { + "version": "29.7.0", + "resolved": "https://registry.npmmirror.com/@jest/core/-/core-29.7.0.tgz", + "integrity": "sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/reporters": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "ansi-escapes": "^4.2.1", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "exit": "^0.1.2", + "graceful-fs": "^4.2.9", + "jest-changed-files": "^29.7.0", + "jest-config": "^29.7.0", + "jest-haste-map": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-resolve-dependencies": "^29.7.0", + "jest-runner": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "jest-watcher": "^29.7.0", + "micromatch": "^4.0.4", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/core/node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmmirror.com/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/core/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/environment": { + "version": "29.7.0", + "resolved": "https://registry.npmmirror.com/@jest/environment/-/environment-29.7.0.tgz", + "integrity": "sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/fake-timers": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-mock": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/expect": { + "version": "29.7.0", + "resolved": "https://registry.npmmirror.com/@jest/expect/-/expect-29.7.0.tgz", + "integrity": "sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "expect": "^29.7.0", + "jest-snapshot": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/expect-utils": { + "version": "29.7.0", + "resolved": "https://registry.npmmirror.com/@jest/expect-utils/-/expect-utils-29.7.0.tgz", + "integrity": "sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-get-type": "^29.6.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/fake-timers": { + "version": "29.7.0", + "resolved": "https://registry.npmmirror.com/@jest/fake-timers/-/fake-timers-29.7.0.tgz", + "integrity": "sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@sinonjs/fake-timers": "^10.0.2", + "@types/node": "*", + "jest-message-util": "^29.7.0", + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/globals": { + "version": "29.7.0", + "resolved": "https://registry.npmmirror.com/@jest/globals/-/globals-29.7.0.tgz", + "integrity": "sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/expect": "^29.7.0", + "@jest/types": "^29.6.3", + "jest-mock": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/reporters": { + "version": "29.7.0", + "resolved": "https://registry.npmmirror.com/@jest/reporters/-/reporters-29.7.0.tgz", + "integrity": "sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^0.2.3", + "@jest/console": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@jridgewell/trace-mapping": "^0.3.18", + "@types/node": "*", + "chalk": "^4.0.0", + "collect-v8-coverage": "^1.0.0", + "exit": "^0.1.2", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "istanbul-lib-coverage": "^3.0.0", + "istanbul-lib-instrument": "^6.0.0", + "istanbul-lib-report": "^3.0.0", + "istanbul-lib-source-maps": "^4.0.0", + "istanbul-reports": "^3.1.3", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "jest-worker": "^29.7.0", + "slash": "^3.0.0", + "string-length": "^4.0.1", + "strip-ansi": "^6.0.0", + "v8-to-istanbul": "^9.0.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/reporters/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jest/reporters/node_modules/brace-expansion": { + "version": "1.1.15", + "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@jest/reporters/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmmirror.com/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@jest/reporters/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@jest/reporters/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/@jest/schemas": { "version": "29.6.3", "resolved": "https://registry.npmmirror.com/@jest/schemas/-/schemas-29.6.3.tgz", @@ -1961,6 +2551,80 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/@jest/source-map": { + "version": "29.6.3", + "resolved": "https://registry.npmmirror.com/@jest/source-map/-/source-map-29.6.3.tgz", + "integrity": "sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.18", + "callsites": "^3.0.0", + "graceful-fs": "^4.2.9" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/test-result": { + "version": "29.7.0", + "resolved": "https://registry.npmmirror.com/@jest/test-result/-/test-result-29.7.0.tgz", + "integrity": "sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "collect-v8-coverage": "^1.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/test-sequencer": { + "version": "29.7.0", + "resolved": "https://registry.npmmirror.com/@jest/test-sequencer/-/test-sequencer-29.7.0.tgz", + "integrity": "sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/test-result": "^29.7.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/transform": { + "version": "29.7.0", + "resolved": "https://registry.npmmirror.com/@jest/transform/-/transform-29.7.0.tgz", + "integrity": "sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.11.6", + "@jest/types": "^29.6.3", + "@jridgewell/trace-mapping": "^0.3.18", + "babel-plugin-istanbul": "^6.1.1", + "chalk": "^4.0.0", + "convert-source-map": "^2.0.0", + "fast-json-stable-stringify": "^2.1.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-util": "^29.7.0", + "micromatch": "^4.0.4", + "pirates": "^4.0.4", + "slash": "^3.0.0", + "write-file-atomic": "^4.0.2" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, "node_modules/@jest/types": { "version": "29.6.3", "resolved": "https://registry.npmmirror.com/@jest/types/-/types-29.6.3.tgz", @@ -3321,6 +3985,26 @@ "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", "license": "MIT" }, + "node_modules/@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "10.3.0", + "resolved": "https://registry.npmmirror.com/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz", + "integrity": "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.0" + } + }, "node_modules/@tanstack/query-core": { "version": "5.101.0", "resolved": "https://registry.npmmirror.com/@tanstack/query-core/-/query-core-5.101.0.tgz", @@ -3447,6 +4131,51 @@ "license": "MIT", "peer": true }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmmirror.com/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmmirror.com/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmmirror.com/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmmirror.com/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, "node_modules/@types/dom-mediacapture-record": { "version": "1.0.22", "resolved": "https://registry.npmmirror.com/@types/dom-mediacapture-record/-/dom-mediacapture-record-1.0.22.tgz", @@ -3460,6 +4189,16 @@ "integrity": "sha512-cMQm7pxu6BxtHyqJ7mQZ2kXWV5SLmugybFdHCBbJ5eHzOo6VhBckEgAT3//rP5FwPHNPeEiq4SmQ5ucBwsOo4Q==", "license": "MIT" }, + "node_modules/@types/graceful-fs": { + "version": "4.1.9", + "resolved": "https://registry.npmmirror.com/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", + "integrity": "sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/istanbul-lib-coverage": { "version": "2.0.6", "resolved": "https://registry.npmmirror.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", @@ -3484,6 +4223,17 @@ "@types/istanbul-lib-report": "*" } }, + "node_modules/@types/jest": { + "version": "29.5.14", + "resolved": "https://registry.npmmirror.com/@types/jest/-/jest-29.5.14.tgz", + "integrity": "sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "expect": "^29.0.0", + "pretty-format": "^29.0.0" + } + }, "node_modules/@types/node": { "version": "25.9.1", "resolved": "https://registry.npmmirror.com/@types/node/-/node-25.9.1.tgz", @@ -3539,6 +4289,13 @@ "@types/react": "*" } }, + "node_modules/@types/stack-utils": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/@types/stack-utils/-/stack-utils-2.0.3.tgz", + "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/yargs": { "version": "17.0.35", "resolved": "https://registry.npmmirror.com/@types/yargs/-/yargs-17.0.35.tgz", @@ -3703,6 +4460,33 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmmirror.com/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/appdirsjs": { "version": "1.2.7", "resolved": "https://registry.npmmirror.com/appdirsjs/-/appdirsjs-1.2.7.tgz", @@ -3891,6 +4675,78 @@ "proxy-from-env": "^2.1.0" } }, + "node_modules/babel-jest": { + "version": "29.7.0", + "resolved": "https://registry.npmmirror.com/babel-jest/-/babel-jest-29.7.0.tgz", + "integrity": "sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/transform": "^29.7.0", + "@types/babel__core": "^7.1.14", + "babel-plugin-istanbul": "^6.1.1", + "babel-preset-jest": "^29.6.3", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.8.0" + } + }, + "node_modules/babel-plugin-istanbul": { + "version": "6.1.1", + "resolved": "https://registry.npmmirror.com/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz", + "integrity": "sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@istanbuljs/load-nyc-config": "^1.0.0", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-instrument": "^5.0.4", + "test-exclude": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/babel-plugin-istanbul/node_modules/istanbul-lib-instrument": { + "version": "5.2.1", + "resolved": "https://registry.npmmirror.com/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.1.tgz", + "integrity": "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/core": "^7.12.3", + "@babel/parser": "^7.14.7", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^6.3.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/babel-plugin-jest-hoist": { + "version": "29.6.3", + "resolved": "https://registry.npmmirror.com/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-29.6.3.tgz", + "integrity": "sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.3.3", + "@babel/types": "^7.3.3", + "@types/babel__core": "^7.1.14", + "@types/babel__traverse": "^7.0.6" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, "node_modules/babel-plugin-polyfill-corejs2": { "version": "0.4.17", "resolved": "https://registry.npmmirror.com/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.17.tgz", @@ -3963,6 +4819,33 @@ "@babel/plugin-syntax-flow": "^7.12.1" } }, + "node_modules/babel-preset-current-node-syntax": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.2.0.tgz", + "integrity": "sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/plugin-syntax-async-generators": "^7.8.4", + "@babel/plugin-syntax-bigint": "^7.8.3", + "@babel/plugin-syntax-class-properties": "^7.12.13", + "@babel/plugin-syntax-class-static-block": "^7.14.5", + "@babel/plugin-syntax-import-attributes": "^7.24.7", + "@babel/plugin-syntax-import-meta": "^7.10.4", + "@babel/plugin-syntax-json-strings": "^7.8.3", + "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", + "@babel/plugin-syntax-numeric-separator": "^7.10.4", + "@babel/plugin-syntax-object-rest-spread": "^7.8.3", + "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", + "@babel/plugin-syntax-optional-chaining": "^7.8.3", + "@babel/plugin-syntax-private-property-in-object": "^7.14.5", + "@babel/plugin-syntax-top-level-await": "^7.14.5" + }, + "peerDependencies": { + "@babel/core": "^7.0.0 || ^8.0.0-0" + } + }, "node_modules/babel-preset-expo": { "version": "56.0.15", "resolved": "https://registry.npmmirror.com/babel-preset-expo/-/babel-preset-expo-56.0.15.tgz", @@ -4030,6 +4913,23 @@ } } }, + "node_modules/babel-preset-jest": { + "version": "29.6.3", + "resolved": "https://registry.npmmirror.com/babel-preset-jest/-/babel-preset-jest-29.6.3.tgz", + "integrity": "sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==", + "dev": true, + "license": "MIT", + "dependencies": { + "babel-plugin-jest-hoist": "^29.6.3", + "babel-preset-current-node-syntax": "^1.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, "node_modules/badgin": { "version": "1.2.3", "resolved": "https://registry.npmmirror.com/badgin/-/badgin-1.2.3.tgz", @@ -4210,6 +5110,19 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/bs-logger": { + "version": "0.2.6", + "resolved": "https://registry.npmmirror.com/bs-logger/-/bs-logger-0.2.6.tgz", + "integrity": "sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-json-stable-stringify": "2.x" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/bser": { "version": "2.1.1", "resolved": "https://registry.npmmirror.com/bser/-/bser-2.1.1.tgz", @@ -4363,6 +5276,16 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/char-regex": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/char-regex/-/char-regex-1.0.2.tgz", + "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/chrome-launcher": { "version": "0.15.2", "resolved": "https://registry.npmmirror.com/chrome-launcher/-/chrome-launcher-0.15.2.tgz", @@ -4424,6 +5347,13 @@ "integrity": "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==", "license": "MIT" }, + "node_modules/cjs-module-lexer": { + "version": "1.4.3", + "resolved": "https://registry.npmmirror.com/cjs-module-lexer/-/cjs-module-lexer-1.4.3.tgz", + "integrity": "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==", + "dev": true, + "license": "MIT" + }, "node_modules/cli-cursor": { "version": "3.1.0", "resolved": "https://registry.npmmirror.com/cli-cursor/-/cli-cursor-3.1.0.tgz", @@ -4499,6 +5429,24 @@ "node": ">=6" } }, + "node_modules/co": { + "version": "4.6.0", + "resolved": "https://registry.npmmirror.com/co/-/co-4.6.0.tgz", + "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">= 1.0.0", + "node": ">= 0.12.0" + } + }, + "node_modules/collect-v8-coverage": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/collect-v8-coverage/-/collect-v8-coverage-1.0.3.tgz", + "integrity": "sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw==", + "dev": true, + "license": "MIT" + }, "node_modules/color": { "version": "4.2.3", "resolved": "https://registry.npmmirror.com/color/-/color-4.2.3.tgz", @@ -4621,6 +5569,13 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmmirror.com/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, "node_modules/connect": { "version": "3.7.0", "resolved": "https://registry.npmmirror.com/connect/-/connect-3.7.0.tgz", @@ -4707,6 +5662,28 @@ } } }, + "node_modules/create-jest": { + "version": "29.7.0", + "resolved": "https://registry.npmmirror.com/create-jest/-/create-jest-29.7.0.tgz", + "integrity": "sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "exit": "^0.1.2", + "graceful-fs": "^4.2.9", + "jest-config": "^29.7.0", + "jest-util": "^29.7.0", + "prompts": "^2.0.1" + }, + "bin": { + "create-jest": "bin/create-jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, "node_modules/cross-fetch": { "version": "3.2.0", "resolved": "https://registry.npmmirror.com/cross-fetch/-/cross-fetch-3.2.0.tgz", @@ -4855,6 +5832,21 @@ "node": ">=0.10" } }, + "node_modules/dedent": { + "version": "1.7.2", + "resolved": "https://registry.npmmirror.com/dedent/-/dedent-1.7.2.tgz", + "integrity": "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "babel-plugin-macros": "^3.1.0" + }, + "peerDependenciesMeta": { + "babel-plugin-macros": { + "optional": true + } + } + }, "node_modules/deepmerge": { "version": "4.3.1", "resolved": "https://registry.npmmirror.com/deepmerge/-/deepmerge-4.3.1.tgz", @@ -4956,12 +5948,32 @@ "node": ">=8" } }, + "node_modules/detect-newline": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/detect-newline/-/detect-newline-3.1.0.tgz", + "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/detect-node-es": { "version": "1.1.0", "resolved": "https://registry.npmmirror.com/detect-node-es/-/detect-node-es-1.1.0.tgz", "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", "license": "MIT" }, + "node_modules/diff-sequences": { + "version": "29.6.3", + "resolved": "https://registry.npmmirror.com/diff-sequences/-/diff-sequences-29.6.3.tgz", + "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, "node_modules/dnssd-advertise": { "version": "1.1.6", "resolved": "https://registry.npmmirror.com/dnssd-advertise/-/dnssd-advertise-1.1.6.tgz", @@ -5001,6 +6013,19 @@ "integrity": "sha512-OlRuhb688YTCzzU3gXPLn6nGyd+F+53INE1qaKKlu6kETErE8FYsyDh0XqXEU+uBRn0MpCzz2vfNwORhkap8qg==", "license": "ISC" }, + "node_modules/emittery": { + "version": "0.13.1", + "resolved": "https://registry.npmmirror.com/emittery/-/emittery-0.13.1.tgz", + "integrity": "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sindresorhus/emittery?sponsor=1" + } + }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -5283,6 +6308,20 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmmirror.com/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/etag": { "version": "1.8.1", "resolved": "https://registry.npmmirror.com/etag/-/etag-1.8.1.tgz", @@ -5334,6 +6373,32 @@ "url": "https://github.com/sindresorhus/execa?sponsor=1" } }, + "node_modules/exit": { + "version": "0.1.2", + "resolved": "https://registry.npmmirror.com/exit/-/exit-0.1.2.tgz", + "integrity": "sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==", + "dev": true, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/expect": { + "version": "29.7.0", + "resolved": "https://registry.npmmirror.com/expect/-/expect-29.7.0.tgz", + "integrity": "sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/expect-utils": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, "node_modules/expo": { "version": "56.0.11", "resolved": "https://registry.npmmirror.com/expo/-/expo-56.0.11.tgz", @@ -6385,6 +7450,13 @@ "node": ">=8.6.0" } }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-xml-builder": { "version": "1.2.0", "resolved": "https://registry.npmmirror.com/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz", @@ -6679,6 +7751,28 @@ "node": ">=6 <7 || >=8" } }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz", @@ -6777,6 +7871,16 @@ "node": ">=6" } }, + "node_modules/get-package-type": { + "version": "0.1.0", + "resolved": "https://registry.npmmirror.com/get-package-type/-/get-package-type-0.1.0.tgz", + "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/get-proto": { "version": "1.0.1", "resolved": "https://registry.npmmirror.com/get-proto/-/get-proto-1.0.1.tgz", @@ -6893,6 +7997,38 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, + "node_modules/handlebars": { + "version": "4.7.9", + "resolved": "https://registry.npmmirror.com/handlebars/-/handlebars-4.7.9.tgz", + "integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.5", + "neo-async": "^2.6.2", + "source-map": "^0.6.1", + "wordwrap": "^1.0.0" + }, + "bin": { + "handlebars": "bin/handlebars" + }, + "engines": { + "node": ">=0.4.7" + }, + "optionalDependencies": { + "uglify-js": "^3.1.4" + } + }, + "node_modules/handlebars/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmmirror.com/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/has-bigints": { "version": "1.1.0", "resolved": "https://registry.npmmirror.com/has-bigints/-/has-bigints-1.1.0.tgz", @@ -7034,6 +8170,13 @@ "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", "license": "ISC" }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmmirror.com/http-errors/-/http-errors-2.0.1.tgz", @@ -7186,6 +8329,36 @@ "node": ">=4" } }, + "node_modules/import-local": { + "version": "3.2.0", + "resolved": "https://registry.npmmirror.com/import-local/-/import-local-3.2.0.tgz", + "integrity": "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pkg-dir": "^4.2.0", + "resolve-cwd": "^3.0.0" + }, + "bin": { + "import-local-fixture": "fixtures/cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmmirror.com/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, "node_modules/indent-string": { "version": "4.0.0", "resolved": "https://registry.npmmirror.com/indent-string/-/indent-string-4.0.0.tgz", @@ -7195,6 +8368,18 @@ "node": ">=8" } }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmmirror.com/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmmirror.com/inherits/-/inherits-2.0.4.tgz", @@ -7433,6 +8618,16 @@ "node": ">=4" } }, + "node_modules/is-generator-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/is-generator-fn/-/is-generator-fn-2.1.0.tgz", + "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/is-generator-function": { "version": "1.1.2", "resolved": "https://registry.npmmirror.com/is-generator-function/-/is-generator-function-1.1.2.tgz", @@ -7708,6 +8903,100 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "license": "ISC" }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmmirror.com/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-instrument": { + "version": "6.0.3", + "resolved": "https://registry.npmmirror.com/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", + "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/core": "^7.23.9", + "@babel/parser": "^7.23.9", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-instrument/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmmirror.com/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "4.0.1", + "resolved": "https://registry.npmmirror.com/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz", + "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmmirror.com/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmmirror.com/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/iterate-iterator": { "version": "1.0.2", "resolved": "https://registry.npmmirror.com/iterate-iterator/-/iterate-iterator-1.0.2.tgz", @@ -7739,6 +9028,293 @@ "react-native": ">= 0.60" } }, + "node_modules/jest": { + "version": "29.7.0", + "resolved": "https://registry.npmmirror.com/jest/-/jest-29.7.0.tgz", + "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/core": "^29.7.0", + "@jest/types": "^29.6.3", + "import-local": "^3.0.2", + "jest-cli": "^29.7.0" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-changed-files": { + "version": "29.7.0", + "resolved": "https://registry.npmmirror.com/jest-changed-files/-/jest-changed-files-29.7.0.tgz", + "integrity": "sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "execa": "^5.0.0", + "jest-util": "^29.7.0", + "p-limit": "^3.1.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-circus": { + "version": "29.7.0", + "resolved": "https://registry.npmmirror.com/jest-circus/-/jest-circus-29.7.0.tgz", + "integrity": "sha512-3E1nCMgipcTkCocFwM90XXQab9bS+GMsjdpmPrlelaxwD93Ad8iVEjX/vvHPdLPnFf+L40u+5+iutRdA1N9myw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/expect": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "co": "^4.6.0", + "dedent": "^1.0.0", + "is-generator-fn": "^2.0.0", + "jest-each": "^29.7.0", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "p-limit": "^3.1.0", + "pretty-format": "^29.7.0", + "pure-rand": "^6.0.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-cli": { + "version": "29.7.0", + "resolved": "https://registry.npmmirror.com/jest-cli/-/jest-cli-29.7.0.tgz", + "integrity": "sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/core": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "create-jest": "^29.7.0", + "exit": "^0.1.2", + "import-local": "^3.0.2", + "jest-config": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "yargs": "^17.3.1" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-config": { + "version": "29.7.0", + "resolved": "https://registry.npmmirror.com/jest-config/-/jest-config-29.7.0.tgz", + "integrity": "sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.11.6", + "@jest/test-sequencer": "^29.7.0", + "@jest/types": "^29.6.3", + "babel-jest": "^29.7.0", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "deepmerge": "^4.2.2", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "jest-circus": "^29.7.0", + "jest-environment-node": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-runner": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "micromatch": "^4.0.4", + "parse-json": "^5.2.0", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@types/node": "*", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/jest-config/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/jest-config/node_modules/brace-expansion": { + "version": "1.1.15", + "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/jest-config/node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmmirror.com/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-config/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmmirror.com/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/jest-config/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/jest-diff": { + "version": "29.7.0", + "resolved": "https://registry.npmmirror.com/jest-diff/-/jest-diff-29.7.0.tgz", + "integrity": "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "diff-sequences": "^29.6.3", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-docblock": { + "version": "29.7.0", + "resolved": "https://registry.npmmirror.com/jest-docblock/-/jest-docblock-29.7.0.tgz", + "integrity": "sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "detect-newline": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-each": { + "version": "29.7.0", + "resolved": "https://registry.npmmirror.com/jest-each/-/jest-each-29.7.0.tgz", + "integrity": "sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "jest-get-type": "^29.6.3", + "jest-util": "^29.7.0", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-environment-node": { + "version": "29.7.0", + "resolved": "https://registry.npmmirror.com/jest-environment-node/-/jest-environment-node-29.7.0.tgz", + "integrity": "sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/fake-timers": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, "node_modules/jest-get-type": { "version": "29.6.3", "resolved": "https://registry.npmmirror.com/jest-get-type/-/jest-get-type-29.6.3.tgz", @@ -7748,6 +9324,347 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/jest-haste-map": { + "version": "29.7.0", + "resolved": "https://registry.npmmirror.com/jest-haste-map/-/jest-haste-map-29.7.0.tgz", + "integrity": "sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/graceful-fs": "^4.1.3", + "@types/node": "*", + "anymatch": "^3.0.3", + "fb-watchman": "^2.0.0", + "graceful-fs": "^4.2.9", + "jest-regex-util": "^29.6.3", + "jest-util": "^29.7.0", + "jest-worker": "^29.7.0", + "micromatch": "^4.0.4", + "walker": "^1.0.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "optionalDependencies": { + "fsevents": "^2.3.2" + } + }, + "node_modules/jest-leak-detector": { + "version": "29.7.0", + "resolved": "https://registry.npmmirror.com/jest-leak-detector/-/jest-leak-detector-29.7.0.tgz", + "integrity": "sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-matcher-utils": { + "version": "29.7.0", + "resolved": "https://registry.npmmirror.com/jest-matcher-utils/-/jest-matcher-utils-29.7.0.tgz", + "integrity": "sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "jest-diff": "^29.7.0", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-message-util": { + "version": "29.7.0", + "resolved": "https://registry.npmmirror.com/jest-message-util/-/jest-message-util-29.7.0.tgz", + "integrity": "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.12.13", + "@jest/types": "^29.6.3", + "@types/stack-utils": "^2.0.0", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "micromatch": "^4.0.4", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-mock": { + "version": "29.7.0", + "resolved": "https://registry.npmmirror.com/jest-mock/-/jest-mock-29.7.0.tgz", + "integrity": "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-pnp-resolver": { + "version": "1.2.3", + "resolved": "https://registry.npmmirror.com/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", + "integrity": "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "peerDependencies": { + "jest-resolve": "*" + }, + "peerDependenciesMeta": { + "jest-resolve": { + "optional": true + } + } + }, + "node_modules/jest-regex-util": { + "version": "29.6.3", + "resolved": "https://registry.npmmirror.com/jest-regex-util/-/jest-regex-util-29.6.3.tgz", + "integrity": "sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-resolve": { + "version": "29.7.0", + "resolved": "https://registry.npmmirror.com/jest-resolve/-/jest-resolve-29.7.0.tgz", + "integrity": "sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-pnp-resolver": "^1.2.2", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "resolve": "^1.20.0", + "resolve.exports": "^2.0.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-resolve-dependencies": { + "version": "29.7.0", + "resolved": "https://registry.npmmirror.com/jest-resolve-dependencies/-/jest-resolve-dependencies-29.7.0.tgz", + "integrity": "sha512-un0zD/6qxJ+S0et7WxeI3H5XSe9lTBBR7bOHCHXkKR6luG5mwDDlIzVQ0V5cZCuoTgEdcdwzTghYkTWfubi+nA==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-regex-util": "^29.6.3", + "jest-snapshot": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-runner": { + "version": "29.7.0", + "resolved": "https://registry.npmmirror.com/jest-runner/-/jest-runner-29.7.0.tgz", + "integrity": "sha512-fsc4N6cPCAahybGBfTRcq5wFR6fpLznMg47sY5aDpsoejOcVYFb07AHuSnR0liMcPTgBsA3ZJL6kFOjPdoNipQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/environment": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "emittery": "^0.13.1", + "graceful-fs": "^4.2.9", + "jest-docblock": "^29.7.0", + "jest-environment-node": "^29.7.0", + "jest-haste-map": "^29.7.0", + "jest-leak-detector": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-resolve": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-util": "^29.7.0", + "jest-watcher": "^29.7.0", + "jest-worker": "^29.7.0", + "p-limit": "^3.1.0", + "source-map-support": "0.5.13" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-runner/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmmirror.com/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/jest-runner/node_modules/source-map-support": { + "version": "0.5.13", + "resolved": "https://registry.npmmirror.com/source-map-support/-/source-map-support-0.5.13.tgz", + "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/jest-runtime": { + "version": "29.7.0", + "resolved": "https://registry.npmmirror.com/jest-runtime/-/jest-runtime-29.7.0.tgz", + "integrity": "sha512-gUnLjgwdGqW7B4LvOIkbKs9WGbn+QLqRQQ9juC6HndeDiezIwhDP+mhMwHWCEcfQ5RUXa6OPnFF8BJh5xegwwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/fake-timers": "^29.7.0", + "@jest/globals": "^29.7.0", + "@jest/source-map": "^29.6.3", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "cjs-module-lexer": "^1.0.0", + "collect-v8-coverage": "^1.0.0", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-mock": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "slash": "^3.0.0", + "strip-bom": "^4.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-runtime/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/jest-runtime/node_modules/brace-expansion": { + "version": "1.1.15", + "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/jest-runtime/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmmirror.com/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/jest-runtime/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/jest-snapshot": { + "version": "29.7.0", + "resolved": "https://registry.npmmirror.com/jest-snapshot/-/jest-snapshot-29.7.0.tgz", + "integrity": "sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.11.6", + "@babel/generator": "^7.7.2", + "@babel/plugin-syntax-jsx": "^7.7.2", + "@babel/plugin-syntax-typescript": "^7.7.2", + "@babel/types": "^7.3.3", + "@jest/expect-utils": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "babel-preset-current-node-syntax": "^1.0.0", + "chalk": "^4.0.0", + "expect": "^29.7.0", + "graceful-fs": "^4.2.9", + "jest-diff": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "natural-compare": "^1.4.0", + "pretty-format": "^29.7.0", + "semver": "^7.5.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-snapshot/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmmirror.com/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/jest-util": { "version": "29.7.0", "resolved": "https://registry.npmmirror.com/jest-util/-/jest-util-29.7.0.tgz", @@ -7809,6 +9726,26 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/jest-watcher": { + "version": "29.7.0", + "resolved": "https://registry.npmmirror.com/jest-watcher/-/jest-watcher-29.7.0.tgz", + "integrity": "sha512-49Fg7WXkU3Vl2h6LbLtMQ/HyB6rXSIX7SqvBLQmssRBGN9I0PNvPmAmCWSOY6SOvrjhI/F7/bGAv9RtnsPA03g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "ansi-escapes": "^4.2.1", + "chalk": "^4.0.0", + "emittery": "^0.13.1", + "jest-util": "^29.7.0", + "string-length": "^4.0.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, "node_modules/jest-worker": { "version": "29.7.0", "resolved": "https://registry.npmmirror.com/jest-worker/-/jest-worker-29.7.0.tgz", @@ -8358,6 +10295,13 @@ "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", "license": "MIT" }, + "node_modules/lodash.memoize": { + "version": "4.1.2", + "resolved": "https://registry.npmmirror.com/lodash.memoize/-/lodash.memoize-4.1.2.tgz", + "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==", + "dev": true, + "license": "MIT" + }, "node_modules/lodash.throttle": { "version": "4.1.1", "resolved": "https://registry.npmmirror.com/lodash.throttle/-/lodash.throttle-4.1.1.tgz", @@ -8590,6 +10534,42 @@ "lz-string": "bin/bin.js" } }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmmirror.com/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmmirror.com/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true, + "license": "ISC" + }, "node_modules/makeerror": { "version": "1.0.12", "resolved": "https://registry.npmmirror.com/makeerror/-/makeerror-1.0.12.tgz", @@ -9160,6 +11140,16 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmmirror.com/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/minipass": { "version": "7.1.3", "resolved": "https://registry.npmmirror.com/minipass/-/minipass-7.1.3.tgz", @@ -9211,6 +11201,13 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmmirror.com/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, "node_modules/negotiator": { "version": "0.6.4", "resolved": "https://registry.npmmirror.com/negotiator/-/negotiator-0.6.4.tgz", @@ -9220,6 +11217,13 @@ "node": ">= 0.6" } }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmmirror.com/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "dev": true, + "license": "MIT" + }, "node_modules/nocache": { "version": "3.0.4", "resolved": "https://registry.npmmirror.com/nocache/-/nocache-3.0.4.tgz", @@ -9288,6 +11292,16 @@ "url": "https://github.com/sponsors/antelle" } }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/npm-package-arg": { "version": "11.0.3", "resolved": "https://registry.npmmirror.com/npm-package-arg/-/npm-package-arg-11.0.3.tgz", @@ -9417,6 +11431,16 @@ "node": ">= 0.8" } }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmmirror.com/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, "node_modules/onetime": { "version": "5.1.2", "resolved": "https://registry.npmmirror.com/onetime/-/onetime-5.1.2.tgz", @@ -9627,6 +11651,16 @@ "node": ">=14.0.0" } }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmmirror.com/path-key/-/path-key-3.1.1.tgz", @@ -9685,6 +11719,85 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmmirror.com/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmmirror.com/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pkg-dir/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/plist": { "version": "3.1.1", "resolved": "https://registry.npmmirror.com/plist/-/plist-3.1.1.tgz", @@ -9888,6 +12001,23 @@ "node": ">=6" } }, + "node_modules/pure-rand": { + "version": "6.1.0", + "resolved": "https://registry.npmmirror.com/pure-rand/-/pure-rand-6.1.0.tgz", + "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, "node_modules/qs": { "version": "6.15.2", "resolved": "https://registry.npmmirror.com/qs/-/qs-6.15.2.tgz", @@ -10654,6 +12784,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/resolve-cwd": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/resolve-cwd/-/resolve-cwd-3.0.0.tgz", + "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/resolve-from": { "version": "5.0.0", "resolved": "https://registry.npmmirror.com/resolve-from/-/resolve-from-5.0.0.tgz", @@ -10669,6 +12812,16 @@ "integrity": "sha512-nR23LHAvaI6aHtMg6RWoaHpdR4D881Nydkzi2CixINyg9T00KgaJdJI6Vwty+Ps8WLxZHuxsS0BseWjxSA4C+w==", "license": "MIT" }, + "node_modules/resolve.exports": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/resolve.exports/-/resolve.exports-2.0.3.tgz", + "integrity": "sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/restore-cursor": { "version": "3.1.0", "resolved": "https://registry.npmmirror.com/restore-cursor/-/restore-cursor-3.1.0.tgz", @@ -11176,6 +13329,16 @@ "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", "license": "MIT" }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/slice-ansi": { "version": "2.1.0", "resolved": "https://registry.npmmirror.com/slice-ansi/-/slice-ansi-2.1.0.tgz", @@ -11276,6 +13439,36 @@ "node": ">=6" } }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/stack-utils": { + "version": "2.0.6", + "resolved": "https://registry.npmmirror.com/stack-utils/-/stack-utils-2.0.6.tgz", + "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/stack-utils/node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/stackframe": { "version": "1.3.4", "resolved": "https://registry.npmmirror.com/stackframe/-/stackframe-1.3.4.tgz", @@ -11357,6 +13550,33 @@ "safe-buffer": "~5.2.0" } }, + "node_modules/string-length": { + "version": "4.0.2", + "resolved": "https://registry.npmmirror.com/string-length/-/string-length-4.0.2.tgz", + "integrity": "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "char-regex": "^1.0.2", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/string-length/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmmirror.com/string-width/-/string-width-4.2.3.tgz", @@ -11469,6 +13689,16 @@ "node": ">=6" } }, + "node_modules/strip-bom": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/strip-bom/-/strip-bom-4.0.0.tgz", + "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/strip-final-newline": { "version": "2.0.0", "resolved": "https://registry.npmmirror.com/strip-final-newline/-/strip-final-newline-2.0.0.tgz", @@ -11491,6 +13721,19 @@ "node": ">=8" } }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/strnum": { "version": "2.3.0", "resolved": "https://registry.npmmirror.com/strnum/-/strnum-2.3.0.tgz", @@ -11605,6 +13848,74 @@ "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", "license": "MIT" }, + "node_modules/test-exclude": { + "version": "6.0.0", + "resolved": "https://registry.npmmirror.com/test-exclude/-/test-exclude-6.0.0.tgz", + "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^7.1.4", + "minimatch": "^3.0.4" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/test-exclude/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/test-exclude/node_modules/brace-expansion": { + "version": "1.1.15", + "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/test-exclude/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmmirror.com/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/test-exclude/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/throat": { "version": "5.0.0", "resolved": "https://registry.npmmirror.com/throat/-/throat-5.0.0.tgz", @@ -11666,12 +13977,101 @@ "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", "license": "MIT" }, + "node_modules/ts-jest": { + "version": "29.4.11", + "resolved": "https://registry.npmmirror.com/ts-jest/-/ts-jest-29.4.11.tgz", + "integrity": "sha512-IrFl7l9AuB/qrNw5quqvAv/hmKMb8dhWOH4jQOGo0Oq8tCeo1O86/iTFG1FaRimgUkF13l4PcepO8ATFT6Ns4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "bs-logger": "^0.2.6", + "fast-json-stable-stringify": "^2.1.0", + "handlebars": "^4.7.9", + "json5": "^2.2.3", + "lodash.memoize": "^4.1.2", + "make-error": "^1.3.6", + "semver": "^7.8.0", + "type-fest": "^4.41.0", + "yargs-parser": "^21.1.1" + }, + "bin": { + "ts-jest": "cli.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "@babel/core": ">=7.0.0-beta.0 <8", + "@jest/transform": "^29.0.0 || ^30.0.0", + "@jest/types": "^29.0.0 || ^30.0.0", + "babel-jest": "^29.0.0 || ^30.0.0", + "jest": "^29.0.0 || ^30.0.0", + "jest-util": "^29.0.0 || ^30.0.0", + "typescript": ">=4.3 <7" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "@jest/transform": { + "optional": true + }, + "@jest/types": { + "optional": true + }, + "babel-jest": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jest-util": { + "optional": true + } + } + }, + "node_modules/ts-jest/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmmirror.com/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/ts-jest/node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmmirror.com/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmmirror.com/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/type-fest": { "version": "0.7.1", "resolved": "https://registry.npmmirror.com/type-fest/-/type-fest-0.7.1.tgz", @@ -11860,6 +14260,20 @@ "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", "license": "MIT" }, + "node_modules/uglify-js": { + "version": "3.19.3", + "resolved": "https://registry.npmmirror.com/uglify-js/-/uglify-js-3.19.3.tgz", + "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", + "dev": true, + "license": "BSD-2-Clause", + "optional": true, + "bin": { + "uglifyjs": "bin/uglifyjs" + }, + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/unbox-primitive": { "version": "1.1.0", "resolved": "https://registry.npmmirror.com/unbox-primitive/-/unbox-primitive-1.1.0.tgz", @@ -12071,6 +14485,21 @@ "uuid": "dist/bin/uuid" } }, + "node_modules/v8-to-istanbul": { + "version": "9.3.0", + "resolved": "https://registry.npmmirror.com/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", + "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==", + "dev": true, + "license": "ISC", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.12", + "@types/istanbul-lib-coverage": "^2.0.1", + "convert-source-map": "^2.0.0" + }, + "engines": { + "node": ">=10.12.0" + } + }, "node_modules/validate-npm-package-name": { "version": "5.0.1", "resolved": "https://registry.npmmirror.com/validate-npm-package-name/-/validate-npm-package-name-5.0.1.tgz", @@ -12330,6 +14759,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", + "dev": true, + "license": "MIT" + }, "node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz", @@ -12359,6 +14795,27 @@ "node": ">=8" } }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/write-file-atomic": { + "version": "4.0.2", + "resolved": "https://registry.npmmirror.com/write-file-atomic/-/write-file-atomic-4.0.2.tgz", + "integrity": "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==", + "dev": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4", + "signal-exit": "^3.0.7" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, "node_modules/ws": { "version": "6.2.4", "resolved": "https://registry.npmmirror.com/ws/-/ws-6.2.4.tgz", diff --git a/package.json b/package.json index 530bb9f..d04aadc 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,9 @@ "ios:simulator": "eas build --platform ios --profile ios-simulator", "ios:preview": "eas build --platform ios --profile preview", "ios:prod": "eas build --platform ios --profile production", - "ios:submit": "eas submit --platform ios --profile production" + "ios:submit": "eas submit --platform ios --profile production", + "test": "jest", + "test:watch": "jest --watch" }, "dependencies": { "@expo/ui": "~56.0.17", @@ -75,9 +77,12 @@ }, "devDependencies": { "@react-native-community/cli": "^20.1.3", + "@types/jest": "^29.5.12", "@types/pako": "^2.0.4", "@types/react": "~19.2.16", "@types/react-native-vector-icons": "^6.4.18", + "jest": "^29.7.0", + "ts-jest": "^29.1.2", "typescript": "~6.0.3" }, "private": true, diff --git a/src/components/business/SearchHeader.tsx b/src/components/business/SearchHeader.tsx new file mode 100644 index 0000000..462c0e2 --- /dev/null +++ b/src/components/business/SearchHeader.tsx @@ -0,0 +1,115 @@ +/** + * SearchHeader 搜索顶栏组件 + * 统一的「搜索框 + 取消按钮」顶栏,用于内嵌式搜索页面。 + * + * - 内置 Android 硬件返回键拦截:存在 onCancel 时,按下返回键调用 onCancel + * 而非退出 App(解决 Tab 根页面内嵌搜索时返回键直接退出的问题)。 + * - 样式与 HomeScreen 搜索页保持一致。 + */ + +import React, { useEffect, useMemo } from 'react'; +import { View, TouchableOpacity, StyleSheet, BackHandler, StyleProp, ViewStyle } from 'react-native'; +import { spacing, fontSizes, borderRadius, useAppColors, type AppColors } from '../../theme'; +import { Text } from '../common'; +import SearchBar from './SearchBar'; + +interface SearchHeaderProps { + value: string; + onChangeText: (text: string) => void; + onSubmit: () => void; + /** 取消回调:同时用于取消按钮与硬件返回键。必传以启用内嵌模式返回拦截。 */ + onCancel: () => void; + placeholder?: string; + /** 透传给 SearchBar 的 compact 模式 */ + compact?: boolean; + /** 搜索框最大宽度(宽屏限宽) */ + searchBarMaxWidth?: number; + /** 水平外边距,默认 spacing.md */ + horizontalPadding?: number; + /** 顶部内边距,默认 spacing.sm */ + paddingTop?: number; + style?: StyleProp; +} + +function createSearchHeaderStyles(colors: AppColors) { + return StyleSheet.create({ + container: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: colors.background.default, + }, + searchShell: { + flex: 1, + }, + cancelButton: { + borderRadius: borderRadius.full, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + }, + cancelText: { + fontSize: fontSizes.md, + fontWeight: '600', + }, + }); +} + +const SearchHeader: React.FC = ({ + value, + onChangeText, + onSubmit, + onCancel, + placeholder, + compact = false, + searchBarMaxWidth, + horizontalPadding = spacing.md, + paddingTop = spacing.sm, + style, +}) => { + const colors = useAppColors(); + const styles = useMemo(() => createSearchHeaderStyles(colors), [colors]); + + // 内嵌模式:拦截 Android 物理返回键,调用 onCancel 而非退出 App + useEffect(() => { + const backHandler = BackHandler.addEventListener('hardwareBackPress', () => { + onCancel(); + return true; // 阻止默认行为(退出 App) + }); + return () => backHandler.remove(); + }, [onCancel]); + + return ( + + + + + + + 取消 + + + + ); +}; + +export default SearchHeader; diff --git a/src/components/business/TabBar.tsx b/src/components/business/TabBar.tsx index 7cad28f..5bf396e 100644 --- a/src/components/business/TabBar.tsx +++ b/src/components/business/TabBar.tsx @@ -10,7 +10,7 @@ import { MaterialCommunityIcons } from '@expo/vector-icons'; import { spacing, fontSizes, borderRadius, useAppColors, type AppColors } from '../../theme'; import Text from '../common/Text'; -type TabBarVariant = 'default' | 'pill' | 'segmented' | 'modern'; +type TabBarVariant = 'default' | 'pill' | 'segmented' | 'modern' | 'home'; interface TabBarProps { tabs: string[]; @@ -20,6 +20,8 @@ interface TabBarProps { rightContent?: ReactNode; variant?: TabBarVariant; icons?: string[]; + /** home 变体的字号,默认 18(HomeScreen 可传 20) */ + fontSize?: number; style?: StyleProp; } @@ -176,6 +178,33 @@ function createTabBarStyles(colors: AppColors) { borderTopLeftRadius: borderRadius.sm, borderTopRightRadius: borderRadius.sm, }, + // home 变体:与 HomeScreen/SearchScreen 一致的文字下划线风格 + homeContainer: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: 'transparent', + }, + homeTab: { + alignItems: 'center', + justifyContent: 'center', + paddingVertical: spacing.sm, + marginRight: 20, + borderBottomWidth: 2, + borderBottomColor: 'transparent', + }, + homeTabActive: { + borderBottomColor: colors.text.primary, + }, + homeTabText: { + fontSize: fontSizes.xl, + fontWeight: '400', + color: colors.text.hint, + }, + homeTabTextActive: { + fontSize: fontSizes.xl, + fontWeight: '700', + color: colors.text.primary, + }, }); } @@ -187,6 +216,7 @@ const TabBar: React.FC = ({ rightContent, variant = 'default', icons, + fontSize, style, }) => { const colors = useAppColors(); @@ -278,6 +308,29 @@ const TabBar: React.FC = ({ ); } + if (variant === 'home') { + const homeTextStyle = [ + isActive ? styles.homeTabTextActive : styles.homeTabText, + fontSize != null ? { fontSize } : undefined, + ]; + return ( + onTabChange(index)} + activeOpacity={0.7} + > + + {tab} + + + ); + } + return ( = ({ return styles.segmentedContainer; case 'modern': return styles.modernContainer; + case 'home': + return styles.homeContainer; default: return styles.container; } diff --git a/src/components/business/index.ts b/src/components/business/index.ts index 134f99c..ce0359c 100644 --- a/src/components/business/index.ts +++ b/src/components/business/index.ts @@ -18,6 +18,7 @@ export { default as CommentItem } from './CommentItem'; export { default as UserProfileHeader } from './UserProfileHeader'; export { default as SystemMessageItem } from './SystemMessageItem'; export { default as SearchBar } from './SearchBar'; +export { default as SearchHeader } from './SearchHeader'; export { default as TabBar } from './TabBar'; export { default as VoteCard } from './VoteCard'; export { default as VoteEditor } from './VoteEditor'; diff --git a/src/core/events/EventBus.ts b/src/core/events/EventBus.ts index f151f33..3298efb 100644 --- a/src/core/events/EventBus.ts +++ b/src/core/events/EventBus.ts @@ -5,9 +5,7 @@ export type AppEvent = | { type: 'NAVIGATE_BACK' } | { type: 'SHOW_VERIFICATION_MODAL' } | { type: 'VIBRATE'; payload: { type: 'message' | 'group_message' | 'call' } } - | { type: 'SHOW_TOAST'; payload: { message: string; type: 'success' | 'error' | 'info' } } - | { type: 'AUTH_LOGOUT' } - | { type: 'AUTH_LOGIN'; payload: { userId: string } }; + | { type: 'AUTH_LOGOUT' }; class EventBusClass { private subscribers: Set> = new Set(); diff --git a/src/data/datasources/WSClient.ts b/src/data/datasources/WSClient.ts deleted file mode 100644 index fa23531..0000000 --- a/src/data/datasources/WSClient.ts +++ /dev/null @@ -1,184 +0,0 @@ -/** - * WSClient - WebSocket连接管理 - * 只负责WebSocket连接管理,提供事件驱动架构 - */ - -import { - wsService, - WSChatMessage, - WSGroupChatMessage, - WSReadMessage, - WSGroupReadMessage, - WSRecallMessage, - WSGroupRecallMessage, - WSGroupTypingMessage, - WSGroupNoticeMessage, - WSMessageType, -} from '@/services/core'; - -// 事件处理器类型 -type EventHandler = (data: T) => void; - -// WS事件类型 -export interface WSEvents { - 'chat': WSChatMessage; - 'group_message': WSGroupChatMessage; - 'read': WSReadMessage; - 'group_read': WSGroupReadMessage; - 'recall': WSRecallMessage; - 'group_recall': WSGroupRecallMessage; - 'group_typing': WSGroupTypingMessage; - 'group_notice': WSGroupNoticeMessage; - 'connected': void; - 'disconnected': void; - 'error': Error; -} - -export type WSEventType = keyof WSEvents; - -class WSClient { - private handlers: Map>> = new Map(); - private unsubscribeFns: Array<() => void> = []; - private isInitialized = false; - - /** - * 初始化WebSocket监听 - */ - initialize(): void { - if (this.isInitialized) return; - - // 监听私聊消息 - const unsubChat = wsService.on('chat', (message) => { - this.emit('chat', message); - }); - - // 监听群聊消息 - const unsubGroupMessage = wsService.on('group_message', (message) => { - this.emit('group_message', message); - }); - - // 监听私聊已读回执 - const unsubRead = wsService.on('read', (message) => { - this.emit('read', message); - }); - - // 监听群聊已读回执 - const unsubGroupRead = wsService.on('group_read', (message) => { - this.emit('group_read', message); - }); - - // 监听私聊消息撤回 - const unsubRecall = wsService.on('recall', (message) => { - this.emit('recall', message); - }); - - // 监听群聊消息撤回 - const unsubGroupRecall = wsService.on('group_recall', (message) => { - this.emit('group_recall', message); - }); - - // 监听群聊输入状态 - const unsubGroupTyping = wsService.on('group_typing', (message) => { - this.emit('group_typing', message); - }); - - // 监听群通知 - const unsubGroupNotice = wsService.on('group_notice', (message) => { - this.emit('group_notice', message); - }); - - // 监听连接状态 - const unsubConnect = wsService.onConnect(() => { - this.emit('connected', undefined); - }); - - const unsubDisconnect = wsService.onDisconnect(() => { - this.emit('disconnected', undefined); - }); - - this.unsubscribeFns = [ - unsubChat, - unsubGroupMessage, - unsubRead, - unsubGroupRead, - unsubRecall, - unsubGroupRecall, - unsubGroupTyping, - unsubGroupNotice, - unsubConnect, - unsubDisconnect, - ]; - - this.isInitialized = true; - } - - /** - * 销毁WebSocket监听 - */ - destroy(): void { - this.unsubscribeFns.forEach((fn) => fn()); - this.unsubscribeFns = []; - this.handlers.clear(); - this.isInitialized = false; - } - - /** - * 订阅事件 - */ - on( - event: T, - handler: EventHandler - ): () => void { - if (!this.handlers.has(event)) { - this.handlers.set(event, new Set()); - } - this.handlers.get(event)!.add(handler); - - return () => { - this.handlers.get(event)?.delete(handler); - }; - } - - /** - * 触发事件 - */ - private emit( - event: T, - data: WSEvents[T] - ): void { - const handlers = this.handlers.get(event); - if (handlers) { - handlers.forEach((handler) => { - try { - handler(data); - } catch (error) { - console.error(`[WSClient] 事件处理器执行失败: ${event}`, error); - } - }); - } - } - - /** - * 检查连接状态 - */ - isConnected(): boolean { - return wsService.isConnected(); - } - - /** - * 启动WebSocket连接 - */ - async connect(): Promise { - return wsService.start(); - } - - /** - * 断开WebSocket连接 - */ - disconnect(): void { - wsService.stop(); - } -} - -export const wsClient = new WSClient(); -export default wsClient; \ No newline at end of file diff --git a/src/screens/home/SearchScreen.tsx b/src/screens/home/SearchScreen.tsx index 4b14a05..49d112f 100644 --- a/src/screens/home/SearchScreen.tsx +++ b/src/screens/home/SearchScreen.tsx @@ -24,7 +24,7 @@ import { useUserStore } from '../../stores'; import { postService, authService } from '../../services'; import { tradeService } from '../../services/trade/tradeService'; import { postSyncService } from '@/services/post'; -import { PostCard, SearchBar } from '../../components/business'; +import { PostCard, SearchHeader, TabBar } from '../../components/business'; import { TradeCard } from '../../components/business/TradeCard/TradeCard'; import type { PostCardAction } from '../../components/business/PostCard'; import { Avatar, EmptyState, Text, ResponsiveGrid, Loading } from '../../components/common'; @@ -601,66 +601,32 @@ export const SearchScreen: React.FC = ({ onBack, homeTab = 's return ( - {/* 搜索输入框 */} - - - - - (onBack ? onBack() : router.back())} - activeOpacity={0.85} - > - - 取消 - - - + {/* 搜索顶栏(统一组件,内置硬件返回键拦截) */} + router.back()} + placeholder={isMarket ? "搜索商品、用户" : "搜索帖子、用户"} + compact + searchBarMaxWidth={searchBarMaxWidth} + horizontalPadding={responsivePadding} + /> {/* Tab切换 - 与主页风格一致的下划线样式 */} - - {TABS.map((tab, index) => { - const isActive = activeIndex === index; - return ( - { - setActiveIndex(index); - if (currentKeyword && hasSearched) { - performSearch(currentKeyword); - } - }} - > - - {tab} - - - ); - })} - + { + setActiveIndex(index); + if (currentKeyword && hasSearched) { + performSearch(currentKeyword); + } + }} + variant="home" + style={{ paddingHorizontal: responsivePadding }} + /> {/* 内容区域 */} @@ -675,52 +641,10 @@ function createSearchScreenStyles(colors: AppColors) { flex: 1, backgroundColor: colors.background.default, }, - searchHeader: { - flexDirection: 'row', - alignItems: 'center', - backgroundColor: colors.background.default, - }, - searchShell: { - flex: 1, - }, - cancelButton: { - borderRadius: borderRadius.full, - paddingHorizontal: spacing.md, - paddingVertical: spacing.sm, - }, - cancelText: { - fontSize: fontSizes.md, - fontWeight: '600', - }, tabWrapper: { backgroundColor: colors.background.default, paddingVertical: spacing.xs, }, - homeTabSwitcher: { - flexDirection: 'row', - alignItems: 'center', - gap: 20, - }, - homeTabItem: { - paddingVertical: spacing.sm, - alignItems: 'center', - justifyContent: 'center', - borderBottomWidth: 2, - borderBottomColor: 'transparent', - }, - homeTabItemActive: { - borderBottomColor: colors.text.primary, - }, - homeTabText: { - fontSize: 18, - fontWeight: '400', - color: colors.text.hint, - }, - homeTabTextActive: { - fontSize: 18, - fontWeight: '700', - color: colors.text.primary, - }, suggestionsContainer: { flex: 1, }, diff --git a/src/screens/message/ChatScreen.tsx b/src/screens/message/ChatScreen.tsx index 8bcfeb2..ba34561 100644 --- a/src/screens/message/ChatScreen.tsx +++ b/src/screens/message/ChatScreen.tsx @@ -214,6 +214,7 @@ export const ChatScreen: React.FC = (props) => { handleReachLatestEdge, jumpToLatestMessages, setBrowsingHistory, + clearHistoryLock, } = useChatScreen(props); const stableOnBack = useCallback(() => router.back(), [router]); @@ -412,15 +413,12 @@ export const ChatScreen: React.FC = (props) => { setBrowsingHistory(true); } - // 仅用户手势主动回到底部时才解除历史阅读锁(避免加载阶段误解锁) - const isScrollingTowardLatest = contentOffset.y < lastScrollYRef.current; - if ( - isUserDraggingRef.current && - !loadingMore && - isScrollingTowardLatest && - contentOffset.y <= 40 - ) { - handleReachLatestEdge(); + // 接近最新消息端时退出历史阅读模式 + // 修复:不再要求 isUserDraggingRef — 动量滚动结束后、layout settle 后、 + // 或用户轻触回到底部时都能正确解锁,防止浏览历史锁永久为 true + // 导致新消息不自动跟随(仅自己发的才显示的问题) + if (!loadingMore && contentOffset.y <= 100) { + clearHistoryLock(); } // inverted 下历史端在“列表尾部”,对应 offset 增大且距离尾部变小 @@ -460,14 +458,18 @@ export const ChatScreen: React.FC = (props) => { loading, messages.length, loadMoreHistory, - handleReachLatestEdge, showEdgeLoadingIndicator, setBrowsingHistory, ]); const handleContentSizeChange = useCallback((contentWidth: number, contentHeight: number) => { handleMessageListContentSizeChange(contentWidth, contentHeight); - }, [handleMessageListContentSizeChange]); + // 安全检查:loadMoreHistory 完成后,如果用户仍在底部附近,清除浏览历史锁 + // 防止 layout settle 后没有触发滚动事件导致锁无法释放 + if (!loadingMore && scrollPositionRef.current.scrollY <= 100) { + clearHistoryLock(); + } + }, [handleMessageListContentSizeChange, loadingMore, clearHistoryLock]); return ( diff --git a/src/screens/message/MessageListScreen.tsx b/src/screens/message/MessageListScreen.tsx index 36447e6..4542d8c 100644 --- a/src/screens/message/MessageListScreen.tsx +++ b/src/screens/message/MessageListScreen.tsx @@ -61,7 +61,7 @@ import { ConversationListRow } from './components/ConversationListRow'; // 导入 NotificationsScreen 用于在内部显示 import { NotificationsScreen } from './NotificationsScreen'; // 导入扫码组件 -import { SearchBar, TabBar } from '../../components/business'; +import { SearchBar, SearchHeader, TabBar } from '../../components/business'; import { QRCodeScanner } from '../../components/business/QRCodeScanner'; // 系统通知会话特殊ID @@ -668,33 +668,27 @@ export const MessageListScreen: React.FC = () => { // 渲染搜索模式UI(扁平化现代化设计) const renderSearchMode = () => ( - {/* 搜索头部 */} - - - performSearch(searchText)} - placeholder={activeSearchTab === 'chat' ? '搜索聊天记录' : '搜索用户'} - autoFocus - /> - - - 取消 - - + {/* 搜索顶栏(统一组件,内置硬件返回键拦截) */} + performSearch(searchText)} + onCancel={handleCloseSearch} + placeholder={activeSearchTab === 'chat' ? '搜索聊天记录' : '搜索用户'} + compact + /> - {/* Tab切换 - 现代化风格 */} + {/* Tab切换 - 与主页一致的下划线风格 */} setActiveSearchTab(index === 0 ? 'chat' : 'user')} - variant="modern" + variant="home" + style={{ + paddingHorizontal: spacing.md, + paddingVertical: spacing.xs, + }} /> @@ -1048,32 +1042,10 @@ function createMessageListStyles(colors: AppColors) { }, searchModeContainer: { flex: 1, - backgroundColor: colors.background.paper, - }, - searchHeader: { - flexDirection: 'row', - alignItems: 'center', - backgroundColor: colors.background.paper, - borderBottomWidth: 1, - borderBottomColor: `${colors.divider}70`, - paddingHorizontal: spacing.md, - paddingBottom: spacing.sm, - }, - searchShell: { - flex: 1, - }, - cancelButton: { - paddingHorizontal: spacing.sm, - paddingVertical: spacing.xs, - marginLeft: spacing.sm, - }, - cancelText: { - fontSize: fontSizes.md, - fontWeight: '600', - color: colors.primary.main, + backgroundColor: colors.background.default, }, searchTabContainer: { - backgroundColor: colors.background.paper, + backgroundColor: colors.background.default, borderBottomWidth: 1, borderBottomColor: `${colors.divider}50`, }, diff --git a/src/screens/message/MessageSearchScreen.tsx b/src/screens/message/MessageSearchScreen.tsx index 6303244..f2cce52 100644 --- a/src/screens/message/MessageSearchScreen.tsx +++ b/src/screens/message/MessageSearchScreen.tsx @@ -17,7 +17,6 @@ import { MaterialCommunityIcons } from '@expo/vector-icons'; import { spacing, fontSizes, - borderRadius, useAppColors, type AppColors, } from '../../theme'; @@ -26,8 +25,8 @@ import { extractTextFromSegments, UserDTO } from '../../types/dto'; import { messageRepository } from '../../database'; import { userManager } from '../../stores/user'; import { useAuthStore } from '../../stores'; -import { Avatar, Text, EmptyState, AppBackButton } from '../../components/common'; -import { SearchBar } from '../../components/business'; +import { Avatar, Text, EmptyState } from '../../components/common'; +import { SearchHeader } from '../../components/business'; import HighlightText from '../../components/common/HighlightText'; import { formatTime } from '../../utils/formatTime'; import * as hrefs from '../../navigation/hrefs'; @@ -233,24 +232,16 @@ export const MessageSearchScreen: React.FC = () => { }, [loading, results.length, styles, colors]); return ( - - - router.back()} /> - - {conversationName} - - - - - - - + + {/* 搜索顶栏(与 HomeScreen 搜索页一致的统一组件) */} + router.back()} + placeholder={`搜索 ${conversationName} 的聊天记录`} + compact + /> { isBrowsingHistoryRef.current = browsing; }, []); + /** + * 无条件清除浏览历史锁与自动跟随抑制(动量滚动结束、layout settle、 + * 轻触回到底部等场景使用)。区别于 handleReachLatestEdge,后者受加载锁保护。 + */ + const clearHistoryLock = useCallback(() => { + if (isBrowsingHistoryRef.current || suppressAutoFollowRef.current) { + isBrowsingHistoryRef.current = false; + suppressAutoFollowRef.current = false; + } + }, []); + // 进入聊天详情后立即清未读(不依赖滚动位置) useEffect(() => { if (!conversationId) return; @@ -1653,5 +1664,6 @@ export const useChatScreen = (props?: ChatScreenProps) => { handleReachLatestEdge, jumpToLatestMessages, setBrowsingHistory, + clearHistoryLock, }; }; diff --git a/src/screens/profile/FollowListScreen.tsx b/src/screens/profile/FollowListScreen.tsx index 253cbeb..b38d910 100644 --- a/src/screens/profile/FollowListScreen.tsx +++ b/src/screens/profile/FollowListScreen.tsx @@ -5,7 +5,7 @@ * 在宽屏下使用网格布局 */ -import React, { useState, useEffect, useCallback, useMemo } from 'react'; +import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { View, FlatList, @@ -22,6 +22,7 @@ import { User } from '../../types'; import { useAuthStore, useUserStore } from '../../stores'; import { authService } from '../../services'; import { Avatar, Text, Button, Loading, EmptyState, ResponsiveContainer } from '../../components/common'; +import { SearchBar } from '../../components/business'; import * as hrefs from '../../navigation/hrefs'; import { useResponsive, useColumnCount } from '../../hooks'; @@ -53,9 +54,13 @@ const FollowListScreen: React.FC = () => { const [refreshing, setRefreshing] = useState(false); const [page, setPage] = useState(1); const [hasMore, setHasMore] = useState(true); + const [keyword, setKeyword] = useState(''); + const [debouncedKeyword, setDebouncedKeyword] = useState(''); + const debounceRef = useRef | null>(null); const isCurrentUser = currentUser?.id === userId; const title = type === 'following' ? '关注' : '粉丝'; + const isSearching = debouncedKeyword.trim().length > 0; // 加载用户列表 const loadUsers = useCallback(async (pageNum: number = 1, refresh: boolean = false) => { @@ -64,11 +69,12 @@ const FollowListScreen: React.FC = () => { try { const pageSize = 20; let userList: User[] = []; + const trimmedKeyword = debouncedKeyword.trim(); if (type === 'following') { - userList = await authService.getFollowingList(userId, pageNum, pageSize); + userList = await authService.getFollowingList(userId, pageNum, pageSize, trimmedKeyword); } else { - userList = await authService.getFollowersList(userId, pageNum, pageSize); + userList = await authService.getFollowersList(userId, pageNum, pageSize, trimmedKeyword); } if (refresh) { @@ -84,12 +90,33 @@ const FollowListScreen: React.FC = () => { } setLoading(false); setRefreshing(false); - }, [userId, type, hasMore]); + }, [userId, type, hasMore, debouncedKeyword]); - // 初始加载 + // 初始加载(包括 type/userId 切换以及防抖后关键词变化) useEffect(() => { + setLoading(true); + setHasMore(true); loadUsers(1, true); - }, [userId, type]); + // 仅在 userId/type/debouncedKeyword 变化时重新加载 + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [userId, type, debouncedKeyword]); + + // 关键词输入防抖(300ms) + useEffect(() => { + if (debounceRef.current) { + clearTimeout(debounceRef.current); + } + debounceRef.current = setTimeout(() => { + setDebouncedKeyword(keyword); + }, 300); + + return () => { + if (debounceRef.current) { + clearTimeout(debounceRef.current); + debounceRef.current = null; + } + }; + }, [keyword]); // 下拉刷新 const onRefresh = useCallback(() => { @@ -248,7 +275,18 @@ const FollowListScreen: React.FC = () => { // 渲染空状态 const renderEmpty = () => { if (loading) return ; - + + if (isSearching) { + return ( + + ); + } + const emptyText = type === 'following' ? '还没有关注任何人' : '还没有粉丝'; @@ -271,17 +309,34 @@ const FollowListScreen: React.FC = () => { {title} - - - 共 {users.length} - - + {!isSearching ? ( + + + 共 {users.length} + + + ) : ( + + + 搜索结果 + + + )} {type === 'following' ? (isCurrentUser ? '你已关注的用户' : 'TA关注的用户') : (isCurrentUser ? '关注你的用户' : 'TA的粉丝')} + + setDebouncedKeyword(keyword)} + placeholder={type === 'following' ? '搜索关注' : '搜索粉丝'} + compact + /> + ); @@ -407,6 +462,9 @@ function createFollowListStyles(colors: AppColors) { headerSubtitle: { marginTop: spacing.xs, }, + headerSearchWrap: { + marginTop: spacing.md, + }, userItem: { flexDirection: 'row', alignItems: 'center', diff --git a/src/services/auth/authService.ts b/src/services/auth/authService.ts index 71569d4..79dd425 100644 --- a/src/services/auth/authService.ts +++ b/src/services/auth/authService.ts @@ -3,10 +3,25 @@ * 处理用户登录、注册、登出等认证功能 */ -import { api } from '../core/api'; +import { api, ApiError, isNetworkError } from '../core/api'; import { User } from '@/types'; import type { PrivacySettingsDTO, PrivacySettingsRequestDTO, DeletionStatusDTO } from '@/types/dto'; +/** + * 当前用户获取结果。 + * - user:成功拿到用户信息 + * - auth_failed:后端明确拒绝(401 / token 失效)→ 应登出 + * - network_error:网络故障(未到达后端)→ 不应登出,应保留登录态等待重试 + * + * 引入该类型是为了修复"断网即被登出"的问题: + * 之前 fetchCurrentUserFromAPI 在任何错误下都返回 null, + * 调用方无法区分"token 真失效"与"只是连不上服务器"。 + */ +export type FetchCurrentUserResult = + | { kind: 'user'; user: User } + | { kind: 'auth_failed' } + | { kind: 'network_error' }; + export function resolveAuthApiError(error: any, fallback = '操作失败,请稍后重试'): string { const code: number = error?.code ?? 0; const msg: string = String(error?.message ?? '').toLowerCase(); @@ -231,21 +246,35 @@ class AuthService { } // 纯 API 获取当前用户(无任何 DB 依赖,供冷启动校验 Token 时使用) - async fetchCurrentUserFromAPI(): Promise { + // 返回判别类型,便于调用方区分"网络故障"与"认证被拒"。 + // 旧调用方(getCurrentUser / fetchCurrentUserFresh)仍返回 User | null。 + async fetchCurrentUserFromAPI(): Promise { try { const response = await api.get('/users/me'); - return response.data; - } catch (error: any) { - // 401 错误(未登录/登录过期)是预期情况,不需要打印错误日志 - const isAuthError = error?.code === 401 || - String(error?.message ?? '').includes('登录') || - String(error?.message ?? '').includes('token') || - String(error?.message ?? '').includes('unauthorized'); - - if (!isAuthError) { - console.error('[AuthService] fetchCurrentUserFromAPI 失败:', error); + if (response.data) { + return { kind: 'user', user: response.data }; } - return null; + // 后端返回成功但无数据:视作认证异常 + return { kind: 'auth_failed' }; + } catch (error: any) { + // 网络故障(断网 / DNS / 超时):保留登录态,不登出 + if (isNetworkError(error)) { + console.warn('[AuthService] fetchCurrentUserFromAPI 网络失败(保留登录态):', error); + return { kind: 'network_error' }; + } + // 后端明确拒绝(401 等):认证失败 + const code = error?.code ?? 0; + const isAuthError = + code === 401 || + error instanceof ApiError && error.code === 401 || + String(error?.message ?? '').includes('unauthorized') || + String(error?.errorCode ?? '').toUpperCase() === 'AUTH_ERROR'; + if (isAuthError) { + return { kind: 'auth_failed' }; + } + // 其它非网络、非 401 错误(如 500):保守视作网络/服务不可用,不登出 + console.error('[AuthService] fetchCurrentUserFromAPI 失败:', error); + return { kind: 'network_error' }; } } @@ -274,6 +303,11 @@ class AuthService { return response.data; } catch (error) { + // 网络故障:保留 token,等网络恢复后重试,避免误判登出 + if (isNetworkError(error)) { + console.warn('[AuthService] 刷新Token网络失败(保留登录态):', error); + return null; + } console.error('刷新Token失败:', error); await api.clearToken(); return null; @@ -286,8 +320,11 @@ class AuthService { } // 强制从服务器获取当前用户信息(别名,保留兼容) + // 注意:此处丢弃了"网络故障 vs 认证失败"的区分,仅返回 User | null。 + // 需要区分的调用方应直接使用 fetchCurrentUserFromAPI()。 async fetchCurrentUserFresh(): Promise { - return this.fetchCurrentUserFromAPI(); + const result = await this.fetchCurrentUserFromAPI(); + return result.kind === 'user' ? result.user : null; } async fetchUserByIdFromAPI(userId: string): Promise { @@ -368,12 +405,16 @@ class AuthService { } // 获取用户关注列表 - async getFollowingList(userId: string, page = 1, pageSize = 20): Promise { + async getFollowingList(userId: string, page = 1, pageSize = 20, keyword = ''): Promise { try { - const response = await api.get<{ list: User[] }>(`/users/${userId}/following`, { + const params: Record = { page, page_size: pageSize, - }); + }; + if (keyword) { + params.keyword = keyword; + } + const response = await api.get<{ list: User[] }>(`/users/${userId}/following`, params); return response.data.list; } catch (error) { console.error('获取关注列表失败:', error); @@ -382,12 +423,16 @@ class AuthService { } // 获取用户粉丝列表 - async getFollowersList(userId: string, page = 1, pageSize = 20): Promise { + async getFollowersList(userId: string, page = 1, pageSize = 20, keyword = ''): Promise { try { - const response = await api.get<{ list: User[] }>(`/users/${userId}/followers`, { + const params: Record = { page, page_size: pageSize, - }); + }; + if (keyword) { + params.keyword = keyword; + } + const response = await api.get<{ list: User[] }>(`/users/${userId}/followers`, params); return response.data.list; } catch (error) { console.error('获取粉丝列表失败:', error); diff --git a/src/services/auth/index.ts b/src/services/auth/index.ts index da49030..7a2cae7 100644 --- a/src/services/auth/index.ts +++ b/src/services/auth/index.ts @@ -12,6 +12,7 @@ export type { BlockStatusResponse, QRCodeSession, ScanResponse, + FetchCurrentUserResult, } from './authService'; export { verificationService } from './verificationService'; diff --git a/src/services/core/api.ts b/src/services/core/api.ts index 7279fb0..f22209c 100644 --- a/src/services/core/api.ts +++ b/src/services/core/api.ts @@ -72,10 +72,33 @@ interface JwtPayload { iss?: string; } +// 刷新 token 的结果: +// - 'ok' 刷新成功 +// - 'auth_failed' 后端明确拒绝(refresh token 失效)→ 可安全登出 +// - 'network_error' 网络故障(未收到后端响应)→ 不应登出 +type RefreshResult = 'ok' | 'auth_failed' | 'network_error'; + +// 判断是否为网络错误(fetch 未收到后端响应)。 +// 与"后端明确拒绝(401 等)"区分开,避免网络故障被误判为账号退出。 +export function isNetworkError(error: unknown): boolean { + if (error instanceof ApiError) { + return error.errorCode === 'NETWORK_ERROR'; + } + if (error instanceof Error) { + const msg = error.message.toLowerCase(); + return ( + msg.includes('network request failed') || + msg.includes('failed to fetch') || + msg.includes('networkerror') + ); + } + return false; +} + // API 客户端类 class ApiClient { private baseUrl: string; - private refreshLock: Promise | null = null; + private refreshLock: Promise | null = null; private isAuthFailed = false; // 全局认证失败标志,防止无限重试 constructor(baseUrl: string) { @@ -208,12 +231,15 @@ class ApiClient { if (token) { // 检查 token 是否快过期,如果是则主动刷新 if (this.isTokenExpiringSoon(token)) { - const refreshed = await this.refreshToken(); - if (!refreshed) { + const refreshResult = await this.refreshToken(); + if (refreshResult === 'auth_failed') { + // 后端明确拒绝(refresh token 失效)→ 登出 await this.clearToken(); this.navigateToLogin(); - throw new ApiError(401, '登录已过期,请重新登录'); + throw new ApiError(401, '登录已过期,请重新登录', 'AUTH_ERROR'); } + // 'network_error':网络故障,保留登录态,沿用旧 token 继续请求 + // (旧 token 可能仍有效;若已过期,后端会返回 401,届时再走重试逻辑) const newToken = await this.getToken(); if (newToken) { headers['Authorization'] = `Bearer ${newToken}`; @@ -240,17 +266,24 @@ class ApiClient { // 处理 401 未授权 if (response.status === 401) { if (_retryCount >= 1) { + // 已重试过仍 401 → 后端明确拒绝该账号 → 登出 await this.clearToken(); this.navigateToLogin(); - throw new ApiError(401, '登录已过期,请重新登录'); + throw new ApiError(401, '登录已过期,请重新登录', 'AUTH_ERROR'); } - const refreshed = await this.refreshToken(); - if (!refreshed) { + const refreshResult = await this.refreshToken(); + if (refreshResult === 'auth_failed') { + // refresh token 失效 → 后端明确拒绝 → 登出 await this.clearToken(); this.navigateToLogin(); - throw new ApiError(401, '登录已过期,请重新登录'); + throw new ApiError(401, '登录已过期,请重新登录', 'AUTH_ERROR'); } - + if (refreshResult === 'network_error') { + // 刷新请求网络失败:此时无法判断 401 是真失效还是旧 token 临时过期, + // 保留登录态,向上抛出可重试的网络错误,避免误判登出 + throw new ApiError(503, '网络连接失败,请检查网络后重试', 'NETWORK_ERROR'); + } + return this.request(method, path, params, body, _retryCount + 1); } @@ -302,14 +335,16 @@ class ApiClient { throw error; } - // 网络错误 + // 网络错误(fetch 未收到后端响应):标记 errorCode,便于上层区分网络故障与认证拒绝 console.error('API请求失败:', error); - throw new ApiError(500, '网络请求失败,请检查网络连接'); + throw new ApiError(503, '网络请求失败,请检查网络连接', 'NETWORK_ERROR'); } } // 刷新 token(带锁,防止并发刷新) - private async refreshToken(): Promise { + // 返回 'ok' / 'auth_failed' / 'network_error': + // - network_error 表示请求未到达后端,调用方不应据此判定账号退出 + private async refreshToken(): Promise { // 如果已有刷新操作正在进行,等待其完成 if (this.refreshLock) { return this.refreshLock; @@ -328,11 +363,12 @@ class ApiClient { } // 实际执行刷新 token - private async doRefreshToken(): Promise { + private async doRefreshToken(): Promise { try { const refreshToken = await this.getRefreshToken(); if (!refreshToken) { - return false; + // 没有刷新令牌:后端无法鉴权,视为认证失败 + return 'auth_failed'; } const response = await fetch(`${this.baseUrl}/auth/refresh`, { @@ -345,8 +381,9 @@ class ApiClient { }), }); + // 后端明确拒绝(refresh token 失效/黑名单)→ 认证失败 if (!response.ok) { - return false; + return 'auth_failed'; } const data = await response.json(); @@ -356,13 +393,19 @@ class ApiClient { await this.setRefreshToken(data.data.refresh_token || data.data.refreshToken); } this.registerDeviceOnRefresh(); - return true; + return 'ok'; } - return false; + // 业务码非 0:后端明确返回失败 → 认证失败 + return 'auth_failed'; } catch (error) { + // fetch 抛异常(DNS 失败、断网、超时)→ 网络故障,不应登出 + if (isNetworkError(error)) { + console.warn('刷新token网络失败(保留登录态):', error); + return 'network_error'; + } console.error('刷新token失败:', error); - return false; + return 'network_error'; } } @@ -443,12 +486,17 @@ class ApiClient { const headers: Record = {}; if (token) { if (this.isTokenExpiringSoon(token)) { - const refreshed = await this.refreshToken(); - if (refreshed) { - const newToken = await this.getToken(); - if (newToken) { - headers['Authorization'] = `Bearer ${newToken}`; - } + const refreshResult = await this.refreshToken(); + if (refreshResult === 'auth_failed') { + // 后端明确拒绝 → 登出,避免上传无意义请求 + await this.clearToken(); + this.navigateToLogin(); + throw new ApiError(401, '登录已过期,请重新登录', 'AUTH_ERROR'); + } + // 'ok' 或 'network_error':尝试用(可能已刷新的)token 继续 + const newToken = await this.getToken(); + if (newToken) { + headers['Authorization'] = `Bearer ${newToken}`; } } else { headers['Authorization'] = `Bearer ${token}`; diff --git a/src/services/core/datasources/WSClient.ts b/src/services/core/datasources/WSClient.ts deleted file mode 100644 index eb55ed3..0000000 --- a/src/services/core/datasources/WSClient.ts +++ /dev/null @@ -1,184 +0,0 @@ -/** - * WSClient - WebSocket连接管理 - * 只负责WebSocket连接管理,提供事件驱动架构 - */ - -import { - wsService, - WSChatMessage, - WSGroupChatMessage, - WSReadMessage, - WSGroupReadMessage, - WSRecallMessage, - WSGroupRecallMessage, - WSGroupTypingMessage, - WSGroupNoticeMessage, - WSMessageType, -} from '@/services/core'; - -// 事件处理器类型 -type EventHandler = (data: T) => void; - -// WS事件类型 -export interface WSEvents { - 'chat': WSChatMessage; - 'group_message': WSGroupChatMessage; - 'read': WSReadMessage; - 'group_read': WSGroupReadMessage; - 'recall': WSRecallMessage; - 'group_recall': WSGroupRecallMessage; - 'group_typing': WSGroupTypingMessage; - 'group_notice': WSGroupNoticeMessage; - 'connected': void; - 'disconnected': void; - 'error': Error; -} - -export type WSEventType = keyof WSEvents; - -class WSClient { - private handlers: Map>> = new Map(); - private unsubscribeFns: Array<() => void> = []; - private isInitialized = false; - - /** - * 初始化WebSocket监听 - */ - initialize(): void { - if (this.isInitialized) return; - - // 监听私聊消息 - const unsubChat = wsService.on('chat', (message) => { - this.emit('chat', message); - }); - - // 监听群聊消息 - const unsubGroupMessage = wsService.on('group_message', (message) => { - this.emit('group_message', message); - }); - - // 监听私聊已读回执 - const unsubRead = wsService.on('read', (message) => { - this.emit('read', message); - }); - - // 监听群聊已读回执 - const unsubGroupRead = wsService.on('group_read', (message) => { - this.emit('group_read', message); - }); - - // 监听私聊消息撤回 - const unsubRecall = wsService.on('recall', (message) => { - this.emit('recall', message); - }); - - // 监听群聊消息撤回 - const unsubGroupRecall = wsService.on('group_recall', (message) => { - this.emit('group_recall', message); - }); - - // 监听群聊输入状态 - const unsubGroupTyping = wsService.on('group_typing', (message) => { - this.emit('group_typing', message); - }); - - // 监听群通知 - const unsubGroupNotice = wsService.on('group_notice', (message) => { - this.emit('group_notice', message); - }); - - // 监听连接状态 - const unsubConnect = wsService.onConnect(() => { - this.emit('connected', undefined); - }); - - const unsubDisconnect = wsService.onDisconnect(() => { - this.emit('disconnected', undefined); - }); - - this.unsubscribeFns = [ - unsubChat, - unsubGroupMessage, - unsubRead, - unsubGroupRead, - unsubRecall, - unsubGroupRecall, - unsubGroupTyping, - unsubGroupNotice, - unsubConnect, - unsubDisconnect, - ]; - - this.isInitialized = true; - } - - /** - * 销毁WebSocket监听 - */ - destroy(): void { - this.unsubscribeFns.forEach((fn) => fn()); - this.unsubscribeFns = []; - this.handlers.clear(); - this.isInitialized = false; - } - - /** - * 订阅事件 - */ - on( - event: T, - handler: EventHandler - ): () => void { - if (!this.handlers.has(event)) { - this.handlers.set(event, new Set()); - } - this.handlers.get(event)!.add(handler); - - return () => { - this.handlers.get(event)?.delete(handler); - }; - } - - /** - * 触发事件 - */ - private emit( - event: T, - data: WSEvents[T] - ): void { - const handlers = this.handlers.get(event); - if (handlers) { - handlers.forEach((handler) => { - try { - handler(data); - } catch (error) { - console.error(`[WSClient] 事件处理器执行失败: ${event}`, error); - } - }); - } - } - - /** - * 检查连接状态 - */ - isConnected(): boolean { - return wsService.isConnected(); - } - - /** - * 启动WebSocket连接 - */ - async connect(): Promise { - return wsService.start(); - } - - /** - * 断开WebSocket连接 - */ - disconnect(): void { - wsService.stop(); - } -} - -export const wsClient = new WSClient(); -export default wsClient; diff --git a/src/services/core/index.ts b/src/services/core/index.ts index 4713af0..7a8694b 100644 --- a/src/services/core/index.ts +++ b/src/services/core/index.ts @@ -1,5 +1,5 @@ export { api, WS_URL, TOKEN_KEY, REFRESH_TOKEN_KEY } from './api'; -export { ApiError } from './api'; +export { ApiError, isNetworkError } from './api'; export type { ApiResponse, PaginatedData } from './api'; export { wsService } from './wsService'; diff --git a/src/services/core/wsService.ts b/src/services/core/wsService.ts index e6deb02..7aca32e 100644 --- a/src/services/core/wsService.ts +++ b/src/services/core/wsService.ts @@ -44,8 +44,6 @@ export type WSMessageType = | 'call_peer_muted' | 'call_invited' | 'call_answered_elsewhere' - | 'call_participant_joined' - | 'call_participant_left' | 'sync_required'; export interface WSCallIncomingMessage { @@ -105,19 +103,6 @@ export interface WSCallAnsweredElsewhereMessage { reason?: string; } -export interface WSCallParticipantJoinedMessage { - type: 'call_participant_joined'; - call_id: string; - user_id: string; -} - -export interface WSCallParticipantLeftMessage { - type: 'call_participant_left'; - call_id: string; - user_id: string; - reason?: string; -} - export interface WSErrorMessage { type: 'error'; code: string; @@ -272,8 +257,6 @@ export type WSMessage = | WSCallPeerMutedMessage | WSCallInvitedMessage | WSCallAnsweredElsewhereMessage - | WSCallParticipantJoinedMessage - | WSCallParticipantLeftMessage | WSErrorMessage | WSSyncRequiredMessage; diff --git a/src/stores/auth/authStore.ts b/src/stores/auth/authStore.ts index 85d0177..051f96a 100644 --- a/src/stores/auth/authStore.ts +++ b/src/stores/auth/authStore.ts @@ -331,9 +331,15 @@ export const useAuthStore = create()( } // 3. 纯 API 调用验证 Token 有效性(fetchCurrentUserFromAPI 完全不碰 DB) - const user = await authService.fetchCurrentUserFromAPI(); + // 返回判别类型: + // - 'user' 成功,继续登录流程 + // - 'auth_failed' 后端明确拒绝(401)→ 清除登录态 + // - 'network_error' 网络故障 → 保留旧登录态(持久化的 isAuthenticated), + // 等待网络恢复后重试,避免断网即被登出 + const result = await authService.fetchCurrentUserFromAPI(); - if (user) { + if (result.kind === 'user') { + const user = result.user; const userId = String(user.id); // 4. 确保 DB 已初始化(如果预初始化失败,这里会重试) @@ -364,27 +370,34 @@ export const useAuthStore = create()( isAuthenticated: true, currentUser: user, isLoading: false, + error: null, }); // 8. 启动 SSE(不阻塞状态设置) startRealtime().catch((err) => { console.warn('[AuthStore] 冷启动实时服务失败:', err); }); + } else if (result.kind === 'network_error') { + // 网络故障:不登出、不清 userId。 + // 保留持久化的 isAuthenticated / currentUser(如有),让用户在网络恢复后仍可用。 + set({ + isLoading: false, + error: '网络连接失败,已使用本地登录态', + }); } else { - // Token 已失效或不存在 + // auth_failed:后端明确拒绝,Token 真失效 → 清除登录态 await clearUserId(); useSessionStore.getState().setUserId(null); set({ isAuthenticated: false, currentUser: null, isLoading: false, + error: null, }); } } catch (error) { console.error('[AuthStore] fetchCurrentUser 异常:', error); set({ - isAuthenticated: false, - currentUser: null, isLoading: false, error: '获取用户信息失败', }); diff --git a/src/stores/call/callStore.ts b/src/stores/call/callStore.ts index bab51af..197a66a 100644 --- a/src/stores/call/callStore.ts +++ b/src/stores/call/callStore.ts @@ -680,24 +680,6 @@ export const callStore = create((set, get) => ({ }) ); - unsubs.push( - wsService.on('call_participant_joined', (msg) => { - console.log('[CallStore] Participant joined:', msg.user_id); - const { currentCall } = get(); - if (!currentCall || currentCall.id !== msg.call_id) return; - get().addParticipant(msg.user_id, { name: msg.user_id }); - }) - ); - - unsubs.push( - wsService.on('call_participant_left', (msg) => { - console.log('[CallStore] Participant left:', msg.user_id, 'reason:', msg.reason); - const { currentCall } = get(); - if (!currentCall || currentCall.id !== msg.call_id) return; - get().removeParticipant(msg.user_id); - }) - ); - const cleanup = () => { cleanupResources(); if (unsubInvited) { diff --git a/src/stores/message/MessageManager.ts b/src/stores/message/MessageManager.ts index ea8a5b1..1c50f6d 100644 --- a/src/stores/message/MessageManager.ts +++ b/src/stores/message/MessageManager.ts @@ -10,13 +10,12 @@ import { useAuthStore } from '../auth/authStore'; import type { MessageManagerConversationListDeps } from './types'; // 导入服务 -import { MessageDeduplication, messageDeduplication } from './services/MessageDeduplication'; -import { UserCacheService, userCacheService } from './services/UserCacheService'; +import { UserCacheService } from './services/UserCacheService'; import { ReadReceiptManager } from './services/ReadReceiptManager'; import { ConversationOperations } from './services/ConversationOperations'; import { MessageSendService } from './services/MessageSendService'; import { MessageSyncService } from './services/MessageSyncService'; -import { WSMessageHandler } from './services/WSMessageHandler'; +import { RealtimeIngestionPipeline } from './services/RealtimeIngestionPipeline'; // 导入 store import { useMessageStore, normalizeConversationId, loadPersistedLastSystemMessageAt } from './store'; @@ -31,13 +30,12 @@ export type { class MessageManager { // 子模块 - private deduplication: MessageDeduplication; private userCacheService: UserCacheService; private readReceiptManager: ReadReceiptManager; private conversationOps: ConversationOperations; private sendService: MessageSendService; private syncService: MessageSyncService; - private wsHandler: WSMessageHandler; + private pipeline: RealtimeIngestionPipeline; // 本地状态 private currentUserId: string | null = null; @@ -47,7 +45,6 @@ class MessageManager { constructor(deps?: MessageManagerConversationListDeps) { // 初始化子模块 - this.deduplication = new MessageDeduplication(); this.userCacheService = new UserCacheService(); this.readReceiptManager = new ReadReceiptManager(); this.conversationOps = new ConversationOperations(); @@ -58,18 +55,25 @@ class MessageManager { this.userCacheService, deps ); - this.wsHandler = new WSMessageHandler( - this.deduplication, - this.userCacheService, - () => this.getCurrentUserId(), - { - markAsRead: (id, seq) => this.markAsRead(id, seq), - fetchConversations: (force, source) => this.fetchConversations(force, source), - fetchUnreadCount: () => this.fetchUnreadCount(), - fetchMessages: (id) => this.fetchMessages(id), - syncBySeq: () => this.syncService.syncBySeq(), - } - ); + this.pipeline = new RealtimeIngestionPipeline({ + userCacheService: this.userCacheService, + getCurrentUserId: () => this.getCurrentUserId(), + onActiveConvMessage: (id, seq) => this.markAsRead(id, seq), + triggerSync: async (source) => { + // 优先基于 seq 增量同步,失败则全量刷新会话 + 活动会话消息 + 未读 + const ok = await this.syncService.syncBySeq(); + if (!ok) { + await this.fetchConversations(true, source); + const activeConv = useMessageStore.getState().getActiveConversation(); + if (activeConv) { + await this.fetchMessages(activeConv).catch(() => {}); + } + } + await this.fetchUnreadCount().catch(() => {}); + }, + fetchConversations: (force, src) => this.fetchConversations(force, src), + fetchUnreadCount: () => this.fetchUnreadCount(), + }); // 监听认证状态变化 this.initAuthListener(); @@ -138,13 +142,13 @@ class MessageManager { return; } - this.wsHandler.setBootstrapping(true); + this.pipeline.setBootstrapping(true); // 从本地缓存恢复最后系统通知时间(避免首次渲染显示当前时间) await loadPersistedLastSystemMessageAt(); - // 初始化SSE监听 - this.wsHandler.connect(); + // 注册实时事件监听(start 内部会先注销旧监听器再注册) + this.pipeline.start(); // 加载会话列表 await this.fetchConversations(false, 'initialize'); @@ -156,15 +160,15 @@ class MessageManager { // 先关闭 bootstrap(事件流转入正常处理路径),再做尾部 flush // 避免 flush 期间新到的事件被压入 buffer 后无人消费 - this.wsHandler.setBootstrapping(false); - await this.wsHandler.flushBufferedSSEEvents(); + this.pipeline.setBootstrapping(false); + await this.pipeline.flushBufferedEvents(); if (useMessageStore.getState().currentConversationId) { await this.fetchMessages(useMessageStore.getState().currentConversationId!); } } catch (error) { console.error('[MessageManager] 初始化失败:', error); - this.wsHandler.setBootstrapping(false); + this.pipeline.setBootstrapping(false); } })(); @@ -176,11 +180,10 @@ class MessageManager { } destroy(): void { - this.wsHandler.disconnect(); + this.pipeline.stop(); useMessageStore.getState().reset(); this.syncService.restartSources(); this.readReceiptManager.reset(); - this.deduplication.reset(); this.userCacheService.reset(); this.activatingConversationTasks.clear(); this.initializePromise = null; diff --git a/src/stores/message/__tests__/RealtimeIngestionPipeline.test.ts b/src/stores/message/__tests__/RealtimeIngestionPipeline.test.ts new file mode 100644 index 0000000..39e6927 --- /dev/null +++ b/src/stores/message/__tests__/RealtimeIngestionPipeline.test.ts @@ -0,0 +1,192 @@ +/** + * RealtimeIngestionPipeline 单测 + * + * 锁定的不变量: + * 1. 重复 chat 事件只入库一次、副作用(未读递增)只触发一次。 + * 2. ingest 中途副作用抛错时,消息仍已落库(修复「标记早于落库」竞态的核心保证)。 + * 3. start() 重复调用不叠加监听器;stop() 彻底注销(杜绝监听器泄漏)。 + */ + +import { wsService } from '@/services/core'; +import { eventBus } from '@/core/events/EventBus'; +import { useMessageStore } from '../store'; +import { RealtimeIngestionPipeline } from '../services/RealtimeIngestionPipeline'; +import type { RealtimeIngestionPipelineDeps } from '../services/RealtimeIngestionPipeline'; + +function makeDeps(overrides: Partial = {}): RealtimeIngestionPipelineDeps { + return { + userCacheService: { getSenderInfo: async () => null, enrichMessagesWithSenderInfo: () => {} }, + getCurrentUserId: () => 'me', + onActiveConvMessage: async () => {}, + triggerSync: async () => {}, + fetchConversations: async () => {}, + fetchUnreadCount: async () => {}, + ...overrides, + }; +} + +function makeChatMessage(id: string, seq: number, senderId = 'other') { + return { + type: 'chat' as const, + conversation_id: 'c1', + id, + sender_id: senderId, + seq, + segments: [{ type: 'text', data: { text: 'hi' } }] as any[], + created_at: `2024-01-01T00:00:${String(seq).padStart(2, '0')}Z`, + }; +} + +describe('RealtimeIngestionPipeline', () => { + beforeEach(() => { + useMessageStore.getState().reset(); + (wsService as any).reset(); + (eventBus as any).reset(); + // 确保 store 处于已初始化状态,否则事件会被 buffer + useMessageStore.getState().setInitialized(true); + }); + + describe('幂等 ingest', () => { + it('重复 chat 事件只入库一次', async () => { + const pipeline = new RealtimeIngestionPipeline(makeDeps()); + const msg = makeChatMessage('m1', 1); + + await pipeline.ingestChatMessage(msg); + await pipeline.ingestChatMessage(msg); // 重复 + + expect(useMessageStore.getState().getMessages('c1')).toHaveLength(1); + }); + + it('重复 chat 事件只递增未读一次', async () => { + const pipeline = new RealtimeIngestionPipeline(makeDeps()); + // 先确保会话存在,否则 incrementUnreadAtomic 会因找不到会话而跳过 + useMessageStore.getState().updateConversation({ + id: 'c1', + unread_count: 0, + last_seq: 0, + } as any); + const msg = makeChatMessage('m1', 1); + + await pipeline.ingestChatMessage(msg); + await pipeline.ingestChatMessage(msg); + + const conv = useMessageStore.getState().getConversation('c1'); + expect(conv?.unread_count).toBe(1); + }); + + it('重复 chat 事件只发一次震动', async () => { + const pipeline = new RealtimeIngestionPipeline(makeDeps()); + useMessageStore.getState().updateConversation({ + id: 'c1', + unread_count: 0, + last_seq: 0, + } as any); + const msg = makeChatMessage('m1', 1); + + await pipeline.ingestChatMessage(msg); + await pipeline.ingestChatMessage(msg); + + const vibrateEvents = (eventBus as any).emitted.filter((e: any) => e.type === 'VIBRATE'); + expect(vibrateEvents).toHaveLength(1); + }); + + it('每次 ingest 都发 ACK(即使重复,服务器侧幂等)', async () => { + const pipeline = new RealtimeIngestionPipeline(makeDeps()); + const msg = makeChatMessage('m1', 1); + + await pipeline.ingestChatMessage(msg); + await pipeline.ingestChatMessage(msg); + + expect((wsService as any).ackCalls).toEqual(['m1', 'm1']); + }); + + it('不同消息独立入库', async () => { + const pipeline = new RealtimeIngestionPipeline(makeDeps()); + await pipeline.ingestChatMessage(makeChatMessage('m1', 1)); + await pipeline.ingestChatMessage(makeChatMessage('m2', 2)); + const msgs = useMessageStore.getState().getMessages('c1'); + expect(msgs.map(m => m.id)).toEqual(['m1', 'm2']); + }); + }); + + describe('落库先于副作用(不丢消息)', () => { + it('副作用回调抛错时消息仍已落库', async () => { + // triggerSync 抛错不应影响 ingest(虽然 ingest 本身不调 triggerSync, + // 这里验证 onActiveConvMessage 抛错时消息仍在 store) + const pipeline = new RealtimeIngestionPipeline( + makeDeps({ + onActiveConvMessage: async () => { + throw new Error('markAsRead failed'); + }, + }) + ); + // 设为活动会话以触发 onActiveConvMessage + useMessageStore.getState().setCurrentConversation('c1'); + + await pipeline.ingestChatMessage(makeChatMessage('m1', 1)); + + // 关键不变量:消息已落库,副作用失败未回滚 + expect(useMessageStore.getState().getMessages('c1')).toHaveLength(1); + }); + }); + + describe('监听器生命周期(杜绝泄漏)', () => { + it('start() 注册监听器,事件能被路由', async () => { + const pipeline = new RealtimeIngestionPipeline(makeDeps()); + pipeline.start(); + // 触发 chat 事件 + (wsService as any).emit('chat', makeChatMessage('m1', 1)); + // ingestChatMessage 是 async,等一个微任务 + await Promise.resolve(); + await Promise.resolve(); + expect(useMessageStore.getState().getMessages('c1')).toHaveLength(1); + pipeline.stop(); + }); + + it('stop() 彻底注销所有监听器', () => { + const pipeline = new RealtimeIngestionPipeline(makeDeps()); + pipeline.start(); + const beforeChat = (wsService as any).listenerCount('chat'); + const beforeConnect = (wsService as any).connectHandlerCount(); + expect(beforeChat).toBeGreaterThan(0); + expect(beforeConnect).toBeGreaterThan(0); + + pipeline.stop(); + + expect((wsService as any).listenerCount('chat')).toBe(0); + expect((wsService as any).listenerCount('group_message')).toBe(0); + expect((wsService as any).listenerCount('recall')).toBe(0); + expect((wsService as any).listenerCount('sync_required')).toBe(0); + expect((wsService as any).connectHandlerCount()).toBe(0); + expect((wsService as any).disconnectHandlerCount()).toBe(0); + }); + + it('重复 start() 不叠加监听器', () => { + const pipeline = new RealtimeIngestionPipeline(makeDeps()); + pipeline.start(); + const firstCount = (wsService as any).listenerCount('chat'); + expect(firstCount).toBeGreaterThan(0); + + pipeline.start(); + pipeline.start(); + + // 监听器数量应与首次注册后相同,而非 3 倍 + expect((wsService as any).listenerCount('chat')).toBe(firstCount); + pipeline.stop(); + }); + + it('stop() 后再 start() 可重新接收事件', async () => { + const pipeline = new RealtimeIngestionPipeline(makeDeps()); + pipeline.start(); + pipeline.stop(); + pipeline.start(); + + (wsService as any).emit('chat', makeChatMessage('m1', 1)); + await Promise.resolve(); + await Promise.resolve(); + + expect(useMessageStore.getState().getMessages('c1')).toHaveLength(1); + pipeline.stop(); + }); + }); +}); diff --git a/src/stores/message/__tests__/mocks/asyncStorageMock.ts b/src/stores/message/__tests__/mocks/asyncStorageMock.ts new file mode 100644 index 0000000..6946afb --- /dev/null +++ b/src/stores/message/__tests__/mocks/asyncStorageMock.ts @@ -0,0 +1,18 @@ +/** + * 测试用 AsyncStorage mock:内存 Map 实现。 + */ + +const store = new Map(); + +export default { + getItem: async (key: string): Promise => store.get(key) ?? null, + setItem: async (key: string, value: string): Promise => { + store.set(key, value); + }, + removeItem: async (key: string): Promise => { + store.delete(key); + }, + __reset(): void { + store.clear(); + }, +}; diff --git a/src/stores/message/__tests__/mocks/authStoreMock.ts b/src/stores/message/__tests__/mocks/authStoreMock.ts new file mode 100644 index 0000000..c1587a1 --- /dev/null +++ b/src/stores/message/__tests__/mocks/authStoreMock.ts @@ -0,0 +1,15 @@ +/** + * 测试用 authStore mock:提供 getState/subscribe 与 currentUser。 + */ + +import { create } from 'zustand'; + +interface AuthState { + currentUser: { id: string } | null; + isAuthenticated: boolean; +} + +export const useAuthStore = create(() => ({ + currentUser: { id: 'me' }, + isAuthenticated: true, +})); diff --git a/src/stores/message/__tests__/mocks/coreMock.ts b/src/stores/message/__tests__/mocks/coreMock.ts new file mode 100644 index 0000000..7a51c50 --- /dev/null +++ b/src/stores/message/__tests__/mocks/coreMock.ts @@ -0,0 +1,104 @@ +/** + * 测试用 core mock:提供 wsService、GroupNoticeType、ApiError 等最小可用实现。 + * 仅覆盖 RealtimeIngestionPipeline 依赖的表面。 + */ + +export const GroupNoticeType = undefined as any; + +export class ApiError extends Error { + code?: number; + constructor(message: string, code?: number) { + super(message); + this.code = code; + } +} + +type EventHandler = (msg: any) => void; +type ConnHandler = () => void; + +/** + * 可控的 wsService mock:on/onConnect/onDisconnect 注册处理器, + * 测试通过 emit('chat', msg) / emitConnect() 等方法模拟 WS 事件。 + */ +class WsServiceMock { + private handlers: Map = new Map(); + private connectHandlers: ConnHandler[] = []; + private disconnectHandlers: ConnHandler[] = []; + private syncRequiredHandlers: EventHandler[] = []; + + ackCalls: string[] = []; + + on(type: string, handler: EventHandler): () => void { + const list = this.handlers.get(type) || []; + list.push(handler); + this.handlers.set(type, list); + return () => { + const cur = this.handlers.get(type) || []; + const idx = cur.indexOf(handler); + if (idx >= 0) cur.splice(idx, 1); + }; + } + + onConnect(handler: ConnHandler): () => void { + this.connectHandlers.push(handler); + return () => { + const i = this.connectHandlers.indexOf(handler); + if (i >= 0) this.connectHandlers.splice(i, 1); + }; + } + + onDisconnect(handler: ConnHandler): () => void { + this.disconnectHandlers.push(handler); + return () => { + const i = this.disconnectHandlers.indexOf(handler); + if (i >= 0) this.disconnectHandlers.splice(i, 1); + }; + } + + sendAck(messageId: string): void { + this.ackCalls.push(messageId); + } + + // ===== 测试驱动方法 ===== + emit(type: string, msg: any): void { + const list = this.handlers.get(type) || []; + list.forEach(h => h(msg)); + } + + emitConnect(): void { + this.connectHandlers.forEach(h => h()); + } + + emitDisconnect(): void { + this.disconnectHandlers.forEach(h => h()); + } + + /** 当前已注册某类型监听器的数量(用于断言无泄漏) */ + listenerCount(type: string): number { + return (this.handlers.get(type) || []).length; + } + + connectHandlerCount(): number { + return this.connectHandlers.length; + } + + disconnectHandlerCount(): number { + return this.disconnectHandlers.length; + } + + reset(): void { + this.handlers.clear(); + this.connectHandlers = []; + this.disconnectHandlers = []; + this.ackCalls = []; + } +} + +export const wsService = new WsServiceMock(); + +/** + * 供 WSServerMessage 等 wsService.ts 导出的类型在 mock 中可见。 + * Pipeline 只用到 wsService 实例和 GroupNoticeType;类型 import 是 type-only, + * 在运行期被擦除,因此这里不需要真正导出接口类型。 + */ +export {}; diff --git a/src/stores/message/__tests__/mocks/databaseMock.ts b/src/stores/message/__tests__/mocks/databaseMock.ts new file mode 100644 index 0000000..5409dfd --- /dev/null +++ b/src/stores/message/__tests__/mocks/databaseMock.ts @@ -0,0 +1,26 @@ +/** + * 测试用 database mock:messageRepository / conversationRepository / userCacheRepository。 + * 仅提供方法存根,单测断言 store 层行为,不验证持久化。 + */ + +export const messageRepository = { + saveMessage: async () => {}, + saveMessagesBatch: async () => {}, + getByConversation: async () => [], + getBeforeSeq: async () => [], + updateStatus: async () => {}, + markConversationAsRead: async () => {}, + delete: async () => {}, + clearConversation: async () => {}, +}; + +export const conversationRepository = { + getListCache: async () => [], + saveWithRelated: async () => {}, + delete: async () => {}, +}; + +export const userCacheRepository = { + get: () => null, + set: () => {}, +}; diff --git a/src/stores/message/__tests__/mocks/eventBusMock.ts b/src/stores/message/__tests__/mocks/eventBusMock.ts new file mode 100644 index 0000000..f3b95e1 --- /dev/null +++ b/src/stores/message/__tests__/mocks/eventBusMock.ts @@ -0,0 +1,23 @@ +/** + * 测试用 eventBus mock:记录 emit 的事件供断言。 + */ + +type AppEvent = { type: string; payload?: any }; + +class EventBusMock { + emitted: AppEvent[] = []; + + emit(event: AppEvent): void { + this.emitted.push(event); + } + + subscribe(_fn: (event: AppEvent) => void): () => void { + return () => {}; + } + + reset(): void { + this.emitted = []; + } +} + +export const eventBus = new EventBusMock(); diff --git a/src/stores/message/__tests__/mocks/messageServiceMock.ts b/src/stores/message/__tests__/mocks/messageServiceMock.ts new file mode 100644 index 0000000..cdde190 --- /dev/null +++ b/src/stores/message/__tests__/mocks/messageServiceMock.ts @@ -0,0 +1,18 @@ +/** + * 测试用 messageService mock:仅覆盖单测路径调用的方法。 + */ + +export const messageService = { + getMessages: async () => ({ messages: [], total: 0, page: 1, page_size: 20 }), + getConversationById: async () => null, + getUnreadCount: async () => ({ total_unread_count: 0 }), + getSystemUnreadCount: async () => ({ unread_count: 0 }), + getSyncData: async () => [], + markAsRead: async () => {}, + markAllAsRead: async () => {}, + markAllSystemMessagesRead: async () => {}, + fetchRemoteConversationListPage: async () => ({ items: [], hasMore: false }), + getSystemMessagesCursor: async () => ({ list: [] }), +}; + +export type MessageListResponse = { messages: any[]; total: number; page: number; page_size: number }; diff --git a/src/stores/message/__tests__/store.idempotency.test.ts b/src/stores/message/__tests__/store.idempotency.test.ts new file mode 100644 index 0000000..0703224 --- /dev/null +++ b/src/stores/message/__tests__/store.idempotency.test.ts @@ -0,0 +1,112 @@ +/** + * Store 幂等性单测 + * + * 锁定的不变量: + * 1. addOrReplaceMessage 以 message id 为幂等键,重复写入返回 false 且不产生重复消息。 + * 2. 消息按 seq 升序排列。 + * 3. 相邻消息不会因重复 ingest 而丢失(修复「消息被吞」的核心保证)。 + * 4. 并发 merge(模拟多次同步写入)不会丢消息。 + */ + +import { useMessageStore } from '../store'; +import type { MessageResponse } from '../../../types/dto'; + +function makeMessage(id: string, seq: number, extra: Partial = {}): MessageResponse { + return { + id, + conversation_id: 'c1', + sender_id: 'u1', + seq, + segments: [], + created_at: `2024-01-01T00:00:${String(seq).padStart(2, '0')}Z`, + status: 'normal', + ...extra, + }; +} + +describe('store 幂等性', () => { + beforeEach(() => { + useMessageStore.getState().reset(); + }); + + describe('addOrReplaceMessage', () => { + it('首次写入返回 true,消息进入 store', () => { + const inserted = useMessageStore.getState().addOrReplaceMessage('c1', makeMessage('m1', 1)); + expect(inserted).toBe(true); + const msgs = useMessageStore.getState().getMessages('c1'); + expect(msgs).toHaveLength(1); + expect(msgs[0].id).toBe('m1'); + }); + + it('同 id 重复写入返回 false 且不产生重复', () => { + useMessageStore.getState().addOrReplaceMessage('c1', makeMessage('m1', 1)); + const second = useMessageStore.getState().addOrReplaceMessage('c1', makeMessage('m1', 1)); + expect(second).toBe(false); + expect(useMessageStore.getState().getMessages('c1')).toHaveLength(1); + }); + + it('id 相同但 seq 不同(重排)仍视为幂等,不重复插入', () => { + useMessageStore.getState().addOrReplaceMessage('c1', makeMessage('m1', 1)); + const second = useMessageStore.getState().addOrReplaceMessage('c1', makeMessage('m1', 999)); + // 幂等键是 id,而非 seq + expect(second).toBe(false); + expect(useMessageStore.getState().getMessages('c1')).toHaveLength(1); + }); + + it('相邻消息不会因中间消息重复 ingest 而丢失', () => { + // 模拟 WS 与 REST 增量同步交织:m1, m2 都先入,然后 m1 重复,最后 m3 入 + useMessageStore.getState().addOrReplaceMessage('c1', makeMessage('m1', 1)); + useMessageStore.getState().addOrReplaceMessage('c1', makeMessage('m2', 2)); + // m1 重复(如发送回显 + WS echo) + useMessageStore.getState().addOrReplaceMessage('c1', makeMessage('m1', 1)); + useMessageStore.getState().addOrReplaceMessage('c1', makeMessage('m3', 3)); + + const msgs = useMessageStore.getState().getMessages('c1'); + expect(msgs.map(m => m.id)).toEqual(['m1', 'm2', 'm3']); + expect(msgs.map(m => m.seq)).toEqual([1, 2, 3]); + }); + + it('不同会话互不影响', () => { + useMessageStore.getState().addOrReplaceMessage('c1', makeMessage('m1', 1)); + const inserted = useMessageStore.getState().addOrReplaceMessage('c2', makeMessage('m1', 1)); + expect(inserted).toBe(true); + expect(useMessageStore.getState().getMessages('c1')).toHaveLength(1); + expect(useMessageStore.getState().getMessages('c2')).toHaveLength(1); + }); + }); + + describe('mergeMessages', () => { + it('批量合并同样按 id 去重', () => { + useMessageStore.getState().mergeMessages('c1', [makeMessage('m1', 1), makeMessage('m2', 2)]); + // 重复 m1 + 新 m3 + useMessageStore.getState().mergeMessages('c1', [makeMessage('m1', 1), makeMessage('m3', 3)]); + const msgs = useMessageStore.getState().getMessages('c1'); + expect(msgs.map(m => m.id)).toEqual(['m1', 'm2', 'm3']); + }); + + it('空 incoming 不改变现有消息', () => { + useMessageStore.getState().mergeMessages('c1', [makeMessage('m1', 1)]); + useMessageStore.getState().mergeMessages('c1', []); + expect(useMessageStore.getState().getMessages('c1')).toHaveLength(1); + }); + + it('乱序 seq 入库后仍按 seq 升序排列', () => { + useMessageStore.getState().mergeMessages('c1', [ + makeMessage('m3', 3), + makeMessage('m1', 1), + makeMessage('m2', 2), + ]); + const msgs = useMessageStore.getState().getMessages('c1'); + expect(msgs.map(m => m.seq)).toEqual([1, 2, 3]); + }); + + it('并发连续 merge 不丢消息(模拟 WS + REST 增量同步竞态)', () => { + // 两条独立 merge,store 在 set 回调内原子合并 + useMessageStore.getState().mergeMessages('c1', [makeMessage('m1', 1), makeMessage('m2', 2)]); + useMessageStore.getState().mergeMessages('c1', [makeMessage('m2', 2), makeMessage('m3', 3)]); + useMessageStore.getState().mergeMessages('c1', [makeMessage('m1', 1), makeMessage('m3', 3), makeMessage('m4', 4)]); + const msgs = useMessageStore.getState().getMessages('c1'); + expect(msgs.map(m => m.id)).toEqual(['m1', 'm2', 'm3', 'm4']); + }); + }); +}); diff --git a/src/stores/message/__tests__/tsconfig.json b/src/stores/message/__tests__/tsconfig.json new file mode 100644 index 0000000..f9e406f --- /dev/null +++ b/src/stores/message/__tests__/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../../../tsconfig.json", + "compilerOptions": { + "rootDir": "../../../../", + "isolatedModules": true, + "noEmit": true, + "types": ["jest", "node"] + }, + "include": ["**/*.ts"], + "exclude": ["node_modules", "mocks/**"] +} diff --git a/src/stores/message/__tests__/unread.atomics.test.ts b/src/stores/message/__tests__/unread.atomics.test.ts new file mode 100644 index 0000000..c57b601 --- /dev/null +++ b/src/stores/message/__tests__/unread.atomics.test.ts @@ -0,0 +1,112 @@ +/** + * 未读计数原子性单测 + * + * 锁定的不变量: + * 1. store 原子 actions(incrementSystemUnreadAtomic / setSystemUnreadAtomic / + * decrementSystemUnreadAtomic)保持 total = system + 会话未读的一致性。 + * 2. ConversationOperations 的系统未读变更走原子路径,不丢更新(修复 RMW 竞态)。 + * 3. 并发多次递增计数不丢失。 + */ + +import { useMessageStore } from '../store'; +import { ConversationOperations } from '../services/ConversationOperations'; + +describe('未读计数原子性', () => { + let convOps: ConversationOperations; + + beforeEach(() => { + useMessageStore.getState().reset(); + convOps = new ConversationOperations(); + }); + + describe('store 原子 actions', () => { + it('incrementSystemUnreadAtomic 同步推进 system 与 total', () => { + useMessageStore.getState().incrementSystemUnreadAtomic(); + useMessageStore.getState().incrementSystemUnreadAtomic(); + const { total, system } = useMessageStore.getState().getUnreadCount(); + expect(system).toBe(2); + expect(total).toBe(2); + }); + + it('setSystemUnreadAtomic 保持 total 一致性(基于 delta)', () => { + useMessageStore.getState().incrementSystemUnreadAtomic(); // system=1,total=1 + useMessageStore.getState().setSystemUnreadAtomic(5); // system=5, total 应 +4 + const { total, system } = useMessageStore.getState().getUnreadCount(); + expect(system).toBe(5); + expect(total).toBe(5); + }); + + it('setSystemUnreadAtomic 向下调整也保持一致', () => { + useMessageStore.getState().setSystemUnreadAtomic(10); // system=10,total=10 + useMessageStore.getState().setSystemUnreadAtomic(3); // system=3,total=3 + const { total, system } = useMessageStore.getState().getUnreadCount(); + expect(system).toBe(3); + expect(total).toBe(3); + }); + + it('decrementSystemUnreadAtomic 不低于 0', () => { + useMessageStore.getState().setSystemUnreadAtomic(2); + useMessageStore.getState().decrementSystemUnreadAtomic(5); // 应被钳制到 0 + const { total, system } = useMessageStore.getState().getUnreadCount(); + expect(system).toBe(0); + expect(total).toBe(0); + }); + + it('decrementSystemUnreadAtomic 默认减 1', () => { + useMessageStore.getState().setSystemUnreadAtomic(3); + useMessageStore.getState().decrementSystemUnreadAtomic(); + const { system } = useMessageStore.getState().getUnreadCount(); + expect(system).toBe(2); + }); + + it('并发连续递增计数不丢失(每个 set 都是原子的)', () => { + // 模拟 WS 高并发推送 100 条系统通知 + for (let i = 0; i < 100; i++) { + useMessageStore.getState().incrementSystemUnreadAtomic(); + } + const { total, system } = useMessageStore.getState().getUnreadCount(); + expect(system).toBe(100); + expect(total).toBe(100); + }); + }); + + describe('ConversationOperations 走原子路径', () => { + it('incrementSystemUnreadCount 不丢更新', () => { + for (let i = 0; i < 50; i++) { + convOps.incrementSystemUnreadCount(); + } + const { system } = useMessageStore.getState().getUnreadCount(); + expect(system).toBe(50); + }); + + it('setSystemUnreadCount 后再并发递增不丢', () => { + convOps.setSystemUnreadCount(10); + for (let i = 0; i < 20; i++) { + convOps.incrementSystemUnreadCount(); + } + const { system, total } = useMessageStore.getState().getUnreadCount(); + expect(system).toBe(30); + expect(total).toBe(30); + }); + + it('decrementSystemUnreadCount 与原子行为一致', () => { + convOps.setSystemUnreadCount(10); + convOps.decrementSystemUnreadCount(3); + expect(useMessageStore.getState().getUnreadCount().system).toBe(7); + }); + + it('会话未读 + 系统未读共同构成 total', () => { + // 先放一个有未读的会话 + useMessageStore.getState().updateConversation({ + id: 'c1', + unread_count: 5, + last_seq: 0, + } as any); + convOps.incrementSystemUnreadCount(); // system=1,total 应 = 5+1=6 + const { total, system } = useMessageStore.getState().getUnreadCount(); + expect(system).toBe(1); + // total 由会话未读累加与系统未读共同维护;这里仅断言 system 路径 + expect(total).toBeGreaterThanOrEqual(system); + }); + }); +}); diff --git a/src/stores/message/constants.ts b/src/stores/message/constants.ts index 9533bb4..bf96676 100644 --- a/src/stores/message/constants.ts +++ b/src/stores/message/constants.ts @@ -9,30 +9,12 @@ */ export const READ_STATE_PROTECTION_DELAY = 5000; // 5秒 -/** - * 已处理消息ID过期时间(毫秒) - * 用于消息去重机制,防止内存泄漏 - */ -export const PROCESSED_MESSAGE_ID_EXPIRY = 5 * 60 * 1000; // 5分钟 - -/** - * 已处理消息ID集合最大大小 - * 超过此大小时触发清理 - */ -export const PROCESSED_MESSAGE_ID_MAX_SIZE = 1000; - /** * 缓冲SSE事件刷新最大迭代次数 * 防止在线流持续不断导致长时间阻塞初始化 */ export const MAX_FLUSH_ITERATIONS = 3; -/** - * 会话列表刷新延迟检查间隔(毫秒) - * 用于防抖处理 - */ -export const CONVERSATION_REFRESH_THROTTLE = 1000; - /** * 重连同步最小间隔(毫秒) * 防止频繁重连导致的重复同步 diff --git a/src/stores/message/index.ts b/src/stores/message/index.ts index d5f0b52..9175f25 100644 --- a/src/stores/message/index.ts +++ b/src/stores/message/index.ts @@ -20,50 +20,41 @@ export type { ReadStateRecord, MessageManagerConversationListDeps, HandleNewMessageOptions, - IMessageDeduplication, IUserCacheService, IReadReceiptManager, IConversationOperations, IMessageSendService, IMessageSyncService, - IWSMessageHandler, } from './types'; // ==================== 常量导出 ==================== export { READ_STATE_PROTECTION_DELAY, - PROCESSED_MESSAGE_ID_EXPIRY, - PROCESSED_MESSAGE_ID_MAX_SIZE, MAX_FLUSH_ITERATIONS, - CONVERSATION_REFRESH_THROTTLE, MIN_RECONNECT_SYNC_INTERVAL, } from './constants'; // ==================== 服务导出 ==================== export { - // 消息去重服务 - MessageDeduplication, - messageDeduplication, - + // 实时消息入口 + RealtimeIngestionPipeline, + // 用户缓存服务 UserCacheService, - userCacheService, - + // 已读回执管理器 ReadReceiptManager, - + // 会话操作服务 ConversationOperations, - + // 消息发送服务 MessageSendService, - + // 消息同步服务 MessageSyncService, - - // WebSocket 消息处理器 - WSMessageHandler, } from './services'; +export type { RealtimeIngestionPipelineDeps, IngestOptions } from './services'; // ==================== Hooks 导出(便捷版,使用 messageManager 单例) ==================== export { diff --git a/src/stores/message/services/ConversationOperations.ts b/src/stores/message/services/ConversationOperations.ts index 73549c8..fd918dc 100644 --- a/src/stores/message/services/ConversationOperations.ts +++ b/src/stores/message/services/ConversationOperations.ts @@ -99,30 +99,23 @@ export class ConversationOperations implements IConversationOperations { } /** - * 设置系统消息未读数 + * 设置系统消息未读数(原子:避免与 WS 推送的并发递增丢更新) */ setSystemUnreadCount(count: number): void { - const store = useMessageStore.getState(); - const currentUnread = store.getUnreadCount(); - store.setUnreadCount(currentUnread.total, count); + useMessageStore.getState().setSystemUnreadAtomic(count); } /** - * 增加系统消息未读数 + * 增加系统消息未读数(原子) */ incrementSystemUnreadCount(): void { - const store = useMessageStore.getState(); - const currentUnread = store.getUnreadCount(); - store.setUnreadCount(currentUnread.total, currentUnread.system + 1); + useMessageStore.getState().incrementSystemUnreadAtomic(); } /** - * 减少系统消息未读数 + * 减少系统消息未读数(原子,不低于 0) */ decrementSystemUnreadCount(count = 1): void { - const store = useMessageStore.getState(); - const currentUnread = store.getUnreadCount(); - const newSystemCount = Math.max(0, currentUnread.system - count); - store.setUnreadCount(currentUnread.total, newSystemCount); + useMessageStore.getState().decrementSystemUnreadAtomic(count); } } \ No newline at end of file diff --git a/src/stores/message/services/MessageDeduplication.ts b/src/stores/message/services/MessageDeduplication.ts deleted file mode 100644 index f8e215e..0000000 --- a/src/stores/message/services/MessageDeduplication.ts +++ /dev/null @@ -1,61 +0,0 @@ -/** - * 消息去重服务 - * 处理消息去重逻辑,防止重复处理相同的消息 - */ - -import type { IMessageDeduplication } from '../types'; -import { - PROCESSED_MESSAGE_ID_EXPIRY, - PROCESSED_MESSAGE_ID_MAX_SIZE, -} from '../constants'; - -export class MessageDeduplication implements IMessageDeduplication { - // 已处理消息ID集合(用于去重,防止ACK消息和正常消息重复处理) - private processedMessageIds: Set = new Set(); - private processedMessageIdsExpiry: Map = new Map(); - - /** - * 检查消息是否已处理 - */ - isMessageProcessed(messageId: string): boolean { - return this.processedMessageIds.has(messageId); - } - - /** - * 标记消息已处理 - */ - markMessageAsProcessed(messageId: string): void { - this.processedMessageIds.add(messageId); - this.processedMessageIdsExpiry.set(messageId, Date.now()); - - // 定期清理 - if (this.processedMessageIds.size > PROCESSED_MESSAGE_ID_MAX_SIZE) { - this.cleanupProcessedMessageIds(); - } - } - - /** - * 清理过期的消息ID(防止内存泄漏) - */ - cleanupProcessedMessageIds(): void { - const now = Date.now(); - for (const [id, timestamp] of this.processedMessageIdsExpiry.entries()) { - if (now - timestamp > PROCESSED_MESSAGE_ID_EXPIRY) { - this.processedMessageIds.delete(id); - this.processedMessageIdsExpiry.delete(id); - } - } - } - - /** - * 重置所有已处理的消息ID - * 用于测试或需要清除缓存的场景 - */ - reset(): void { - this.processedMessageIds.clear(); - this.processedMessageIdsExpiry.clear(); - } -} - -// 单例导出 -export const messageDeduplication = new MessageDeduplication(); \ No newline at end of file diff --git a/src/stores/message/services/MessageSyncService.ts b/src/stores/message/services/MessageSyncService.ts index 1c80137..36b4491 100644 --- a/src/stores/message/services/MessageSyncService.ts +++ b/src/stores/message/services/MessageSyncService.ts @@ -36,7 +36,6 @@ export class MessageSyncService implements IMessageSyncService { private inflightFetches: Map> = new Map(); private inflightFetchConversations: Promise | null = null; private inflightSyncBySeq: Promise | null = null; - private inflightSyncByVersion: Promise | null = null; constructor( getCurrentUserId: () => string | null, @@ -507,77 +506,6 @@ export class MessageSyncService implements IMessageSyncService { } } - /** - * 基于版本号的增量同步 — 去重入口 - */ - async syncByVersion(): Promise { - if (this.inflightSyncByVersion) return this.inflightSyncByVersion; - this.inflightSyncByVersion = this._doSyncByVersion(); - try { - return await this.inflightSyncByVersion; - } finally { - this.inflightSyncByVersion = null; - } - } - - /** - * 基于版本号的增量同步 — 核心实现 - */ - private async _doSyncByVersion(): Promise { - try { - const store = useMessageStore.getState(); - const version = store.syncVersion ?? 0; - - const result = await messageService.getSyncByVersion(version, 100); - if (!result) return false; - - // full_sync 表示需要全量刷新 - if (result.full_sync) { - return false; - } - - if (result.changes.length === 0) { - // 保存当前版本号,下次增量同步时使用 - useMessageStore.getState().setSyncVersion(result.current_version); - return true; - } - - // 按变更类型处理 - const staleIds: string[] = []; - for (const change of result.changes) { - const normId = normalizeConversationId(change.conversation_id); - if (change.change_type === 'hide') { - // 用户隐藏了会话,从本地移除 - store.removeConversation(normId); - } else { - // new_msg, read, pin, unpin, mute, unmute, group_update - staleIds.push(normId); - } - } - - // 刷新有变更的会话 - if (staleIds.length > 0) { - const detailPromises = staleIds.map(id => - this.fetchConversationDetail(id).catch(() => {}) - ); - await Promise.all(detailPromises); - } - - // 保存版本号 - useMessageStore.getState().setSyncVersion(result.current_version); - - // has_more 时继续拉取 - if (result.has_more) { - return this.syncByVersion(); - } - - return true; - } catch (error) { - console.error('[MessageSyncService] syncByVersion 失败:', error); - return false; - } - } - /** * 检查是否可加载更多会话 */ diff --git a/src/stores/message/services/ReadReceiptManager.ts b/src/stores/message/services/ReadReceiptManager.ts index f9e6d0d..d0a93c3 100644 --- a/src/stores/message/services/ReadReceiptManager.ts +++ b/src/stores/message/services/ReadReceiptManager.ts @@ -180,69 +180,6 @@ export class ReadReceiptManager implements IReadReceiptManager { } } - /** - * 开始已读操作,返回版本号 - */ - beginReadOperation(conversationId: string, seq: number): number { - this.readStateVersion++; - const currentVersion = this.readStateVersion; - - this.pendingReadMap.set(conversationId, { - timestamp: Date.now(), - version: currentVersion, - lastReadSeq: seq, - }); - - return currentVersion; - } - - /** - * 结束已读操作 - */ - endReadOperation(conversationId: string, version: number, success: boolean): void { - if (!success) { - this.pendingReadMap.delete(conversationId); - return; - } - - // API 成功后,设置延迟清除保护 - const clearTimer = setTimeout(() => { - const record = this.pendingReadMap.get(conversationId); - if (record && record.version === version) { - this.pendingReadMap.delete(conversationId); - } - }, READ_STATE_PROTECTION_DELAY); - - const existingRecord = this.pendingReadMap.get(conversationId); - if (existingRecord) { - this.pendingReadMap.set(conversationId, { - ...existingRecord, - clearTimer, - }); - } - } - - /** - * 检查是否有进行中的已读操作 - */ - isReadOperationPending(conversationId: string): boolean { - return this.pendingReadMap.has(conversationId); - } - - /** - * 获取当前已读状态版本号 - */ - getReadStateVersion(): number { - return this.readStateVersion; - } - - /** - * 获取待处理的已读映射 - */ - getPendingReadMap(): Map { - return this.pendingReadMap; - } - /** * 根据已读保护状态智能合并会话 */ diff --git a/src/stores/message/services/RealtimeIngestionPipeline.ts b/src/stores/message/services/RealtimeIngestionPipeline.ts new file mode 100644 index 0000000..e643791 --- /dev/null +++ b/src/stores/message/services/RealtimeIngestionPipeline.ts @@ -0,0 +1,605 @@ +/** + * RealtimeIngestionPipeline — 实时消息单一入口与幂等处理 + * + * 设计目标(对标 Matrix/Element 的 idempotent event ingestion 与 Discord 的 gateway + * event deduplication): + * + * 1. **幂等键下沉到 store**:不再在上层维护独立的「已处理消息 id」集合。 + * 去重的唯一真理源是 `useMessageStore.addOrReplaceMessage`,它在 zustand set 的 + * 原子边界内判断消息是否已存在。落库与去重原子绑定,**永不丢消息** + * (最坏情况是重复 ingest,被 store 静默去重)。 + * + * 2. **副作用与状态变更分离**:未读递增、震动、本地持久化、自动已读、会话更新都是 + * 「副作用」,只有在消息**确认新入库后**(`addOrReplaceMessage` 返回 true)才执行。 + * 任何副作用失败都不回滚已落库的消息——彻底消除旧实现「标记早于落库」导致的 + * 永久丢消息竞态。 + * + * 3. **显式监听器生命周期**:所有 `wsService.on/onConnect/onDisconnect` 返回的 + * unsubscribe 函数被收集到 `unsubscribers`,`start()` 先彻底注销旧监听器再注册, + * `stop()` 一并释放——杜绝重复注册与重登录后的监听器泄漏。 + * + * 该类取代旧的 `WSMessageHandler` + `MessageDeduplication`。 + */ + +import type { + WSChatMessage, + WSGroupChatMessage, + WSReadMessage, + WSGroupReadMessage, + WSRecallMessage, + WSGroupRecallMessage, + WSGroupTypingMessage, + WSGroupNoticeMessage, + WSNotificationMessage, +} from '@/services/core'; +import { wsService, GroupNoticeType } from '@/services/core'; +import { eventBus } from '@/core/events/EventBus'; +import { messageRepository } from '@/database'; +import type { MessageResponse } from '../../../types/dto'; +import { + MAX_FLUSH_ITERATIONS, + MIN_RECONNECT_SYNC_INTERVAL, +} from '../constants'; +import { useMessageStore, normalizeConversationId } from '../store'; +import type { IUserCacheService } from '../types'; + +/** 实时事件统一缓冲类型(bootstrap 阶段暂存,初始化完成后回放) */ +type BufferedEvent = + | { kind: 'chat'; message: WSChatMessage | WSGroupChatMessage } + | { kind: 'read'; message: WSReadMessage | WSGroupReadMessage }; + +/** ingest 时可抑制的副作用开关 */ +export interface IngestOptions { + suppressUnreadIncrement?: boolean; + suppressVibration?: boolean; + suppressConversationUpdates?: boolean; + suppressAutoMarkAsRead?: boolean; +} + +/** Pipeline 对外依赖(由 MessageManager 注入) */ +export interface RealtimeIngestionPipelineDeps { + userCacheService: IUserCacheService; + getCurrentUserId: () => string | null; + /** 活动会话收到新消息时回调(用于自动标记已读) */ + onActiveConvMessage: (conversationId: string, seq: number) => Promise; + /** 重连 / sync_required 时触发的增量同步入口 */ + triggerSync: (source: string) => Promise; + /** 权威刷新会话列表入口(flush 阶段调用) */ + fetchConversations: (forceRefresh: boolean, source: string) => Promise; + /** 权威刷新未读数入口(flush 阶段调用) */ + fetchUnreadCount: () => Promise; +} + +export class RealtimeIngestionPipeline { + private deps: RealtimeIngestionPipelineDeps; + /** 所有 wsService 监听器的注销函数;start() 先清空再注册,stop() 全部释放 */ + private unsubscribers: Array<() => void> = []; + /** bootstrap 阶段是否缓冲事件(初始化完成前为 true) */ + private isBootstrapping = false; + /** bootstrap 期间缓冲的实时事件 */ + private bufferedEvents: BufferedEvent[] = []; + + /** 同步触发器状态:防止 onConnect 和 sync_required 并发 */ + private syncInProgress = false; + private lastSyncTriggerAt = 0; + + /** 系统通知去重(防止重连时重复递增系统未读) */ + private processedNotificationIds: Set = new Set(); + + constructor(deps: RealtimeIngestionPipelineDeps) { + this.deps = deps; + } + + // ==================== 生命周期 ==================== + + /** + * 注册所有 wsService 监听器。重复调用安全:先注销旧监听器再注册新的。 + */ + start(): void { + this.stop(); + + const push = (unsub: () => void) => this.unsubscribers.push(unsub); + + // 聊天消息:bootstrap 期间缓冲,否则直接 ingest + push( + wsService.on('chat', (message: WSChatMessage) => { + if (!this.isReady()) { + this.bufferedEvents.push({ kind: 'chat', message }); + return; + } + this.ingestChatMessage(message).catch(error => { + console.error('[RealtimeIngestionPipeline] ingest chat 失败:', error); + }); + }) + ); + + push( + wsService.on('group_message', (message: WSGroupChatMessage) => { + if (!this.isReady()) { + this.bufferedEvents.push({ kind: 'chat', message }); + return; + } + this.ingestChatMessage(message).catch(error => { + console.error('[RealtimeIngestionPipeline] ingest group_message 失败:', error); + }); + }) + ); + + // 已读回执:bootstrap 期间缓冲 + push( + wsService.on('read', (message: WSReadMessage) => { + if (!this.isReady()) { + this.bufferedEvents.push({ kind: 'read', message }); + return; + } + this.handleReadReceipt(message); + }) + ); + + push( + wsService.on('group_read', (message: WSGroupReadMessage) => { + if (!this.isReady()) { + this.bufferedEvents.push({ kind: 'read', message }); + return; + } + this.handleGroupReadReceipt(message); + }) + ); + + // 撤回、输入状态、群通知:直接处理(这些操作幂等或无副作用丢失风险) + push( + wsService.on('recall', (message: WSRecallMessage) => this.handleRecallMessage(message)) + ); + push( + wsService.on('group_recall', (message: WSGroupRecallMessage) => this.handleGroupRecallMessage(message)) + ); + push( + wsService.on('group_typing', (message: WSGroupTypingMessage) => this.handleGroupTyping(message)) + ); + push( + wsService.on('group_notice', (message: WSGroupNoticeMessage) => this.handleGroupNotice(message)) + ); + + // 系统通知:去重后递增系统未读 + push( + wsService.on('notification', (message: WSNotificationMessage) => this.handleNotification(message)) + ); + + // 连接状态 + push( + wsService.onConnect(() => { + useMessageStore.getState().setSSEConnected(true); + this.triggerSync('sse-reconnect').catch(error => { + console.error('[RealtimeIngestionPipeline] 重连同步失败:', error); + }); + }) + ); + + push( + wsService.on('sync_required', () => { + this.triggerSync('sync_required').catch(error => { + console.error('[RealtimeIngestionPipeline] sync_required 同步失败:', error); + }); + }) + ); + + push( + wsService.onDisconnect(() => { + useMessageStore.getState().setSSEConnected(false); + }) + ); + } + + /** + * 注销所有监听器并清空缓冲。重复调用安全。 + */ + stop(): void { + for (const unsub of this.unsubscribers) { + try { + unsub(); + } catch (error) { + console.error('[RealtimeIngestionPipeline] 注销监听器失败:', error); + } + } + this.unsubscribers = []; + } + + /** 设置 bootstrap 标志 */ + setBootstrapping(value: boolean): void { + this.isBootstrapping = value; + } + + /** + * 初始化完成后,回放缓冲的实时事件。 + * 每轮取走当前 buffer 后立即清空(flush 期间新到事件走正常路径),直到稳定。 + */ + async flushBufferedEvents(): Promise { + let lastLen = -1; + for (let i = 0; i < MAX_FLUSH_ITERATIONS; i++) { + const events = this.bufferedEvents; + if (events.length === lastLen) { + return; + } + lastLen = events.length; + this.bufferedEvents = []; + + for (const event of events) { + if (event.kind === 'chat') { + await this.ingestChatMessage(event.message, { + suppressUnreadIncrement: true, + suppressVibration: true, + suppressConversationUpdates: true, + suppressAutoMarkAsRead: true, + }); + } else if (event.message.type === 'read') { + this.handleReadReceipt(event.message as WSReadMessage); + } else { + this.handleGroupReadReceipt(event.message as WSGroupReadMessage); + } + } + + // 使用服务器权威数据刷新会话与未读 + await this.deps.fetchConversations(true, 'sse-buffer-flush'); + await this.deps.fetchUnreadCount(); + } + } + + // ==================== 核心:聊天消息幂等 ingest ==================== + + /** + * 幂等 ingest 一条聊天消息(私聊 chat / 群聊 group_message)。 + * + * 流程(关键:先落库,落库成功后才发副作用): + * 1. 构造 MessageResponse + * 2. 通过 store.addOrReplaceMessage 原子写入并取得「是否新写入」标志 + * 3. 始终发送 ACK(服务器侧幂等;ACK 失败不影响消息) + * 4. 仅当 inserted === true 时触发副作用:会话更新 / 未读递增 / 震动 / + * 自动已读 / 本地持久化 / 群聊 sender 补全 + */ + async ingestChatMessage( + message: (WSChatMessage | WSGroupChatMessage) & { _isAck?: boolean }, + options?: IngestOptions + ): Promise { + const store = useMessageStore.getState(); + const { conversation_id, id, sender_id, seq, segments, created_at, _isAck } = message; + const normalizedConversationId = normalizeConversationId(conversation_id); + const currentUserId = this.deps.getCurrentUserId(); + + const suppressUnreadIncrement = options?.suppressUnreadIncrement ?? false; + const suppressVibration = options?.suppressVibration ?? false; + const suppressConversationUpdates = options?.suppressConversationUpdates ?? false; + const suppressAutoMarkAsRead = options?.suppressAutoMarkAsRead ?? false; + + // 1. 构造消息对象 + const newMessage: MessageResponse = { + id, + conversation_id: normalizedConversationId, + sender_id, + seq: typeof seq === 'string' ? parseInt(seq, 10) : (seq ?? 0), + segments: segments || [], + created_at, + status: 'normal', + }; + + // 2. 原子幂等写入;inserted === true 才是真正的新消息 + const inserted = useMessageStore.getState().addOrReplaceMessage(normalizedConversationId, newMessage); + + // 3. 始终 ACK(即使重复,服务器侧幂等;ACK 失败不回滚消息) + wsService.sendAck(id); + + // 4. 幂等:已存在的消息跳过所有副作用 + if (!inserted) return; + + // —— 以下副作用仅在消息新入库后执行,任一失败都不回滚消息 —— + + if (!suppressConversationUpdates) { + this.applyConversationUpdate(normalizedConversationId, newMessage, created_at); + } + + // 重新读取 store 以获得最新的活动会话状态(避免闭包陈旧) + const latestStore = useMessageStore.getState(); + const isActiveConversation = normalizedConversationId === latestStore.getActiveConversation(); + const isCurrentUserMessage = sender_id === currentUserId; + + const shouldIncrementUnread = + !suppressUnreadIncrement && + !isCurrentUserMessage && + !isActiveConversation && + !!currentUserId && + !_isAck; + + if (shouldIncrementUnread) { + const isNotificationMuted = latestStore.isNotificationMuted(normalizedConversationId); + if (!suppressVibration && !isNotificationMuted) { + const vibrationType = message.type === 'group_message' ? 'group_message' : 'message'; + eventBus.emit({ type: 'VIBRATE', payload: { type: vibrationType } }); + } + this.incrementUnreadCount(normalizedConversationId); + } + + // 活动会话且非本人消息:自动标记已读 + if (isActiveConversation && !isCurrentUserMessage && !suppressAutoMarkAsRead) { + this.deps.onActiveConvMessage(normalizedConversationId, seq).catch(error => { + console.error('[RealtimeIngestionPipeline] 自动标记已读失败:', error); + }); + } + + // 群聊消息:异步补 sender 信息(patchMessages 自身幂等,不影响去重) + if ( + message.type === 'group_message' && + sender_id && + sender_id !== currentUserId && + sender_id !== '10000' + ) { + this.deps.userCacheService + .getSenderInfo(sender_id) + .then(user => { + if (!user) return; + const patches = new Map>(); + patches.set(String(id), { sender: user }); + useMessageStore.getState().patchMessages(normalizedConversationId, patches); + }) + .catch(error => { + console.error('[RealtimeIngestionPipeline] 获取发送者信息失败:', { userId: sender_id, error }); + }); + } + + // 异步保存到本地数据库(fire-and-forget) + const textContent = + segments?.filter((s: any) => s.type === 'text').map((s: any) => s.data?.text || '').join('') || ''; + messageRepository + .saveMessage({ + id, + conversationId: normalizedConversationId, + senderId: sender_id || '', + content: textContent, + type: segments?.find((s: any) => s.type === 'image') ? 'image' : 'text', + isRead: isActiveConversation, + createdAt: (() => { + const d = new Date(created_at); + return Number.isNaN(d.getTime()) ? new Date().toISOString() : d.toISOString(); + })(), + seq, + status: 'normal', + segments, + }) + .catch(error => { + console.error('[RealtimeIngestionPipeline] 保存消息到本地失败:', error); + }); + } + + // ==================== 其他事件处理 ==================== + + /** 私聊已读回执(当前为占位,保留扩展点) */ + handleReadReceipt(_message: WSReadMessage): void { + // 预留:可在此更新对方已读状态 + } + + /** 群聊已读回执(当前为占位,保留扩展点) */ + handleGroupReadReceipt(_message: WSGroupReadMessage): void { + // 预留:可在此更新成员已读状态 + } + + /** 私聊消息撤回 */ + handleRecallMessage(message: WSRecallMessage): void { + const { conversation_id, message_id } = message; + const normalizedConversationId = normalizeConversationId(conversation_id); + + this.markMessageAsRecalled(normalizedConversationId, message_id); + this.syncConversationLastMessageOnRecall(normalizedConversationId, message_id); + + messageRepository.updateStatus(message_id, 'recalled', true).catch(error => { + console.error('[RealtimeIngestionPipeline] 更新本地撤回状态失败:', error); + }); + } + + /** 群聊消息撤回 */ + handleGroupRecallMessage(message: WSGroupRecallMessage): void { + const { conversation_id, message_id } = message; + const normalizedConversationId = normalizeConversationId(conversation_id); + + this.markMessageAsRecalled(normalizedConversationId, message_id); + this.syncConversationLastMessageOnRecall(normalizedConversationId, message_id); + + messageRepository.updateStatus(message_id, 'recalled', true).catch(error => { + console.error('[RealtimeIngestionPipeline] 更新本地撤回状态失败:', error); + }); + } + + /** 群聊输入状态 */ + handleGroupTyping(message: WSGroupTypingMessage): void { + const store = useMessageStore.getState(); + const { group_id, user_id, is_typing } = message; + const groupIdStr = String(group_id); + + const currentTypingUsers = store.getTypingUsers(groupIdStr); + let updatedTypingUsers: string[]; + + if (is_typing) { + updatedTypingUsers = currentTypingUsers.includes(user_id) + ? currentTypingUsers + : [...currentTypingUsers, user_id]; + } else { + updatedTypingUsers = currentTypingUsers.filter(id => id !== user_id); + } + + if (updatedTypingUsers.length !== currentTypingUsers.length) { + store.setTypingUsers(groupIdStr, updatedTypingUsers); + } + } + + /** 群通知(禁言/系统消息等) */ + handleGroupNotice(message: WSGroupNoticeMessage): void { + const store = useMessageStore.getState(); + const { notice_type, group_id, data, timestamp, message_id, seq } = message; + const groupIdStr = String(group_id); + + // 处理禁言/解除禁言(仅本人相关) + if (notice_type === 'muted' || notice_type === 'unmuted') { + const mutedUserId = data?.user_id; + const currentUserId = this.deps.getCurrentUserId(); + if (mutedUserId && mutedUserId === currentUserId) { + store.setMutedStatus(groupIdStr, notice_type === 'muted'); + } + } + + // 补一条系统消息到会话消息流(幂等:addOrReplaceMessage 内部去重) + if (message_id && typeof seq === 'number' && seq > 0) { + const conversationId = this.findConversationIdByGroupId(groupIdStr); + if (conversationId) { + const noticeText = this.buildGroupNoticeText(notice_type, data); + const systemNoticeMessage: MessageResponse = { + id: String(message_id), + conversation_id: conversationId, + sender_id: '10000', + seq, + segments: noticeText + ? [{ type: 'text', data: { text: noticeText } as any }] + : [], + status: 'normal', + category: 'notification', + created_at: (() => { + const d = new Date(timestamp || Date.now()); + return Number.isNaN(d.getTime()) ? new Date().toISOString() : d.toISOString(); + })(), + }; + useMessageStore.getState().addOrReplaceMessage(conversationId, systemNoticeMessage); + } + } + } + + /** 系统通知:去重后递增系统未读 */ + handleNotification(message: WSNotificationMessage): void { + if (message.is_read) return; + + // 按通知 ID 去重,防止重连时重复递增 + const notificationId = (message as any).id || (message as any).message_id; + if (notificationId) { + const idStr = String(notificationId); + if (this.processedNotificationIds.has(idStr)) return; + this.processedNotificationIds.add(idStr); + // 防止内存泄漏:超过 500 条淘汰最旧 100 条 + if (this.processedNotificationIds.size > 500) { + const firstIds = Array.from(this.processedNotificationIds).slice(0, 100); + firstIds.forEach(id => this.processedNotificationIds.delete(id)); + } + } + + useMessageStore.getState().incrementSystemUnreadAtomic(); + + if (message.created_at) { + const store = useMessageStore.getState(); + const currentLast = store.lastSystemMessageAt; + const msgDate = new Date(message.created_at); + if (!Number.isNaN(msgDate.getTime()) && (!currentLast || msgDate > new Date(currentLast))) { + store.setLastSystemMessageAt(message.created_at); + } + } + } + + // ==================== 同步触发器 ==================== + + /** + * 统一同步入口:节流 + 去重,onConnect 与 sync_required 共用。 + * bootstrap 期间跳过(由 flushBufferedEvents 统一回放)。 + */ + private async triggerSync(source: string): Promise { + if (this.isBootstrapping) return; + + const now = Date.now(); + if (now - this.lastSyncTriggerAt < MIN_RECONNECT_SYNC_INTERVAL) return; + if (this.syncInProgress) return; + + this.syncInProgress = true; + this.lastSyncTriggerAt = now; + + try { + await this.deps.triggerSync(source); + } catch (error) { + console.error('[RealtimeIngestionPipeline] 同步失败:', error); + } finally { + this.syncInProgress = false; + } + } + + // ==================== 私有工具方法 ==================== + + /** store 已初始化且非 bootstrap 阶段时才直接处理事件 */ + private isReady(): boolean { + return useMessageStore.getState().isInitialized && !this.isBootstrapping; + } + + private applyConversationUpdate( + conversationId: string, + message: MessageResponse, + createdAt: string + ): void { + const store = useMessageStore.getState(); + const conversation = store.getConversation(conversationId); + if (!conversation) return; + + const updatedConv = { + ...conversation, + last_seq: message.seq, + last_message: message, + last_message_at: createdAt, + updated_at: createdAt, + }; + store.updateConversation(updatedConv); + } + + private incrementUnreadCount(conversationId: string): void { + useMessageStore.getState().incrementUnreadAtomic(conversationId); + } + + private markMessageAsRecalled(conversationId: string, messageId: string): void { + const patches = new Map>(); + patches.set(String(messageId), { + status: 'recalled' as MessageResponse['status'], + segments: [], + }); + useMessageStore.getState().patchMessages(conversationId, patches); + } + + private syncConversationLastMessageOnRecall(conversationId: string, messageId: string): void { + const store = useMessageStore.getState(); + const conversation = store.getConversation(conversationId); + if (!conversation?.last_message) return; + if (String(conversation.last_message.id) !== String(messageId)) return; + if (conversation.last_message.status === 'recalled') return; + + const updatedConversation = { + ...conversation, + last_message: { ...conversation.last_message, status: 'recalled' as const }, + }; + store.updateConversation(updatedConversation); + } + + private findConversationIdByGroupId(groupId: string): string | null { + const conversations = useMessageStore.getState().getConversations(); + for (const conv of conversations) { + if (String(conv.group?.id || '') === groupId) { + return conv.id; + } + } + return null; + } + + private buildGroupNoticeText(noticeType: GroupNoticeType, data: any): string { + const username = data?.username || '用户'; + switch (noticeType) { + case 'member_join': + return `"${username}" 加入了群聊`; + case 'member_leave': + return `"${username}" 退出了群聊`; + case 'member_removed': + return `"${username}" 被移出群聊`; + case 'muted': + return `"${username}" 已被管理员禁言`; + case 'unmuted': + return `"${username}" 已被管理员解除禁言`; + default: + return ''; + } + } +} diff --git a/src/stores/message/services/UserCacheService.ts b/src/stores/message/services/UserCacheService.ts index 8c0f774..6023f78 100644 --- a/src/stores/message/services/UserCacheService.ts +++ b/src/stores/message/services/UserCacheService.ts @@ -120,7 +120,4 @@ export class UserCacheService implements IUserCacheService { reset(): void { this.pendingUserRequests.clear(); } -} - -// 单例导出 -export const userCacheService = new UserCacheService(); \ No newline at end of file +} \ No newline at end of file diff --git a/src/stores/message/services/WSMessageHandler.ts b/src/stores/message/services/WSMessageHandler.ts deleted file mode 100644 index ee17cfa..0000000 --- a/src/stores/message/services/WSMessageHandler.ts +++ /dev/null @@ -1,597 +0,0 @@ -/** - * WebSocket 消息处理器 - * 处理所有来自 WebSocket 的消息事件 - */ - -import type { - WSChatMessage, - WSGroupChatMessage, - WSReadMessage, - WSGroupReadMessage, - WSRecallMessage, - WSGroupRecallMessage, - WSGroupTypingMessage, - WSGroupNoticeMessage, - WSNotificationMessage, -} from '@/services/core'; -import { wsService, GroupNoticeType } from '@/services/core'; -import { eventBus } from '@/core/events/EventBus'; -import { messageRepository } from '@/database'; -import type { MessageResponse, ConversationResponse } from '../../../types/dto'; -import type { - IWSMessageHandler, - IMessageDeduplication, - IUserCacheService, - HandleNewMessageOptions, -} from '../types'; -import { MAX_FLUSH_ITERATIONS, MIN_RECONNECT_SYNC_INTERVAL } from '../constants'; -import { useMessageStore, normalizeConversationId } from '../store'; - -export class WSMessageHandler implements IWSMessageHandler { - private deduplication: IMessageDeduplication; - private userCacheService: IUserCacheService; - private getCurrentUserId: () => string | null; - private markAsReadCallback: (conversationId: string, seq: number) => Promise; - private fetchConversationsCallback: (forceRefresh: boolean, source: string) => Promise; - private fetchUnreadCountCallback: () => Promise; - private fetchMessagesCallback: (conversationId: string) => Promise; - private syncBySeqCallback: () => Promise; - - private sseUnsubscribe: (() => void) | null = null; - private isBootstrapping: boolean = false; - private lastReconnectSyncAt: number = 0; - - // 同步触发器状态:防止 onConnect 和 sync_required 并发 - private syncInProgress: boolean = false; - private lastSyncTriggerAt: number = 0; - - // 系统通知去重(防止重连时重复递增系统未读) - private processedNotificationIds: Set = new Set(); - - // 初始化阶段缓冲来自 SSE 的消息事件 - private bufferedChatMessages: Array<(WSChatMessage | WSGroupChatMessage) & { _isAck?: boolean }> = []; - private bufferedReadReceipts: Array = []; - - constructor( - deduplication: IMessageDeduplication, - userCacheService: IUserCacheService, - getCurrentUserId: () => string | null, - callbacks: { - markAsRead: (conversationId: string, seq: number) => Promise; - fetchConversations: (forceRefresh: boolean, source: string) => Promise; - fetchUnreadCount: () => Promise; - fetchMessages: (conversationId: string) => Promise; - syncBySeq: () => Promise; - } - ) { - this.deduplication = deduplication; - this.userCacheService = userCacheService; - this.getCurrentUserId = getCurrentUserId; - this.markAsReadCallback = callbacks.markAsRead; - this.fetchConversationsCallback = callbacks.fetchConversations; - this.fetchUnreadCountCallback = callbacks.fetchUnreadCount; - this.fetchMessagesCallback = callbacks.fetchMessages; - this.syncBySeqCallback = callbacks.syncBySeq; - } - - /** - * 初始化 SSE 监听器 - */ - connect(): void { - if (this.sseUnsubscribe) { - this.sseUnsubscribe(); - } - - wsService.on('chat', (message: WSChatMessage) => { - if (!useMessageStore.getState().isInitialized || this.isBootstrapping) { - this.bufferedChatMessages.push(message); - return; - } - this.handleNewMessage(message); - }); - - wsService.on('group_message', (message: WSGroupChatMessage) => { - if (!useMessageStore.getState().isInitialized || this.isBootstrapping) { - this.bufferedChatMessages.push(message); - return; - } - this.handleNewMessage(message); - }); - - wsService.on('read', (message: WSReadMessage) => { - if (!useMessageStore.getState().isInitialized || this.isBootstrapping) { - this.bufferedReadReceipts.push(message); - return; - } - this.handleReadReceipt(message); - }); - - // 监听群聊已读回执 - wsService.on('group_read', (message: WSGroupReadMessage) => { - if (!useMessageStore.getState().isInitialized || this.isBootstrapping) { - this.bufferedReadReceipts.push(message); - return; - } - this.handleGroupReadReceipt(message); - }); - - // 监听私聊消息撤回 - wsService.on('recall', (message: WSRecallMessage) => { - this.handleRecallMessage(message); - }); - - // 监听群聊消息撤回 - wsService.on('group_recall', (message: WSGroupRecallMessage) => { - this.handleGroupRecallMessage(message); - }); - - // 监听群聊输入状态 - wsService.on('group_typing', (message: WSGroupTypingMessage) => { - this.handleGroupTyping(message); - }); - - // 监听群通知 - wsService.on('group_notice', (message: WSGroupNoticeMessage) => { - this.handleGroupNotice(message); - }); - - // 监听系统通知,实时更新系统未读数 - wsService.on('notification', (message: WSNotificationMessage) => { - if (message.is_read) { - return; - } - - // 按通知 ID 去重,防止重连时重复递增 - const notificationId = (message as any).id || (message as any).message_id; - if (notificationId) { - if (this.processedNotificationIds.has(String(notificationId))) { - return; - } - this.processedNotificationIds.add(String(notificationId)); - // 防止内存泄漏:超过 500 条时淘汰最旧的 100 条 - if (this.processedNotificationIds.size > 500) { - const firstIds = Array.from(this.processedNotificationIds).slice(0, 100); - firstIds.forEach(id => this.processedNotificationIds.delete(id)); - } - } - - this.incrementSystemUnread(); - if (message.created_at) { - const store = useMessageStore.getState(); - const currentLast = store.lastSystemMessageAt; - const msgDate = new Date(message.created_at); - if (Number.isNaN(msgDate.getTime())) return; - if (!currentLast || msgDate > new Date(currentLast)) { - store.setLastSystemMessageAt(message.created_at); - } - } - }); - - // 监听连接状态 - wsService.onConnect(() => { - useMessageStore.getState().setSSEConnected(true); - this.triggerSync('sse-reconnect'); - }); - - // 监听 sync_required 事件,触发增量同步 - wsService.on('sync_required', () => { - console.log('[WSMessageHandler] 收到 sync_required,开始增量同步'); - this.triggerSync('sync_required'); - }); - - wsService.onDisconnect(() => { - useMessageStore.getState().setSSEConnected(false); - }); - } - - /** - * 断开 SSE 连接 - */ - disconnect(): void { - if (this.sseUnsubscribe) { - this.sseUnsubscribe(); - this.sseUnsubscribe = null; - } - this.processedNotificationIds.clear(); - } - - /** - * 检查是否已连接 - */ - isConnected(): boolean { - return useMessageStore.getState().isConnected(); - } - - /** - * 设置启动状态 - */ - setBootstrapping(value: boolean): void { - this.isBootstrapping = value; - } - - /** - * 统一同步入口 — 节流 + 去重 - * onConnect 和 sync_required 共用此入口,防止并发触发重复同步 - * 启动阶段(isBootstrapping=true)跳过,由 MessageManager.initialize 完成后统一触发 - */ - private async triggerSync(source: string): Promise { - if (this.isBootstrapping) return; - - const now = Date.now(); - // 最小间隔节流:避免连续 sync_required 事件短时间内重复触发全量同步 - if (now - this.lastSyncTriggerAt < MIN_RECONNECT_SYNC_INTERVAL) return; - if (this.syncInProgress) return; - - this.syncInProgress = true; - this.lastSyncTriggerAt = now; - - try { - const ok = await this.syncBySeqCallback(); - if (!ok) { - await this.fetchConversationsCallback(true, source); - const activeConv = useMessageStore.getState().getActiveConversation(); - if (activeConv) { - await this.fetchMessagesCallback(activeConv).catch(() => {}); - } - } - await this.fetchUnreadCountCallback().catch(() => {}); - } catch { - await this.fetchConversationsCallback(true, source).catch(() => {}); - const activeConv = useMessageStore.getState().getActiveConversation(); - if (activeConv) { - await this.fetchMessagesCallback(activeConv).catch(() => {}); - } - await this.fetchUnreadCountCallback().catch(() => {}); - } finally { - this.syncInProgress = false; - } - } - - /** - * 初始化完成后,处理缓冲的 SSE 事件 - * 每次循环:取走当前 buffer 后立即清空(避免在 fetch 期间新事件再被覆盖) - * 退出条件:连续一轮没有新事件,确保 flush 期间到达的事件也被消费 - */ - async flushBufferedSSEEvents(): Promise { - let lastChatLen = -1; - let lastReadLen = -1; - for (let i = 0; i < MAX_FLUSH_ITERATIONS; i++) { - const chatEvents = this.bufferedChatMessages; - const readEvents = this.bufferedReadReceipts; - // 上一轮长度未变且都已清空,说明已稳定 - if (chatEvents.length === lastChatLen && readEvents.length === lastReadLen) { - return; - } - lastChatLen = chatEvents.length; - lastReadLen = readEvents.length; - // 立即清空 buffer;flush 期间新到的事件将走正常处理路径(isBootstrapping 已是 false) - this.bufferedChatMessages = []; - this.bufferedReadReceipts = []; - - // 处理消息 - for (const m of chatEvents) { - await this.handleNewMessage(m, { - suppressUnreadIncrement: true, - suppressVibration: true, - suppressConversationUpdates: true, - suppressAutoMarkAsRead: true, - }); - } - - // 处理已读回执 - for (const r of readEvents) { - if (r.type === 'read') { - this.handleReadReceipt(r); - } else { - this.handleGroupReadReceipt(r as WSGroupReadMessage); - } - } - - // 使用服务器权威刷新 - await this.fetchConversationsCallback(true, 'sse-buffer-flush'); - await this.fetchUnreadCountCallback(); - } - } - - /** - * 处理新消息(SSE推送) - */ - async handleNewMessage( - message: (WSChatMessage | WSGroupChatMessage) & { _isAck?: boolean }, - options?: HandleNewMessageOptions - ): Promise { - const store = useMessageStore.getState(); - const { conversation_id, id, sender_id, seq, segments, created_at, _isAck } = message; - const normalizedConversationId = normalizeConversationId(conversation_id); - const currentUserId = this.getCurrentUserId(); - const suppressUnreadIncrement = options?.suppressUnreadIncrement ?? false; - const suppressVibration = options?.suppressVibration ?? false; - const suppressConversationUpdates = options?.suppressConversationUpdates ?? false; - const suppressAutoMarkAsRead = options?.suppressAutoMarkAsRead ?? false; - - // 消息去重检查 - if (this.deduplication.isMessageProcessed(id)) { - return; - } - this.deduplication.markMessageAsProcessed(id); - - // 发送 ACK 确认 - wsService.sendAck(id); - - // 对于群聊消息,获取发送者信息 - if (message.type === 'group_message' && sender_id && sender_id !== currentUserId && sender_id !== '10000') { - this.userCacheService.getSenderInfo(sender_id).then(user => { - if (user) { - const patches = new Map>(); - patches.set(String(id), { sender: user }); - useMessageStore.getState().patchMessages(normalizedConversationId, patches); - } - }).catch(error => { - console.error('[WSMessageHandler] 获取发送者信息失败:', { userId: sender_id, error }); - }); - } - - // 构造消息对象 - const newMessage: MessageResponse = { - id, - conversation_id: normalizedConversationId, - sender_id, - seq: typeof seq === 'string' ? parseInt(seq, 10) : (seq ?? 0), - segments: segments || [], - created_at, - status: 'normal', - }; - - // 立即更新内存中的消息列表 — 使用原子性 mergeMessages 防止并发写入丢失 - const messageExists = useMessageStore.getState().getMessages(normalizedConversationId).some(m => String(m.id) === String(id)); - - if (!messageExists) { - useMessageStore.getState().mergeMessages(normalizedConversationId, [newMessage]); - } - - // 更新会话信息 - if (!suppressConversationUpdates) { - this.updateConversationWithNewMessage(normalizedConversationId, newMessage, created_at); - } - - // 更新未读数 - const isCurrentUserMessage = sender_id === currentUserId; - const isActiveConversation = normalizedConversationId === store.getActiveConversation(); - - const shouldIncrementUnread = - !suppressUnreadIncrement && - !isCurrentUserMessage && - !isActiveConversation && - !!currentUserId && - !_isAck; - - if (shouldIncrementUnread) { - const isNotificationMuted = store.isNotificationMuted(normalizedConversationId); - if (!suppressVibration && !isNotificationMuted) { - const vibrationType = message.type === 'group_message' ? 'group_message' : 'message'; - eventBus.emit({ type: 'VIBRATE', payload: { type: vibrationType } }); - } - this.incrementUnreadCount(normalizedConversationId); - } - - // 如果是当前活动会话,自动标记已读 - if (isActiveConversation && !isCurrentUserMessage && !suppressAutoMarkAsRead) { - this.markAsReadCallback(normalizedConversationId, seq).catch(() => {}); - } - - // 异步保存到本地数据库 - const textContent = segments?.filter((s: any) => s.type === 'text').map((s: any) => s.data?.text || '').join('') || ''; - messageRepository.saveMessage({ - id, - conversationId: normalizedConversationId, - senderId: sender_id || '', - content: textContent, - type: segments?.find((s: any) => s.type === 'image') ? 'image' : 'text', - isRead: isActiveConversation, - createdAt: (() => { const d = new Date(created_at); return Number.isNaN(d.getTime()) ? new Date().toISOString() : d.toISOString(); })(), - seq, - status: 'normal', - segments, - }).catch(error => { - console.error('[WSMessageHandler] 保存消息到本地失败:', error); - }); - } - - /** - * 处理私聊已读回执 - */ - handleReadReceipt(message: WSReadMessage): void { - // 可以在这里处理对方已读的状态更新 - } - - /** - * 处理群聊已读回执 - */ - handleGroupReadReceipt(message: WSGroupReadMessage): void { - // 群聊已读回执处理 - } - - /** - * 处理私聊消息撤回 - */ - handleRecallMessage(message: WSRecallMessage): void { - const { conversation_id, message_id } = message; - const normalizedConversationId = normalizeConversationId(conversation_id); - - this.markMessageAsRecalled(normalizedConversationId, message_id); - this.syncConversationLastMessageOnRecall(normalizedConversationId, message_id); - - messageRepository.updateStatus(message_id, 'recalled', true).catch(error => { - console.error('[WSMessageHandler] 更新本地消息撤回状态失败:', error); - }); - } - - /** - * 处理群聊消息撤回 - */ - handleGroupRecallMessage(message: WSGroupRecallMessage): void { - const { conversation_id, message_id } = message; - const normalizedConversationId = normalizeConversationId(conversation_id); - - this.markMessageAsRecalled(normalizedConversationId, message_id); - this.syncConversationLastMessageOnRecall(normalizedConversationId, message_id); - - messageRepository.updateStatus(message_id, 'recalled', true).catch(error => { - console.error('[WSMessageHandler] 更新本地消息撤回状态失败:', error); - }); - } - - /** - * 处理群聊输入状态 - */ - handleGroupTyping(message: WSGroupTypingMessage): void { - const store = useMessageStore.getState(); - const { group_id, user_id, is_typing } = message; - const groupIdStr = String(group_id); - - const currentTypingUsers = store.getTypingUsers(groupIdStr); - let updatedTypingUsers: string[]; - - if (is_typing) { - if (!currentTypingUsers.includes(user_id)) { - updatedTypingUsers = [...currentTypingUsers, user_id]; - } else { - updatedTypingUsers = currentTypingUsers; - } - } else { - updatedTypingUsers = currentTypingUsers.filter(id => id !== user_id); - } - - if (updatedTypingUsers.length !== currentTypingUsers.length) { - store.setTypingUsers(groupIdStr, updatedTypingUsers); - } - } - - /** - * 处理群通知 - */ - handleGroupNotice(message: WSGroupNoticeMessage): void { - const store = useMessageStore.getState(); - const { notice_type, group_id, data, timestamp, message_id, seq } = message; - const groupIdStr = String(group_id); - - // 处理禁言/解除禁言通知 - if (notice_type === 'muted' || notice_type === 'unmuted') { - const mutedUserId = data?.user_id; - const currentUserId = this.getCurrentUserId(); - - if (mutedUserId && mutedUserId === currentUserId) { - const isMuted = notice_type === 'muted'; - store.setMutedStatus(groupIdStr, isMuted); - } - } - - // 补一条系统消息到当前会话消息流(原子性 merge) - if (message_id && typeof seq === 'number' && seq > 0) { - const conversationId = this.findConversationIdByGroupId(groupIdStr); - if (conversationId) { - const messageExists = store.getMessages(conversationId).some(m => String(m.id) === String(message_id)); - if (!messageExists) { - const noticeText = this.buildGroupNoticeText(notice_type, data); - const systemNoticeMessage: MessageResponse = { - id: String(message_id), - conversation_id: conversationId, - sender_id: '10000', - seq, - segments: noticeText - ? [{ type: 'text', data: { text: noticeText } as any }] - : [], - status: 'normal', - category: 'notification', - created_at: (() => { const d = new Date(timestamp || Date.now()); return Number.isNaN(d.getTime()) ? new Date().toISOString() : d.toISOString(); })(), - }; - - useMessageStore.getState().mergeMessages(conversationId, [systemNoticeMessage]); - } - } - } - } - - // ==================== 私有工具方法 ==================== - - private updateConversationWithNewMessage( - conversationId: string, - message: MessageResponse, - createdAt: string - ): void { - const store = useMessageStore.getState(); - const conversation = store.getConversation(conversationId); - - if (conversation) { - const updatedConv: ConversationResponse = { - ...conversation, - last_seq: message.seq, - last_message: message, - last_message_at: createdAt, - updated_at: createdAt, - }; - store.updateConversation(updatedConv); - } - } - - private incrementUnreadCount(conversationId: string): void { - useMessageStore.getState().incrementUnreadAtomic(conversationId); - } - - private incrementSystemUnread(): void { - // 原子递增:避免外部 read-modify-write 造成丢计数 - useMessageStore.getState().incrementSystemUnreadAtomic(); - } - - private markMessageAsRecalled(conversationId: string, messageId: string): void { - const patches = new Map>(); - patches.set(String(messageId), { status: 'recalled' as MessageResponse['status'], segments: [] }); - useMessageStore.getState().patchMessages(conversationId, patches); - } - - private syncConversationLastMessageOnRecall(conversationId: string, messageId: string): void { - const store = useMessageStore.getState(); - const conversation = store.getConversation(conversationId); - if (!conversation?.last_message) return; - if (String(conversation.last_message.id) !== String(messageId)) return; - if (conversation.last_message.status === 'recalled') return; - - const updatedConversation: ConversationResponse = { - ...conversation, - last_message: { - ...conversation.last_message, - status: 'recalled', - }, - }; - store.updateConversation(updatedConversation); - } - - private findConversationIdByGroupId(groupId: string): string | null { - const store = useMessageStore.getState(); - const conversations = store.getConversations(); - for (const conv of conversations) { - if (String(conv.group?.id || '') === groupId) { - return conv.id; - } - } - return null; - } - - private buildGroupNoticeText(noticeType: GroupNoticeType, data: any): string { - const username = data?.username || '用户'; - switch (noticeType) { - case 'member_join': - return `"${username}" 加入了群聊`; - case 'member_leave': - return `"${username}" 退出了群聊`; - case 'member_removed': - return `"${username}" 被移出群聊`; - case 'muted': - return `"${username}" 已被管理员禁言`; - case 'unmuted': - return `"${username}" 已被管理员解除禁言`; - default: - return ''; - } - } -} \ No newline at end of file diff --git a/src/stores/message/services/index.ts b/src/stores/message/services/index.ts index 47ec3f9..1d150fb 100644 --- a/src/stores/message/services/index.ts +++ b/src/stores/message/services/index.ts @@ -2,11 +2,15 @@ * 消息服务层统一导出 */ -// 消息去重服务 -export { MessageDeduplication, messageDeduplication } from './MessageDeduplication'; +// 实时消息入口(幂等 ingest + 监听器生命周期) +export { RealtimeIngestionPipeline } from './RealtimeIngestionPipeline'; +export type { + RealtimeIngestionPipelineDeps, + IngestOptions, +} from './RealtimeIngestionPipeline'; // 用户缓存服务 -export { UserCacheService, userCacheService } from './UserCacheService'; +export { UserCacheService } from './UserCacheService'; // 已读回执管理器 export { ReadReceiptManager } from './ReadReceiptManager'; @@ -19,6 +23,3 @@ export { MessageSendService } from './MessageSendService'; // 消息同步服务 export { MessageSyncService } from './MessageSyncService'; - -// WebSocket 消息处理器 -export { WSMessageHandler } from './WSMessageHandler'; \ No newline at end of file diff --git a/src/stores/message/store.ts b/src/stores/message/store.ts index cfaf840..ebea297 100644 --- a/src/stores/message/store.ts +++ b/src/stores/message/store.ts @@ -85,6 +85,16 @@ export interface MessageActions { removeConversation: (conversationId: string) => void; setMessages: (conversationId: string, messages: MessageResponse[]) => void; mergeMessages: (conversationId: string, incoming: MessageResponse[]) => void; + /** + * 幂等写入单条消息:以 message id 为幂等键,在 zustand set 原子边界内去重。 + * 返回 true 表示这条消息是新写入的(store 之前没有同 id 的消息), + * 返回 false 表示已存在(被静默去重,副作用调用方应据此跳过未读/通知等副作用)。 + * + * 这是消息幂等性的唯一真理源:任何来源(WS 推送、REST 拉取、发送回显、 + * loadMore)都应通过 addOrReplaceMessage/mergeMessages 走 store,避免上层 + * 维护独立去重表带来的「标记早于落库」竞态。 + */ + addOrReplaceMessage: (conversationId: string, message: MessageResponse) => boolean; removeMessage: (conversationId: string, messageId: string) => void; patchMessages: (conversationId: string, patches: Map>) => void; setUnreadCount: (total: number, system: number) => void; @@ -92,6 +102,15 @@ export interface MessageActions { * 原子递增系统未读数:避免外部 RMW 造成丢失 */ incrementSystemUnreadAtomic: () => void; + /** + * 原子设置系统未读数:保持 totalUnreadCount 一致性(total = sum(各会话未读) + system), + * 在 zustand set 回调内一次性完成读-改-写,避免外部 RMW 丢更新。 + */ + setSystemUnreadAtomic: (count: number) => void; + /** + * 原子递减系统未读数(不低于 0):避免外部 RMW 造成丢失。 + */ + decrementSystemUnreadAtomic: (count?: number) => void; setLastSystemMessageAt: (time: string | null) => void; setSSEConnected: (connected: boolean) => void; setCurrentConversation: (conversationId: string | null) => void; @@ -364,6 +383,31 @@ export const useMessageStore = create((set, get) => ({ }); }, + /** + * 幂等写入单条消息,返回是否为新写入。 + * 去重发生在 zustand set 的原子边界内,调用方据此决定是否触发未读/通知等副作用。 + */ + addOrReplaceMessage: (conversationId: string, message: MessageResponse): boolean => { + const normalizedId = normalizeConversationId(conversationId); + let inserted = false; + set(state => { + const existing = state.messagesMap.get(normalizedId); + const targetId = String(message.id); + // 已存在同 id:静默去重(幂等),不视为新增 + if (existing && existing.some(m => String(m.id) === targetId)) { + inserted = false; + return state; + } + inserted = true; + const base = existing || []; + const merged = mergeMessagesById(base, [message]); + const newMessagesMap = new Map(state.messagesMap); + newMessagesMap.set(normalizedId, merged); + return { messagesMap: newMessagesMap }; + }); + return inserted; + }, + removeMessage: (conversationId: string, messageId: string) => { const normalizedId = normalizeConversationId(conversationId); set(state => { @@ -414,6 +458,27 @@ export const useMessageStore = create((set, get) => ({ })); }, + setSystemUnreadAtomic: (count: number) => { + set(state => { + const delta = count - state.systemUnreadCount; + return { + systemUnreadCount: count, + totalUnreadCount: Math.max(0, state.totalUnreadCount + delta), + }; + }); + }, + + decrementSystemUnreadAtomic: (count = 1) => { + set(state => { + const newSystemCount = Math.max(0, state.systemUnreadCount - count); + const actualDecrement = state.systemUnreadCount - newSystemCount; + return { + systemUnreadCount: newSystemCount, + totalUnreadCount: Math.max(0, state.totalUnreadCount - actualDecrement), + }; + }); + }, + setLastSystemMessageAt: (time: string | null) => { set({ lastSystemMessageAt: time }); if (time) { diff --git a/src/stores/message/types.ts b/src/stores/message/types.ts index f88d7c7..842028a 100644 --- a/src/stores/message/types.ts +++ b/src/stores/message/types.ts @@ -22,12 +22,6 @@ export interface HandleNewMessageOptions { suppressAutoMarkAsRead?: boolean; } -export interface IMessageDeduplication { - isMessageProcessed(messageId: string): boolean; - markMessageAsProcessed(messageId: string): void; - cleanupProcessedMessageIds(): void; -} - export interface IUserCacheService { getSenderInfo(userId: string): Promise; enrichMessagesWithSenderInfo( @@ -40,11 +34,6 @@ export interface IUserCacheService { export interface IReadReceiptManager { markAsRead(conversationId: string, seq: number): Promise; markAllAsRead(): Promise; - beginReadOperation(conversationId: string, seq: number): number; - endReadOperation(conversationId: string, version: number, success: boolean): void; - isReadOperationPending(conversationId: string): boolean; - getReadStateVersion(): number; - getPendingReadMap(): Map; } export interface IConversationOperations { @@ -77,9 +66,3 @@ export interface IMessageSyncService { canLoadMoreConversations(): boolean; restartSources(): void; } - -export interface IWSMessageHandler { - connect(): void; - disconnect(): void; - isConnected(): boolean; -} diff --git a/src/stores/user/UserManager.ts b/src/stores/user/UserManager.ts index 431246e..dfa35bd 100644 --- a/src/stores/user/UserManager.ts +++ b/src/stores/user/UserManager.ts @@ -14,6 +14,14 @@ import { createDedupe } from '../utils/requestDedupe'; const dedupe = createDedupe(() => useUserManagerStore); +// 将 fetchCurrentUserFromAPI 的判别结果转为 User | null。 +// 网络故障/认证失败时返回 null,由上层缓存兜底(避免空数据覆盖缓存)。 +// 注意:UserManager 只负责缓存,不触发登出,登出由 authStore 统一处理。 +async function fetchCurrentUserOrNull(): Promise { + const result = await authService.fetchCurrentUserFromAPI(); + return result.kind === 'user' ? result.user : null; +} + // ==================== UserManager 类 ==================== class UserManager { @@ -41,11 +49,11 @@ class UserManager { } return dedupe('users:current', async () => { - const user = await authService.fetchCurrentUserFromAPI(); - const newEntry = createCacheEntry(user, DEFAULT_TTL.USER); - store.setCurrentUserEntry(newEntry); - + const user = await fetchCurrentUserOrNull(); + // 仅在拿到真实用户时刷新缓存;网络/认证失败时保留旧缓存,避免误清空 if (user) { + const newEntry = createCacheEntry(user, DEFAULT_TTL.USER); + store.setCurrentUserEntry(newEntry); userCacheRepository.saveCurrent(user).catch(() => {}); userCacheRepository.save(user).catch(() => {}); } @@ -56,11 +64,10 @@ class UserManager { private refreshCurrentUserInBackground(): void { dedupe('users:current:bg', async () => { const store = useUserManagerStore.getState(); - const user = await authService.fetchCurrentUserFromAPI(); - const newEntry = createCacheEntry(user, DEFAULT_TTL.USER); - store.setCurrentUserEntry(newEntry); - + const user = await fetchCurrentUserOrNull(); if (user) { + const newEntry = createCacheEntry(user, DEFAULT_TTL.USER); + store.setCurrentUserEntry(newEntry); userCacheRepository.saveCurrent(user).catch(() => {}); userCacheRepository.save(user).catch(() => {}); } diff --git a/tsconfig.json b/tsconfig.json index fa4382f..e7a4750 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,5 +9,6 @@ "skipLibCheck": true, "moduleResolution": "bundler", "resolveJsonModule": true - } + }, + "exclude": ["node_modules", "**/__tests__/**", "**/*.test.ts", "**/*.test.tsx", "jest.config.js"] }