diff --git a/.env.example b/.env.example index cd56eb5..baeded7 100644 --- a/.env.example +++ b/.env.example @@ -22,3 +22,7 @@ VITE_FILTER_MSG_TYPES= # AES Key: 16-character string, derived from wxid and code VITE_IMAGE_XOR_KEY= VITE_IMAGE_AES_KEY= + +# Electron E2E test window close delay in milliseconds. +# Local default: 2000 (2 seconds). Set to 0 for immediate close. +WXE_E2E_CLOSE_DELAY_MS=2000 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..f5066f6 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,76 @@ +name: Tests + +on: + push: + pull_request: + +jobs: + desktop-tests: + name: ${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [windows-latest, macos-latest] + + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + with: + version: 7.33.7 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + - uses: actions/setup-go@v5 + with: + go-version-file: services/wechat-connector/go.mod + cache-dependency-path: services/wechat-connector/go.sum + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Type check + run: pnpm typecheck + + - name: Unit tests + run: pnpm test:unit + + - name: Component tests + run: pnpm test:component + + - name: IPC integration tests + run: pnpm test:integration + + - name: Skill installation instruction tests + run: pnpm test:skill-install + + - name: WeChat connector tests + run: pnpm test:wechat-connector + + - name: Build Electron test application + run: pnpm test:e2e:build + + - name: Electron E2E tests + run: pnpm exec playwright test --grep-invert @visual + env: + WXE_E2E_CLOSE_DELAY_MS: 0 + + - name: Platform visual regression + run: pnpm exec playwright test tests/e2e/visual.spec.ts + env: + WXE_E2E_CLOSE_DELAY_MS: 0 + + - name: Upload Playwright diagnostics + if: always() + uses: actions/upload-artifact@v4 + with: + name: playwright-${{ matrix.os }} + path: | + test-results/ + playwright-report/ + if-no-files-found: ignore + retention-days: 14 diff --git a/.gitignore b/.gitignore index f69de56..5ee19a7 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,9 @@ out .DS_Store .eslintcache *.log* +coverage/ +playwright-report/ +test-results/ resources/connectors/wechat/ .omc .codex/ diff --git a/package.json b/package.json index f31fe44..a32ac75 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ }, "main": "./out/main/index.js", "scripts": { + "test": "pnpm typecheck && pnpm test:unit && pnpm test:component && pnpm test:integration && pnpm test:skill-install && pnpm test:wechat-connector && pnpm test:e2e:build && playwright test", "format": "prettier --write .", "lint": "eslint --cache .", "typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false", @@ -28,7 +29,13 @@ "start": "electron-vite preview", "dev": "node scripts/ensure-env.cjs && node scripts/build-wechat-connector.cjs && electron-vite dev", "test:wechat-connector": "go -C services/wechat-connector test ./... && go -C services/wechat-connector vet ./...", - "test:stability": "node --experimental-strip-types --test tests/stability-compat.test.mjs", + "test:unit": "vitest run --config vitest.unit.config.ts", + "test:component": "vitest run --config vitest.component.config.ts", + "test:integration": "vitest run --config vitest.integration.config.ts", + "test:e2e:build": "electron-vite build", + "test:e2e": "pnpm test:e2e:build && playwright test --grep-invert @visual", + "test:visual": "pnpm test:e2e:build && playwright test tests/e2e/visual.spec.ts", + "test:smoke": "node --test tests/smoke/native-environment.test.mjs", "build:wechat-connector": "node scripts/build-wechat-connector.cjs", "build:wechat-connector:win": "node scripts/build-wechat-connector.cjs --platform win32 --arch x64,arm64", "build:wechat-connector:mac": "node scripts/build-wechat-connector.cjs --platform darwin --arch x64,arm64", @@ -47,11 +54,11 @@ "build:linux": "electron-vite build && electron-builder --config electron-builder.yml --linux" }, "dependencies": { - "cross-env": "^10.1.0", "@electron-toolkit/preload": "^3.0.2", "@electron-toolkit/utils": "^4.0.0", "@koromix/koffi-win32-x64": "3.1.0", "@tanstack/react-virtual": "^3.14.6", + "cross-env": "^10.1.0", "electron-updater": "^6.6.2", "fs-extra": "^11.3.2", "fzstd": "^0.1.1", @@ -65,12 +72,18 @@ "@electron-toolkit/eslint-config-prettier": "^3.0.0", "@electron-toolkit/eslint-config-ts": "^3.1.0", "@electron-toolkit/tsconfig": "^2.0.0", + "@playwright/test": "^1.62.1", "@rollup/rollup-darwin-arm64": "^4.62.2", + "@testing-library/dom": "^10.4.1", + "@testing-library/jest-dom": "^7.0.0", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/fs-extra": "^11.0.4", "@types/node": "^22.19.1", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^5.1.1", + "@vitest/coverage-v8": "^4.1.10", "electron": "^43.0.0", "electron-builder": "^26.0.12", "electron-vite": "^5.0.0", @@ -78,12 +91,14 @@ "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.4.24", + "jsdom": "^30.0.1", "prettier": "^3.7.4", "react": "^19.2.1", "react-dom": "^19.2.1", "sass": "^1.102.0", "typescript": "^5.9.3", - "vite": "^7.2.6" + "vite": "^7.2.6", + "vitest": "^4.1.10" }, "pnpm": { "supportedArchitectures": { diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..81574fc --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,22 @@ +import { defineConfig } from '@playwright/test' + +export default defineConfig({ + testDir: './tests/e2e', + testMatch: /.*\.spec\.ts/, + timeout: 45_000, + expect: { timeout: 8_000 }, + fullyParallel: false, + workers: 1, + forbidOnly: Boolean(process.env.CI), + retries: process.env.CI ? 1 : 0, + reporter: process.env.CI + ? [['line'], ['html', { outputFolder: 'playwright-report', open: 'never' }]] + : [['list'], ['html', { outputFolder: 'playwright-report', open: 'never' }]], + outputDir: 'test-results', + snapshotPathTemplate: 'tests/e2e/__screenshots__/{platform}/{testFilePath}/{arg}{ext}', + use: { + trace: 'retain-on-failure', + screenshot: 'only-on-failure', + video: 'retain-on-failure' + } +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a890ac1..fc19f3a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,13 +11,19 @@ specifiers: '@electron-toolkit/tsconfig': ^2.0.0 '@electron-toolkit/utils': ^4.0.0 '@koromix/koffi-win32-x64': 3.1.0 + '@playwright/test': ^1.62.1 '@rollup/rollup-darwin-arm64': ^4.62.2 '@tanstack/react-virtual': ^3.14.6 + '@testing-library/dom': ^10.4.1 + '@testing-library/jest-dom': ^7.0.0 + '@testing-library/react': ^16.3.2 + '@testing-library/user-event': ^14.6.1 '@types/fs-extra': ^11.0.4 '@types/node': ^22.19.1 '@types/react': ^19.2.7 '@types/react-dom': ^19.2.3 '@vitejs/plugin-react': ^5.1.1 + '@vitest/coverage-v8': ^4.1.10 cross-env: ^10.1.0 electron: ^43.0.0 electron-builder: ^26.0.12 @@ -29,6 +35,7 @@ specifiers: eslint-plugin-react-refresh: ^0.4.24 fs-extra: ^11.3.2 fzstd: ^0.1.1 + jsdom: ^30.0.1 jsonrepair: ^3.15.0 koffi: ^3.1.0 openai: ^6.10.0 @@ -39,6 +46,7 @@ specifiers: silk-wasm: ^3.7.1 typescript: ^5.9.3 vite: ^7.2.6 + vitest: ^4.1.10 wechat-emojis: ^1.0.2 dependencies: @@ -60,12 +68,18 @@ devDependencies: '@electron-toolkit/eslint-config-prettier': 3.0.0_fiitszekoa4sqtbwyewxi6kyy4 '@electron-toolkit/eslint-config-ts': 3.1.0_ohw7ybem3stexzc2ktgcr2xgzi '@electron-toolkit/tsconfig': 2.0.0_@types+node@22.19.2 + '@playwright/test': 1.62.1 '@rollup/rollup-darwin-arm64': 4.62.2 + '@testing-library/dom': 10.4.1 + '@testing-library/jest-dom': 7.0.0_rwcxf7kajngkz26znmtz643644 + '@testing-library/react': 16.3.2_ifbmwgqdqpqwyvhobr7vc7fmyu + '@testing-library/user-event': 14.6.1_rwcxf7kajngkz26znmtz643644 '@types/fs-extra': 11.0.4 '@types/node': 22.19.2 '@types/react': 19.2.7 '@types/react-dom': 19.2.3_@types+react@19.2.7 '@vitejs/plugin-react': 5.1.2_vite@7.2.7 + '@vitest/coverage-v8': 4.1.10_vitest@4.1.10 electron: 43.1.0 electron-builder: 26.0.12 electron-vite: 5.0.0_vite@7.2.7 @@ -73,12 +87,14 @@ devDependencies: eslint-plugin-react: 7.37.5_eslint@9.39.1 eslint-plugin-react-hooks: 7.0.1_eslint@9.39.1 eslint-plugin-react-refresh: 0.4.24_eslint@9.39.1 + jsdom: 30.0.1 prettier: 3.7.4 react: 19.2.1 react-dom: 19.2.1_react@19.2.1 sass: 1.102.0 typescript: 5.9.3 vite: 7.2.7_kx7yyowhop237vgn7dp2p54shq + vitest: 4.1.10_vx7uwpt5cwqrhusdpoa262p5gm packages: @@ -86,11 +102,36 @@ packages: resolution: {integrity: sha512-ukTPVhqG4jNzMro2qA9HSCSSVJN3aN7tlb+hfqYCt3ER0yWroeA2VR38MNrOHLQ/cVj+DaIMad0kFCtWWowh/A==} dev: true + /@adobe/css-tools/4.5.0: + resolution: {integrity: sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==} + dev: true + + /@asamuzakjp/css-color/6.0.5: + resolution: {integrity: sha512-mbhpPMmnw/kwW19aRNmSUl1QzLbdGo1SCuE49BT98MNwqF6zaHb3o2owssFc/PEO/4t2UjqtCNwocuDtJornzA==} + engines: {node: ^22.13.0 || >=24.0.0} + dependencies: + '@csstools/css-calc': 3.3.0_pa3hd4j4wjku7uaqbbhzkpahzy + '@csstools/css-color-parser': 4.1.10_pa3hd4j4wjku7uaqbbhzkpahzy + '@csstools/css-parser-algorithms': 4.0.0_itoglcgmvufaigicydd4iuewyu + '@csstools/css-tokenizer': 4.0.0 + lru-cache: 11.5.2 + dev: true + + /@asamuzakjp/dom-selector/8.3.0: + resolution: {integrity: sha512-UJLfKXBhrc8i1vH2eJXuYQMwlsLKWFw3O+CPqXSuVEiikeAim3UgrfWX0k4tA/X8cRFM8iZ7OaqBokFGbYusdg==} + engines: {node: ^22.13.0 || >=24.0.0} + dependencies: + bidi-js: 1.0.3 + css-tree: 3.2.1 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.5.2 + dev: true + /@babel/code-frame/7.27.1: resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} engines: {node: '>=6.9.0'} dependencies: - '@babel/helper-validator-identifier': 7.28.5 + '@babel/helper-validator-identifier': 7.29.7 js-tokens: 4.0.0 picocolors: 1.1.1 dev: true @@ -168,7 +209,7 @@ packages: dependencies: '@babel/core': 7.28.5 '@babel/helper-module-imports': 7.27.1 - '@babel/helper-validator-identifier': 7.28.5 + '@babel/helper-validator-identifier': 7.29.7 '@babel/traverse': 7.28.5 transitivePeerDependencies: - supports-color @@ -184,8 +225,13 @@ packages: engines: {node: '>=6.9.0'} dev: true - /@babel/helper-validator-identifier/7.28.5: - resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + /@babel/helper-string-parser/7.29.7: + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + dev: true + + /@babel/helper-validator-identifier/7.29.7: + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} engines: {node: '>=6.9.0'} dev: true @@ -210,6 +256,14 @@ packages: '@babel/types': 7.28.5 dev: true + /@babel/parser/7.29.8: + resolution: {integrity: sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==} + engines: {node: '>=6.0.0'} + hasBin: true + dependencies: + '@babel/types': 7.29.8 + dev: true + /@babel/plugin-transform-arrow-functions/7.27.1_@babel+core@7.28.5: resolution: {integrity: sha512-8Z4TGic6xW70FKThA5HYEKKyBpOOsucTOD1DjU3fZxDg+K3zBJcXMFnt/4yQiZnf5+MiOMSXQ9PaEK/Ilh1DeA==} engines: {node: '>=6.9.0'} @@ -240,6 +294,11 @@ packages: '@babel/helper-plugin-utils': 7.27.1 dev: true + /@babel/runtime/7.29.7: + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + dev: true + /@babel/template/7.27.2: resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==} engines: {node: '>=6.9.0'} @@ -269,7 +328,81 @@ packages: engines: {node: '>=6.9.0'} dependencies: '@babel/helper-string-parser': 7.27.1 - '@babel/helper-validator-identifier': 7.28.5 + '@babel/helper-validator-identifier': 7.29.7 + dev: true + + /@babel/types/7.29.8: + resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + dev: true + + /@bcoe/v8-coverage/1.0.2: + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} + engines: {node: '>=18'} + dev: true + + /@bramus/specificity/2.4.2: + resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} + hasBin: true + dependencies: + css-tree: 3.2.1 + dev: true + + /@csstools/color-helpers/6.1.0: + resolution: {integrity: sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==} + engines: {node: '>=20.19.0'} + dev: true + + /@csstools/css-calc/3.3.0_pa3hd4j4wjku7uaqbbhzkpahzy: + resolution: {integrity: sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + dependencies: + '@csstools/css-parser-algorithms': 4.0.0_itoglcgmvufaigicydd4iuewyu + '@csstools/css-tokenizer': 4.0.0 + dev: true + + /@csstools/css-color-parser/4.1.10_pa3hd4j4wjku7uaqbbhzkpahzy: + resolution: {integrity: sha512-UZhQLIUyJaaMepqehrCODwCg2KW25vFvLWBmqYFaPclYvvxzj/sG8LBOhBFCp11i9uE7t1EyS+RAoV9tztPFyw==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + dependencies: + '@csstools/color-helpers': 6.1.0 + '@csstools/css-calc': 3.3.0_pa3hd4j4wjku7uaqbbhzkpahzy + '@csstools/css-parser-algorithms': 4.0.0_itoglcgmvufaigicydd4iuewyu + '@csstools/css-tokenizer': 4.0.0 + dev: true + + /@csstools/css-parser-algorithms/4.0.0_itoglcgmvufaigicydd4iuewyu: + resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-tokenizer': ^4.0.0 + dependencies: + '@csstools/css-tokenizer': 4.0.0 + dev: true + + /@csstools/css-syntax-patches-for-csstree/1.1.7_css-tree@3.2.1: + resolution: {integrity: sha512-fQ+05118eQS1cofO3aJpB5efgpBZMvIzwr/sbC8kDLVA5XLG8q1kJV5yzrUAI1f7lvhPnm8fgIjzFB8/O/5Dig==} + peerDependencies: + css-tree: ^3.2.1 + peerDependenciesMeta: + css-tree: + optional: true + dependencies: + css-tree: 3.2.1 + dev: true + + /@csstools/css-tokenizer/4.0.0: + resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} + engines: {node: '>=20.19.0'} dev: true /@develar/schema-utils/2.6.5: @@ -726,6 +859,16 @@ packages: levn: 0.4.1 dev: true + /@exodus/bytes/1.15.1: + resolution: {integrity: sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@noble/hashes': ^1.8.0 || ^2.0.0 + peerDependenciesMeta: + '@noble/hashes': + optional: true + dev: true + /@gar/promisify/1.1.3: resolution: {integrity: sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==} dev: true @@ -1077,6 +1220,14 @@ packages: engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} dev: true + /@playwright/test/1.62.1: + resolution: {integrity: sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==} + engines: {node: '>=20'} + hasBin: true + dependencies: + playwright: 1.62.1 + dev: true + /@rolldown/pluginutils/1.0.0-beta.53: resolution: {integrity: sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==} dev: true @@ -1257,6 +1408,10 @@ packages: engines: {node: '>=10'} dev: true + /@standard-schema/spec/1.1.0: + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + dev: true + /@szmarczak/http-timer/4.0.6: resolution: {integrity: sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==} engines: {node: '>=10'} @@ -1279,11 +1434,76 @@ packages: resolution: {integrity: sha512-nGm5KteqxasUdThLc2izl6dHUqLv0LQj7Nuyo5gYalTPf/U8a9ermvsl7reT+6ioBW1l8WfpP/mcU338nLXpqw==} dev: false + /@testing-library/dom/10.4.1: + resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} + engines: {node: '>=18'} + dependencies: + '@babel/code-frame': 7.27.1 + '@babel/runtime': 7.29.7 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + dev: true + + /@testing-library/jest-dom/7.0.0_rwcxf7kajngkz26znmtz643644: + resolution: {integrity: sha512-HKAH9C6mBo5yBG6yRO5i43L2iisencAo5z+o5P/saHUoY+miC5ivXRxHBJcFyB5ypPNxHJdK3BoF/3O4DIptMg==} + engines: {node: '>=22', npm: '>=6', yarn: '>=1'} + peerDependencies: + '@testing-library/dom': '>=10 <11' + dependencies: + '@adobe/css-tools': 4.5.0 + '@testing-library/dom': 10.4.1 + aria-query: 5.3.2 + css.escape: 1.5.1 + dom-accessibility-api: 0.6.3 + picocolors: 1.1.1 + redent: 3.0.0 + dev: true + + /@testing-library/react/16.3.2_ifbmwgqdqpqwyvhobr7vc7fmyu: + resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@babel/runtime': 7.29.7 + '@testing-library/dom': 10.4.1 + '@types/react': 19.2.7 + '@types/react-dom': 19.2.3_@types+react@19.2.7 + react: 19.2.1 + react-dom: 19.2.1_react@19.2.1 + dev: true + + /@testing-library/user-event/14.6.1_rwcxf7kajngkz26znmtz643644: + resolution: {integrity: sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==} + engines: {node: '>=12', npm: '>=6'} + peerDependencies: + '@testing-library/dom': '>=7.21.4' + dependencies: + '@testing-library/dom': 10.4.1 + dev: true + /@tootallnate/once/2.0.0: resolution: {integrity: sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==} engines: {node: '>= 10'} dev: true + /@types/aria-query/5.0.4: + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + dev: true + /@types/babel__core/7.20.5: resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} dependencies: @@ -1322,12 +1542,23 @@ packages: '@types/responselike': 1.0.3 dev: true + /@types/chai/5.2.3: + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + dev: true + /@types/debug/4.1.12: resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} dependencies: '@types/ms': 2.1.0 dev: true + /@types/deep-eql/4.0.2: + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + dev: true + /@types/estree/1.0.8: resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} dev: true @@ -1570,6 +1801,90 @@ packages: - supports-color dev: true + /@vitest/coverage-v8/4.1.10_vitest@4.1.10: + resolution: {integrity: sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g==} + peerDependencies: + '@vitest/browser': 4.1.10 + vitest: 4.1.10 + peerDependenciesMeta: + '@vitest/browser': + optional: true + dependencies: + '@bcoe/v8-coverage': 1.0.2 + '@vitest/utils': 4.1.10 + ast-v8-to-istanbul: 1.0.5 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-reports: 3.2.0 + magicast: 0.5.4 + obug: 2.1.4 + std-env: 4.2.0 + tinyrainbow: 3.1.1 + vitest: 4.1.10_vx7uwpt5cwqrhusdpoa262p5gm + dev: true + + /@vitest/expect/4.1.10: + resolution: {integrity: sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==} + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + chai: 6.2.2 + tinyrainbow: 3.1.1 + dev: true + + /@vitest/mocker/4.1.10_vite@7.2.7: + resolution: {integrity: sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + dependencies: + '@vitest/spy': 4.1.10 + estree-walker: 3.0.3 + magic-string: 0.30.21 + vite: 7.2.7_kx7yyowhop237vgn7dp2p54shq + dev: true + + /@vitest/pretty-format/4.1.10: + resolution: {integrity: sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==} + dependencies: + tinyrainbow: 3.1.1 + dev: true + + /@vitest/runner/4.1.10: + resolution: {integrity: sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==} + dependencies: + '@vitest/utils': 4.1.10 + pathe: 2.0.3 + dev: true + + /@vitest/snapshot/4.1.10: + resolution: {integrity: sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==} + dependencies: + '@vitest/pretty-format': 4.1.10 + '@vitest/utils': 4.1.10 + magic-string: 0.30.21 + pathe: 2.0.3 + dev: true + + /@vitest/spy/4.1.10: + resolution: {integrity: sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==} + dev: true + + /@vitest/utils/4.1.10: + resolution: {integrity: sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==} + dependencies: + '@vitest/pretty-format': 4.1.10 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.1 + dev: true + /@xmldom/xmldom/0.8.11: resolution: {integrity: sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==} engines: {node: '>=10.0.0'} @@ -1656,6 +1971,11 @@ packages: color-convert: 2.0.1 dev: true + /ansi-styles/5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + dev: true + /ansi-styles/6.2.3: resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} engines: {node: '>=12'} @@ -1714,6 +2034,17 @@ packages: /argparse/2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + /aria-query/5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + dependencies: + dequal: 2.0.3 + dev: true + + /aria-query/5.3.2: + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} + engines: {node: '>= 0.4'} + dev: true + /array-buffer-byte-length/1.0.2: resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==} engines: {node: '>= 0.4'} @@ -1798,6 +2129,19 @@ packages: dev: true optional: true + /assertion-error/2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + dev: true + + /ast-v8-to-istanbul/1.0.5: + resolution: {integrity: sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA==} + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 10.0.0 + dev: true + /astral-regex/2.0.0: resolution: {integrity: sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==} engines: {node: '>=8'} @@ -1847,6 +2191,12 @@ packages: hasBin: true dev: true + /bidi-js/1.0.3: + resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + dependencies: + require-from-string: 2.0.2 + dev: true + /bl/4.1.0: resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} dependencies: @@ -2019,6 +2369,11 @@ packages: resolution: {integrity: sha512-7AAMPcueWELt1p3mi13HR/LHH0TJLT11cnwDJEs3xA4+CK/PLKeO9Kl1oru24htkyUKtkGCvAx4ohB0Ttry8Dw==} dev: true + /chai/6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + dev: true + /chalk/4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} @@ -2166,10 +2521,32 @@ packages: shebang-command: 2.0.0 which: 2.0.2 + /css-tree/3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + dev: true + + /css.escape/1.5.1: + resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} + dev: true + /csstype/3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} dev: true + /data-urls/7.0.0: + resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + transitivePeerDependencies: + - '@noble/hashes' + dev: true + /data-view-buffer/1.0.2: resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} engines: {node: '>= 0.4'} @@ -2208,6 +2585,10 @@ packages: dependencies: ms: 2.1.3 + /decimal.js/10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + dev: true + /decompress-response/6.0.0: resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} engines: {node: '>=10'} @@ -2253,6 +2634,11 @@ packages: engines: {node: '>=0.4.0'} dev: true + /dequal/2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + dev: true + /detect-libc/2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} @@ -2306,6 +2692,14 @@ packages: esutils: 2.0.3 dev: true + /dom-accessibility-api/0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + dev: true + + /dom-accessibility-api/0.6.3: + resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + dev: true + /dotenv-expand/11.0.7: resolution: {integrity: sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==} engines: {node: '>=12'} @@ -2448,6 +2842,11 @@ packages: once: 1.4.0 dev: true + /entities/8.0.0: + resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} + engines: {node: '>=20.19.0'} + dev: true + /env-paths/2.2.1: resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} engines: {node: '>=6'} @@ -2553,6 +2952,10 @@ packages: safe-array-concat: 1.1.3 dev: true + /es-module-lexer/2.3.1: + resolution: {integrity: sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==} + dev: true + /es-object-atoms/1.1.1: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} @@ -2805,11 +3208,22 @@ packages: engines: {node: '>=4.0'} dev: true + /estree-walker/3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + dependencies: + '@types/estree': 1.0.8 + dev: true + /esutils/2.0.3: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} dev: true + /expect-type/1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + dev: true + /exponential-backoff/3.1.3: resolution: {integrity: sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==} dev: true @@ -2848,6 +3262,18 @@ packages: picomatch: 4.0.3 dev: true + /fdir/6.5.0_picomatch@4.0.5: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + dependencies: + picomatch: 4.0.5 + dev: true + /file-entry-cache/8.0.0: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} @@ -2944,6 +3370,13 @@ packages: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} dev: true + /fsevents/2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + dev: true + optional: true + /fsevents/2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -3175,6 +3608,19 @@ packages: lru-cache: 6.0.0 dev: true + /html-encoding-sniffer/6.0.0: + resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + dependencies: + '@exodus/bytes': 1.15.1 + transitivePeerDependencies: + - '@noble/hashes' + dev: true + + /html-escaper/2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + dev: true + /http-cache-semantics/4.2.0: resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==} dev: true @@ -3450,6 +3896,10 @@ packages: has-tostringtag: 1.0.2 dev: true + /is-potential-custom-element-name/1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + dev: true + /is-regex/1.2.1: resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} engines: {node: '>= 0.4'} @@ -3538,6 +3988,28 @@ packages: /isexe/2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + /istanbul-lib-coverage/3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + dev: true + + /istanbul-lib-report/3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + dev: true + + /istanbul-reports/3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + dev: true + /iterator.prototype/1.1.5: resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==} engines: {node: '>= 0.4'} @@ -3568,6 +4040,10 @@ packages: picocolors: 1.1.1 dev: true + /js-tokens/10.0.0: + resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + dev: true + /js-tokens/4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} dev: true @@ -3578,6 +4054,40 @@ packages: dependencies: argparse: 2.0.1 + /jsdom/30.0.1: + resolution: {integrity: sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + peerDependencies: + canvas: ^3.2.3 + peerDependenciesMeta: + canvas: + optional: true + dependencies: + '@asamuzakjp/css-color': 6.0.5 + '@asamuzakjp/dom-selector': 8.3.0 + '@bramus/specificity': 2.4.2 + '@csstools/css-syntax-patches-for-csstree': 1.1.7_css-tree@3.2.1 + '@exodus/bytes': 1.15.1 + css-tree: 3.2.1 + data-urls: 7.0.0 + decimal.js: 10.6.0 + html-encoding-sniffer: 6.0.0 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.5.2 + parse5: 8.0.1 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 6.0.2 + undici: 8.9.0 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 8.0.1 + whatwg-mimetype: 5.0.0 + whatwg-url: 17.1.0 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@noble/hashes' + dev: true + /jsesc/3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} @@ -3708,6 +4218,11 @@ packages: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} dev: true + /lru-cache/11.5.2: + resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} + engines: {node: 20 || >=22} + dev: true + /lru-cache/5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} dependencies: @@ -3726,12 +4241,32 @@ packages: engines: {node: '>=12'} dev: true + /lz-string/1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + dev: true + /magic-string/0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} dependencies: '@jridgewell/sourcemap-codec': 1.5.5 dev: true + /magicast/0.5.4: + resolution: {integrity: sha512-llBEhWm1SacoRwgHUoQJYtwp4PBLF4faQi5TCpIGyGs9n4y5+juI0tDgyKIfpqxckRHaHzouUEph3THklWh03w==} + dependencies: + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + source-map-js: 1.2.1 + dev: true + + /make-dir/4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + dependencies: + semver: 7.7.3 + dev: true + /make-fetch-happen/10.2.1: resolution: {integrity: sha512-NgOPbRiaQM10DYXvN3/hhGVI2M5MtITFryzBGxHM5p4wnFxsVCbxkrBrDsk+EZ5OB4jEOT7AjDxtdF+KVEFT7w==} engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} @@ -3762,6 +4297,10 @@ packages: engines: {node: '>= 0.4'} dev: true + /mdn-data/2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + dev: true + /mime-db/1.52.0: resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} engines: {node: '>= 0.6'} @@ -3795,6 +4334,11 @@ packages: engines: {node: '>=10'} dev: true + /min-indent/1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} + dev: true + /minimatch/10.1.1: resolution: {integrity: sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==} engines: {node: 20 || >=22} @@ -4011,6 +4555,11 @@ packages: es-object-atoms: 1.1.1 dev: true + /obug/2.1.4: + resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==} + engines: {node: '>=12.20.0'} + dev: true + /once/1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} dependencies: @@ -4110,6 +4659,12 @@ packages: callsites: 3.1.0 dev: true + /parse5/8.0.1: + resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} + dependencies: + entities: 8.0.0 + dev: true + /path-exists/4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -4136,6 +4691,10 @@ packages: minipass: 7.1.2 dev: true + /pathe/2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + dev: true + /pe-library/0.4.1: resolution: {integrity: sha512-eRWB5LBz7PpDu4PUlwT0PhnQfTQJlDDdPa35urV4Osrm0t0AqQFGn+UIkU3klZvwJ8KPO3VbBFsXquA6p6kqZw==} engines: {node: '>=12', npm: '>=6'} @@ -4154,7 +4713,22 @@ packages: resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} engines: {node: '>=12'} dev: true - optional: true + + /playwright-core/1.62.1: + resolution: {integrity: sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==} + engines: {node: '>=20'} + hasBin: true + dev: true + + /playwright/1.62.1: + resolution: {integrity: sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==} + engines: {node: '>=20'} + hasBin: true + dependencies: + playwright-core: 1.62.1 + optionalDependencies: + fsevents: 2.3.2 + dev: true /plist/3.1.0: resolution: {integrity: sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==} @@ -4197,6 +4771,15 @@ packages: hasBin: true dev: true + /pretty-format/27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + dev: true + /proc-log/2.0.1: resolution: {integrity: sha512-Kcmo2FhfDTXdcbfDH76N7uBYHINxc/8GW7UAVuVP9I+Va3uHSerrnKV6dLooga/gh7GlgzuCCr/eoldnL1muGw==} engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} @@ -4260,6 +4843,10 @@ packages: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} dev: true + /react-is/17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + dev: true + /react-refresh/0.18.0: resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==} engines: {node: '>=0.10.0'} @@ -4292,6 +4879,14 @@ packages: engines: {node: '>= 20.19.0'} dev: true + /redent/3.0.0: + resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} + engines: {node: '>=8'} + dependencies: + indent-string: 4.0.0 + strip-indent: 3.0.0 + dev: true + /reflect.getprototypeof/1.0.10: resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} engines: {node: '>= 0.4'} @@ -4323,6 +4918,11 @@ packages: engines: {node: '>=0.10.0'} dev: true + /require-from-string/2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + dev: true + /resedit/1.7.2: resolution: {integrity: sha512-vHjcY2MlAITJhC0eRD/Vv8Vlgmu9Sd3LX9zZvtGzU5ZImdTN3+d6e/4mnTyV8vEbyf1sgNIrWxhWlrys52OkEA==} engines: {node: '>=12', npm: '>=6'} @@ -4464,6 +5064,13 @@ packages: /sax/1.4.3: resolution: {integrity: sha512-yqYn1JhPczigF94DMS+shiDMjDowYO6y9+wB/4WgO0Y19jWYk0lQ4tuG5KI7kj4FTp1wxPj5IFfcrz/s1c3jjQ==} + /saxes/6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + dependencies: + xmlchars: 2.2.0 + dev: true + /scheduler/0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} @@ -4563,6 +5170,10 @@ packages: side-channel-weakmap: 1.0.2 dev: true + /siginfo/2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + dev: true + /signal-exit/3.0.7: resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} dev: true @@ -4642,11 +5253,19 @@ packages: minipass: 3.3.6 dev: true + /stackback/0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + dev: true + /stat-mode/1.0.0: resolution: {integrity: sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg==} engines: {node: '>= 6'} dev: true + /std-env/4.2.0: + resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} + dev: true + /stop-iteration-iterator/1.1.0: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} @@ -4751,6 +5370,13 @@ packages: ansi-regex: 6.2.2 dev: true + /strip-indent/3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + engines: {node: '>=8'} + dependencies: + min-indent: 1.0.1 + dev: true + /strip-json-comments/3.1.1: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} @@ -4776,6 +5402,10 @@ packages: engines: {node: '>= 0.4'} dev: true + /symbol-tree/3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + dev: true + /synckit/0.11.11: resolution: {integrity: sha512-MeQTA1r0litLUf0Rp/iisCaL8761lKAZHaimlbGK4j0HysC4PLfqygQj9srcs0m2RdtDYnF8UuYyKpbjHYp7Jw==} engines: {node: ^14.18.0 || >=16.0.0} @@ -4812,12 +5442,37 @@ packages: resolution: {integrity: sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==} dev: false + /tinybench/2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + dev: true + + /tinyexec/1.2.4: + resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} + engines: {node: '>=18'} + dev: true + /tinyglobby/0.2.15: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} dependencies: - fdir: 6.5.0_picomatch@4.0.3 - picomatch: 4.0.3 + fdir: 6.5.0_picomatch@4.0.5 + picomatch: 4.0.5 + dev: true + + /tinyrainbow/3.1.1: + resolution: {integrity: sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==} + engines: {node: '>=14.0.0'} + dev: true + + /tldts-core/7.4.10: + resolution: {integrity: sha512-KnQjp53ZekKgm/r3l+u8kJGGzYgrWdP8+Mql7a4vijh2WE0IrZWspQj/TpTxDho/YxO+AnOZnIjQcCD+q6iJsw==} + dev: true + + /tldts/7.4.10: + resolution: {integrity: sha512-GgouD1B+sWwvkaEq8vXC15DjQitxbvs12oIXELpconwm+Tg3zfcEv4jgzq3vtKverDXsg3VI8aRgNL2Nra0Iog==} + hasBin: true + dependencies: + tldts-core: 7.4.10 dev: true /tmp-promise/3.0.3: @@ -4831,6 +5486,20 @@ packages: engines: {node: '>=14.14'} dev: true + /tough-cookie/6.0.2: + resolution: {integrity: sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==} + engines: {node: '>=16'} + dependencies: + tldts: 7.4.10 + dev: true + + /tr46/6.0.0: + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} + engines: {node: '>=20'} + dependencies: + punycode: 2.3.1 + dev: true + /truncate-utf8-bytes/1.0.2: resolution: {integrity: sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ==} dependencies: @@ -4943,6 +5612,11 @@ packages: engines: {node: '>=20.18.1'} optional: true + /undici/8.9.0: + resolution: {integrity: sha512-aWZpUj7XoGonMClx4gdDRfgBjqeA+F473aDmROQQbM9n6PRfK/u1q/a0X4wMTgcHfT8H6fpbt98PFuDUwFg2YA==} + engines: {node: '>=22.19.0'} + dev: true + /unique-filename/2.0.1: resolution: {integrity: sha512-ODWHtkkdx3IAR+veKxFV+VBkUMcN+FaqzUUd7IZzt+0zhDZFPFxhlqwPF3YQvMHx1TD0tdgYl+kuPnJ8E6ql7A==} engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} @@ -5048,17 +5722,124 @@ packages: fsevents: 2.3.3 dev: true + /vitest/4.1.10_vx7uwpt5cwqrhusdpoa262p5gm: + resolution: {integrity: sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.10 + '@vitest/browser-preview': 4.1.10 + '@vitest/browser-webdriverio': 4.1.10 + '@vitest/coverage-istanbul': 4.1.10 + '@vitest/coverage-v8': 4.1.10 + '@vitest/ui': 4.1.10 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + dependencies: + '@types/node': 22.19.2 + '@vitest/coverage-v8': 4.1.10_vitest@4.1.10 + '@vitest/expect': 4.1.10 + '@vitest/mocker': 4.1.10_vite@7.2.7 + '@vitest/pretty-format': 4.1.10 + '@vitest/runner': 4.1.10 + '@vitest/snapshot': 4.1.10 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + es-module-lexer: 2.3.1 + expect-type: 1.4.0 + jsdom: 30.0.1 + magic-string: 0.30.21 + obug: 2.1.4 + pathe: 2.0.3 + picomatch: 4.0.5 + std-env: 4.2.0 + tinybench: 2.9.0 + tinyexec: 1.2.4 + tinyglobby: 0.2.15 + tinyrainbow: 3.1.1 + vite: 7.2.7_kx7yyowhop237vgn7dp2p54shq + why-is-node-running: 2.3.0 + transitivePeerDependencies: + - msw + dev: true + + /w3c-xmlserializer/5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + dependencies: + xml-name-validator: 5.0.0 + dev: true + /wcwidth/1.0.1: resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} dependencies: defaults: 1.0.4 dev: true + /webidl-conversions/8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} + dev: true + /wechat-emojis/1.0.2: resolution: {integrity: sha512-T1drHGy92rKm/Vo7LRkU4D4wdREpVTjAMEa4gR1NB9IAyck3qmmewFSrnEEIyZfsv3SXTA7X1kt6Smt0UKVCyw==} engines: {node: '>=14.0.0'} dev: false + /whatwg-mimetype/5.0.0: + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} + engines: {node: '>=20'} + dev: true + + /whatwg-url/16.0.1: + resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + dependencies: + '@exodus/bytes': 1.15.1 + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + dev: true + + /whatwg-url/17.1.0: + resolution: {integrity: sha512-3GeworPmc2ZfEEHP7lEbUfBX/L75wdEsi0rLNhXcXxnoN5jyq0SL5gCy06SGW2cyTIZdTvWIDQNQoza++vKeaw==} + engines: {node: ^22.14.0 || >=24.0.0} + dependencies: + '@exodus/bytes': 1.15.1 + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + dev: true + /which-boxed-primitive/1.1.1: resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} engines: {node: '>= 0.4'} @@ -5119,6 +5900,15 @@ packages: dependencies: isexe: 2.0.0 + /why-is-node-running/2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + dev: true + /word-wrap/1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} @@ -5146,11 +5936,20 @@ packages: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} dev: true + /xml-name-validator/5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + dev: true + /xmlbuilder/15.1.1: resolution: {integrity: sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==} engines: {node: '>=8.0'} dev: true + /xmlchars/2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + dev: true + /y18n/5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} diff --git a/src/main/app-logger.ts b/src/main/app-logger.ts index c3d6400..a128e7e 100644 --- a/src/main/app-logger.ts +++ b/src/main/app-logger.ts @@ -13,6 +13,7 @@ const sanitize = (value: unknown, depth = 0): unknown => { return value .replace(/\bsk-[a-z0-9_-]{8,}\b/gi, '***') .replace(/\bBearer\s+[a-z0-9._~-]{8,}\b/gi, 'Bearer ***') + .replace(/\b(?:0x)?[a-f0-9]{64}\b/gi, '***') .slice(0, 2000) } if (Array.isArray(value)) return value.slice(0, 30).map((item) => sanitize(item, depth + 1)) @@ -56,7 +57,7 @@ export class AppLogger { mode: isPackagedRuntime() ? 'packaged' : 'development', level: entry.level, scope: String(entry.scope || 'app').slice(0, 80), - message: String(entry.message || '').slice(0, 500), + message: String(sanitize(entry.message || '')).slice(0, 500), details: sanitize(entry.details || {}) } fs.appendFileSync(this.logPath, `${JSON.stringify(record)}\n`, { encoding: 'utf8' }) diff --git a/src/main/image-decrypt-service.ts b/src/main/image-decrypt-service.ts index 60671e2..a646438 100644 --- a/src/main/image-decrypt-service.ts +++ b/src/main/image-decrypt-service.ts @@ -203,9 +203,11 @@ function getMimeType(extension) { function strictRemovePadding(buffer) { if (buffer.length === 0) return buffer const paddingLength = buffer[buffer.length - 1] - if (paddingLength <= 0 || paddingLength > 16 || paddingLength > buffer.length) return buffer + if (paddingLength <= 0 || paddingLength > 16 || paddingLength > buffer.length) { + throw new Error('invalid PKCS#7 padding') + } for (let index = buffer.length - paddingLength; index < buffer.length; index += 1) { - if (buffer[index] !== paddingLength) return buffer + if (buffer[index] !== paddingLength) throw new Error('invalid PKCS#7 padding') } return buffer.subarray(0, buffer.length - paddingLength) } @@ -1636,19 +1638,13 @@ export class ImageDecryptService { private strictRemovePadding(buffer: Buffer): Buffer { if (buffer.length === 0) return buffer const lastByte = buffer[buffer.length - 1] - if (lastByte <= 16 && lastByte > 0) { - const paddingLength = lastByte - let valid = true - for (let i = buffer.length - paddingLength; i < buffer.length; i++) { - if (buffer[i] !== lastByte) { - valid = false - break - } - } - if (valid) { - return buffer.subarray(0, buffer.length - paddingLength) - } + if (lastByte <= 0 || lastByte > 16 || lastByte > buffer.length) { + throw new Error('invalid PKCS#7 padding') } - return buffer + const paddingLength = lastByte + for (let i = buffer.length - paddingLength; i < buffer.length; i += 1) { + if (buffer[i] !== lastByte) throw new Error('invalid PKCS#7 padding') + } + return buffer.subarray(0, buffer.length - paddingLength) } } diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 6122b89..f2a35ae 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -24,6 +24,11 @@ import { FirstUseWelcome } from './components/FirstUseWelcome' import { ExportWorkspace } from './components/export/ExportWorkspace' import { AISearchWorkspace } from './components/search/AISearchWorkspace' import type { ExportJobProgress, ExportRequest, ExportTaskRecord } from '../../shared/export' +import { + getMessageIdentity, + mergeMessagePages, + sortMessagesChronologically +} from './utils/message-pages' const SIDEBAR_MIN_WIDTH = 260 const SIDEBAR_MAX_WIDTH = 380 @@ -47,12 +52,6 @@ const INITIAL_MESSAGE_COUNT = 20 const MESSAGE_PAGE_SIZE = 100 const MESSAGE_PREFETCH_COUNT = INITIAL_MESSAGE_COUNT + MESSAGE_PAGE_SIZE const EXPORT_PREVIEW_LIMIT = 20 -const getMessageIdentity = (message: Message): string => { - if (message.localId) return `local:${message.localId}` - if (message.id) return `id:${message.id}` - return `${message.createTime || 0}:${message.from}:${message.type}:${message.content}` -} - const normalizeQuotedText = (value: string | undefined): string => String(value || '') .replace(/\s+/g, ' ') @@ -194,19 +193,6 @@ const buildSyntheticGroupMessages = ( void buildSyntheticGroupMessages -const sortMessagesChronologically = (items: Message[]): Message[] => - [...items].sort((left, right) => { - const timeDelta = (left.createTime || 0) - (right.createTime || 0) - if (timeDelta !== 0) return timeDelta - return getMessageIdentity(left).localeCompare(getMessageIdentity(right)) - }) - -const mergeMessagePages = (older: Message[], current: Message[]): Message[] => { - const merged = new Map() - for (const message of [...older, ...current]) merged.set(getMessageIdentity(message), message) - return sortMessagesChronologically(Array.from(merged.values())) -} - function App(): React.ReactElement { const [isAuthenticated, setIsAuthenticated] = useState(false) const [isDatabaseConnected, setIsDatabaseConnected] = useState(false) diff --git a/src/renderer/src/utils/message-pages.ts b/src/renderer/src/utils/message-pages.ts new file mode 100644 index 0000000..68bcf96 --- /dev/null +++ b/src/renderer/src/utils/message-pages.ts @@ -0,0 +1,20 @@ +import type { Message } from '../../../shared/types' + +export const getMessageIdentity = (message: Message): string => { + if (message.localId) return `local:${message.localId}` + if (message.id) return `id:${message.id}` + return `${message.createTime || 0}:${message.from}:${message.type}:${message.content}` +} + +export const sortMessagesChronologically = (items: Message[]): Message[] => + [...items].sort((left, right) => { + const timeDelta = (left.createTime || 0) - (right.createTime || 0) + if (timeDelta !== 0) return timeDelta + return getMessageIdentity(left).localeCompare(getMessageIdentity(right)) + }) + +export const mergeMessagePages = (older: Message[], current: Message[]): Message[] => { + const merged = new Map() + for (const message of [...older, ...current]) merged.set(getMessageIdentity(message), message) + return sortMessagesChronologically(Array.from(merged.values())) +} diff --git a/tests/component/database-connection.test.tsx b/tests/component/database-connection.test.tsx new file mode 100644 index 0000000..3720915 --- /dev/null +++ b/tests/component/database-connection.test.tsx @@ -0,0 +1,55 @@ +import { render, screen, type RenderResult } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import type { ComponentProps } from 'react' +import { describe, expect, it, vi } from 'vitest' +import { DatabaseConnectionPage } from '../../src/renderer/src/components/DatabaseConnectionPage' + +function renderPage( + overrides: Partial> = {} +): RenderResult & { props: ComponentProps } { + const props = { + platform: 'win32', + mode: 'manual' as const, + dbKey: '', + dbRoot: '', + showDbKey: false, + isFetching: false, + status: '', + statusKind: 'normal' as const, + showMacKeyFaq: false, + macKeyFaqUrl: 'https://fixture.invalid/mac', + onModeChange: vi.fn(), + onDbKeyChange: vi.fn(), + onDbRootChange: vi.fn(), + onToggleDbKey: vi.fn(), + onAutoGetKey: vi.fn(), + onManualConnect: vi.fn(), + onPasteKey: vi.fn(), + onClearKey: vi.fn(), + ...overrides + } + return { props, ...render() } +} + +describe('DatabaseConnectionPage', () => { + it('keeps connect disabled until a valid 64-character key is supplied', () => { + const { rerender, props } = renderPage() + expect(screen.getByRole('button', { name: '连接数据库' })).toBeDisabled() + rerender() + expect(screen.getByRole('button', { name: '连接数据库' })).toBeEnabled() + }) + + it('shows a recoverable error and keeps form actions available', async () => { + const onManualConnect = vi.fn() + renderPage({ + dbKey: 'b'.repeat(64), + status: '数据库密钥无效,请重新输入', + statusKind: 'error', + onManualConnect + }) + expect(screen.getByText('数据库密钥无效,请重新输入')).toBeVisible() + await userEvent.click(screen.getByRole('button', { name: '连接数据库' })) + expect(onManualConnect).toHaveBeenCalledOnce() + expect(screen.getByRole('button', { name: '从剪贴板粘贴并安全保存' })).toBeEnabled() + }) +}) diff --git a/tests/component/empty-state.test.tsx b/tests/component/empty-state.test.tsx new file mode 100644 index 0000000..215cd04 --- /dev/null +++ b/tests/component/empty-state.test.tsx @@ -0,0 +1,18 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import { EmptyConversationState } from '../../src/renderer/src/components/chat/EmptyConversationState' +import { SettingsEmptyState } from '../../src/renderer/src/features/settings/components/SettingsEmptyState' + +describe('empty states', () => { + it('explains how to leave an empty archive without pretending data failed', () => { + render() + expect(screen.getByRole('heading', { name: '选择一条消息' })).toBeVisible() + expect(screen.getByText('从左侧选择群聊或联系人以浏览历史记录')).toBeVisible() + }) + + it('labels an unavailable settings section explicitly', () => { + render() + expect(screen.getByRole('heading', { name: '测试设置' })).toBeVisible() + expect(screen.getByText('该设置将在后续阶段接入。')).toBeVisible() + }) +}) diff --git a/tests/component/image-bubble.test.tsx b/tests/component/image-bubble.test.tsx new file mode 100644 index 0000000..32a92c4 --- /dev/null +++ b/tests/component/image-bubble.test.tsx @@ -0,0 +1,53 @@ +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const requestImage = vi.fn() +vi.mock('../../src/renderer/src/components/image-loader', () => ({ + getCachedLoadedImage: vi.fn(() => undefined), + requestImage: (...args: unknown[]) => requestImage(...args) +})) + +import { ImageBubble } from '../../src/renderer/src/components/ImageBubble' + +const thumbnail = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9ZQmcAAAAASUVORK5CYII=' +const original = `${thumbnail}original` + +describe('ImageBubble', () => { + beforeEach(() => { + requestImage.mockReset() + window.api = { copyImage: vi.fn().mockResolvedValue({ success: true }) } as typeof window.api + }) + + it('loads a thumbnail lazily, then requests the original when opened', async () => { + requestImage + .mockResolvedValueOnce({ data: thumbnail, isThumbnail: true }) + .mockResolvedValueOnce({ data: original, isThumbnail: false }) + const onImageClick = vi.fn() + render( + + ) + + const image = await screen.findByAltText('图片') + expect(image).toHaveAttribute('src', thumbnail) + await userEvent.click(image) + await waitFor(() => expect(onImageClick).toHaveBeenCalledWith(original)) + expect(requestImage.mock.calls[1][3]).toMatchObject({ force: true }) + }) + + it('shows an explicit error and allows retry', async () => { + requestImage.mockRejectedValueOnce(new Error('不支持的 DAT 版本')) + render() + expect(await screen.findByText('不支持的 DAT 版本')).toBeVisible() + + requestImage.mockResolvedValueOnce({ data: thumbnail, isThumbnail: true }) + await userEvent.click(screen.getByText('加载失败')) + expect(await screen.findByAltText('图片')).toBeVisible() + }) +}) diff --git a/tests/component/navigation.test.tsx b/tests/component/navigation.test.tsx new file mode 100644 index 0000000..e2684c3 --- /dev/null +++ b/tests/component/navigation.test.tsx @@ -0,0 +1,21 @@ +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, expect, it, vi } from 'vitest' +import { PrimaryNavigation } from '../../src/renderer/src/components/layout/PrimaryNavigation' +import { PRIMARY_NAV_ITEMS } from '../../src/renderer/src/components/layout/navigation' + +describe('PrimaryNavigation', () => { + it('shows every real top-level page exactly once and emits the selected page', async () => { + const onPageChange = vi.fn() + render() + + const navigation = screen.getByRole('navigation', { name: '一级导航' }) + expect(navigation).toBeInTheDocument() + for (const item of PRIMARY_NAV_ITEMS) { + expect(screen.getAllByRole('button', { name: item.label })).toHaveLength(1) + } + + await userEvent.click(screen.getByRole('button', { name: '设置' })) + expect(onPageChange).toHaveBeenCalledWith('settings') + }) +}) diff --git a/tests/component/voice-player.test.tsx b/tests/component/voice-player.test.tsx new file mode 100644 index 0000000..abc4863 --- /dev/null +++ b/tests/component/voice-player.test.tsx @@ -0,0 +1,47 @@ +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { VoicePlayer } from '../../src/renderer/src/components/VoicePlayer' + +const play = vi.fn(() => Promise.resolve()) +const pause = vi.fn() + +class FakeAudio { + preload = '' + src = '' + duration = 1 + currentTime = 0 + onloadedmetadata: (() => void) | null = null + ontimeupdate: (() => void) | null = null + onended: (() => void) | null = null + play = play + pause = pause + load = vi.fn() + removeAttribute = vi.fn() +} + +describe('VoicePlayer', () => { + beforeEach(() => { + play.mockClear() + pause.mockClear() + vi.stubGlobal('Audio', FakeAudio) + window.api = { + getVoiceData: vi.fn().mockResolvedValue({ + success: true, + data: 'UklGRiQAAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YQAAAAA=' + }) + } as typeof window.api + }) + + it('waits for decrypted bytes and calls play on the first click', async () => { + const { container } = render( + + ) + await userEvent.click(container.querySelector('.voice-message') as HTMLElement) + + await waitFor(() => expect(window.api.getVoiceData).toHaveBeenCalledOnce()) + await waitFor(() => expect(play).toHaveBeenCalledOnce()) + expect(container.querySelector('.voice-icon')).toHaveClass('playing') + expect(screen.queryByText('当前版本暂不支持播放')).not.toBeInTheDocument() + }) +}) diff --git a/tests/e2e/__screenshots__/win32/visual.spec.ts/archive-page.png b/tests/e2e/__screenshots__/win32/visual.spec.ts/archive-page.png new file mode 100644 index 0000000..086df4c Binary files /dev/null and b/tests/e2e/__screenshots__/win32/visual.spec.ts/archive-page.png differ diff --git a/tests/e2e/__screenshots__/win32/visual.spec.ts/login-page.png b/tests/e2e/__screenshots__/win32/visual.spec.ts/login-page.png new file mode 100644 index 0000000..537b93e Binary files /dev/null and b/tests/e2e/__screenshots__/win32/visual.spec.ts/login-page.png differ diff --git a/tests/e2e/app.spec.ts b/tests/e2e/app.spec.ts new file mode 100644 index 0000000..d7d48b6 --- /dev/null +++ b/tests/e2e/app.spec.ts @@ -0,0 +1,289 @@ +import { expect, test } from '@playwright/test' +import { existsSync, mkdtempSync, readFileSync, rmSync, statSync } from 'fs' +import { tmpdir } from 'os' +import { resolve } from 'path' +import { launchTestApp } from './support/electron' + +test('APP-01 first launch renders a usable connection screen without uncaught errors', async () => { + const fixture = await launchTestApp({ mode: 'disconnected' }) + const pageErrors: Error[] = [] + fixture.page.on('pageerror', (error) => pageErrors.push(error)) + try { + await expect(fixture.page.getByRole('heading', { name: 'WechatExplorer' })).toBeVisible() + await expect(fixture.page.getByRole('main')).not.toBeEmpty() + expect(pageErrors).toEqual([]) + } finally { + await fixture.close() + } +}) + +test('KEY-01 KEY-02 invalid key remains recoverable and valid key enters the app', async () => { + const fixture = await launchTestApp({ mode: 'disconnected' }) + try { + await fixture.page.getByRole('tab', { name: /高级用户/ }).click() + const keyInput = fixture.page.getByLabel('数据库密钥') + await keyInput.fill('b'.repeat(64)) + const errorDialog = fixture.page.waitForEvent('dialog') + await fixture.page + .getByRole('button', { name: '连接数据库' }) + .evaluate((element: HTMLButtonElement) => element.click()) + const dialog = await errorDialog + expect(dialog.message()).toContain('数据库密钥无效') + await dialog.dismiss() + + await expect(keyInput).toBeVisible() + await keyInput.fill('a'.repeat(64)) + await fixture.page.getByRole('button', { name: '连接数据库' }).click() + await expect(fixture.page.getByRole('navigation', { name: '一级导航' })).toBeVisible() + } finally { + await fixture.close() + } +}) + +test('KEY-03 changing one key does not invalidate archive data or unrelated settings', async () => { + const fixture = await launchTestApp() + try { + await expect(fixture.page.getByRole('navigation', { name: '一级导航' })).toBeVisible() + const result = await fixture.page.evaluate(async () => { + const before = await window.api.getContacts() + const image = await window.api.saveImageKeyConfig({ + resourceRoot: 'fixture-account', + xorKey: '0x41', + aesKey: 'fedcba9876543210' + }) + const after = await window.api.getContacts() + const database = await window.api.getSavedDbKey() + return { before, after, image, database } + }) + expect(result.image.success).toBe(true) + expect(result.before).toEqual(result.after) + expect(result.database.saved).toBe(true) + } finally { + await fixture.close() + } +}) + +test('NAV-01 NAV-02 every top-level page is unique and switchable', async () => { + const fixture = await launchTestApp() + const labels = ['档案', '问问微信', '日报', 'Agent', '导出', 'API', '设置'] + try { + const navigation = fixture.page.getByRole('navigation', { name: '一级导航' }) + await expect(navigation).toBeVisible() + for (const label of labels) { + await expect(navigation.getByRole('button', { name: label })).toHaveCount(1) + await navigation.getByRole('button', { name: label }).click() + await expect(fixture.page.locator(`main.app-shell-main[aria-label="${label}"]`)).toBeVisible() + } + } finally { + await fixture.close() + } +}) + +test('ARCH-01 ARCH-02 folded chats and supported message types are represented explicitly', async () => { + const fixture = await launchTestApp() + try { + await expect(fixture.page.getByText('产品测试群', { exact: true })).toBeVisible() + await fixture.page.getByText('产品测试群', { exact: true }).click() + await expect(fixture.page.getByText('这是一条脱敏测试消息', { exact: true })).toBeVisible() + await expect(fixture.page.getByText('暂不支持此消息', { exact: true })).toBeVisible() + await expect(fixture.page.getByAltText('图片')).toBeVisible() + await fixture.page.locator('.image-bubble.image-loaded').click() + await expect(fixture.page.getByText('图片查看', { exact: true })).toBeVisible() + await fixture.page.locator('.image-viewer-overlay').click({ position: { x: 5, y: 5 } }) + + await fixture.page.getByRole('button', { name: '折叠群聊 (1)' }).click() + await expect(fixture.page.getByText('折叠群聊样本', { exact: true })).toBeVisible() + } finally { + await fixture.close() + } +}) + +test('MEDIA-01 and merged forwards work on the first interaction', async () => { + const fixture = await launchTestApp() + try { + await fixture.page.getByRole('button', { name: '联系人 (1)' }).click() + await fixture.page.getByText('文件传输助手', { exact: true }).click() + await expect(fixture.page.getByText('转发多条内容', { exact: true })).toBeVisible() + await fixture.page.evaluate(() => { + Object.defineProperty(window, '__wxePlayCount', { + configurable: true, + value: 0, + writable: true + }) + HTMLMediaElement.prototype.play = async function () { + ;(window as Window & { __wxePlayCount: number }).__wxePlayCount += 1 + } + HTMLMediaElement.prototype.pause = function () { + return undefined + } + HTMLMediaElement.prototype.load = function () { + return undefined + } + }) + await fixture.page.locator('.voice-message').click() + await expect(fixture.page.locator('.voice-icon')).toHaveClass(/playing/) + expect( + await fixture.page.evaluate( + () => (window as Window & { __wxePlayCount: number }).__wxePlayCount + ) + ).toBe(1) + } finally { + await fixture.close() + } +}) + +test('MEDIA-02 MEDIA-04 return accurate unsupported and HTTP 403 reasons', async () => { + const fixture = await launchTestApp() + try { + const result = await fixture.page.evaluate(async () => ({ + image: await window.api.getImage('unsupported'), + sticker: await window.api.getSticker( + 'https://fixture.invalid/403?token=secret', + 'b'.repeat(32) + ) + })) + expect(result.image).toMatchObject({ success: false, error: '不支持的 DAT 版本' }) + expect(result.sticker).toMatchObject({ + success: false, + failureCode: 'access_denied', + httpStatus: 403 + }) + } finally { + await fixture.close() + } +}) + +test('ASK-01 uses the local fixed AI service and keeps evidence in the UI', async () => { + const fixture = await launchTestApp() + try { + await fixture.page.getByRole('button', { name: '问问微信' }).click() + await fixture.page.getByPlaceholder(/例如:技术交流群/).fill('测试群讨论了什么?') + await fixture.page.getByRole('button', { name: '开始分析' }).click() + await expect(fixture.page.getByText(/固定假回答:测试数据中的核心流程正常/)).toBeVisible({ + timeout: 15_000 + }) + } finally { + await fixture.close() + } +}) + +test('ASK-02 AI failures are recoverable and do not break the archive', async () => { + const fixture = await launchTestApp({ aiFailure: '429' }) + try { + await fixture.page.getByRole('button', { name: '问问微信' }).click() + await fixture.page.getByPlaceholder(/例如:技术交流群/).fill('测试') + await fixture.page.getByRole('button', { name: '开始分析' }).click() + await expect(fixture.page.getByText(/本地假服务错误 429/)).toBeVisible() + await fixture.page.getByRole('button', { name: '档案' }).click() + await expect(fixture.page.getByText('产品测试群', { exact: true })).toBeVisible() + } finally { + await fixture.close() + } +}) + +test('REPORT-01 REPORT-02 generates a fixed report with non-empty local assets', async () => { + const fixture = await launchTestApp() + try { + await fixture.page.getByRole('button', { name: '日报' }).click() + await fixture.page.getByRole('button', { name: '开始生成日报' }).click() + await expect(fixture.page.getByRole('heading', { name: '生成群聊日报' })).toBeVisible() + await fixture.page.locator('.report-source-item').filter({ hasText: '产品测试群' }).click() + const generate = fixture.page.getByRole('button', { name: '开始生成日报' }) + await expect(generate).toBeEnabled() + await generate.click() + await expect(fixture.page.getByAltText('产品测试群 群聊日报')).toBeVisible({ + timeout: 15_000 + }) + + const exported = await fixture.page.evaluate(async () => + window.api.exportGroupReport({ + report: {} as never, + metadata: {} as never, + templateId: 'v1' + }) + ) + expect(exported.success).toBe(true) + expect(exported.imageDataUrl).toMatch(/^data:image\/png;base64,/) + expect(existsSync(exported.htmlPath!)).toBe(true) + expect(existsSync(exported.pngPath!)).toBe(true) + expect(statSync(exported.pngPath!).size).toBeGreaterThan(20) + } finally { + await fixture.close() + } +}) + +test('REPORT-03 report failure is retryable and leaves other pages usable', async () => { + const fixture = await launchTestApp({ aiFailure: '401' }) + try { + await fixture.page.getByRole('button', { name: '日报' }).click() + await fixture.page.getByRole('button', { name: '开始生成日报' }).click() + await fixture.page.locator('.report-source-item').filter({ hasText: '产品测试群' }).click() + await fixture.page.getByRole('button', { name: '开始生成日报' }).click() + await expect(fixture.page.getByText(/本地假服务错误 401/).first()).toBeVisible() + await expect(fixture.page.getByRole('button', { name: '重试' })).toBeEnabled() + await fixture.page.getByRole('button', { name: '档案' }).click() + await expect(fixture.page.locator('main.app-shell-main[aria-label="档案"]')).toBeVisible() + await expect( + fixture.page.locator('.conversation-item-name').filter({ hasText: '产品测试群' }).first() + ).toBeVisible() + } finally { + await fixture.close() + } +}) + +test('CACHE-01 corrupt startup cache degrades to native fixture data', async () => { + const fixture = await launchTestApp({ corruptCache: true }) + try { + await expect(fixture.page.getByRole('navigation', { name: '一级导航' })).toBeVisible() + await expect(fixture.page.getByText('产品测试群', { exact: true })).toBeVisible() + } finally { + await fixture.close() + } +}) + +test('PERF-01 repeated startup with 1500 sessions remains bounded and responsive', async () => { + test.setTimeout(60_000) + const userData = mkdtempSync(resolve(tmpdir(), 'wxe-e2e-perf-')) + try { + for (let run = 0; run < 2; run += 1) { + const startedAt = Date.now() + const fixture = await launchTestApp({ userData, largeContacts: 1500 }) + try { + await expect(fixture.page.getByRole('navigation', { name: '一级导航' })).toBeVisible({ + timeout: 10_000 + }) + expect(Date.now() - startedAt).toBeLessThan(10_000) + await fixture.page + .getByRole('navigation', { name: '一级导航' }) + .getByRole('button', { name: '设置' }) + .click() + await expect(fixture.page.locator('main.app-shell-main[aria-label="设置"]')).toBeVisible() + } finally { + await fixture.close() + } + } + } finally { + rmSync(userData, { recursive: true, force: true }) + } +}) + +test('KEY-04 e2e diagnostic log does not contain a supplied key', async () => { + const fixture = await launchTestApp() + const key = 'c'.repeat(64) + try { + await fixture.page.evaluate( + (databaseKey) => + window.api.writeAppLog({ + level: 'error', + scope: 'key-test', + message: `fixture key=${databaseKey}` + }), + key + ) + const logPath = resolve(fixture.userData, 'logs/e2e.log') + const content = readFileSync(logPath, 'utf8') + expect(content).not.toContain(key) + } finally { + await fixture.close() + } +}) diff --git a/tests/e2e/support/electron-main.cjs b/tests/e2e/support/electron-main.cjs new file mode 100644 index 0000000..438f378 --- /dev/null +++ b/tests/e2e/support/electron-main.cjs @@ -0,0 +1,397 @@ +/* eslint-disable @typescript-eslint/no-require-imports, @typescript-eslint/explicit-function-return-type */ +const { app, BrowserWindow, ipcMain } = require('electron') +const fs = require('fs') +const path = require('path') + +const root = path.resolve(__dirname, '../../..') +const fixture = require(path.join(root, 'tests/fixtures/chat-data.json')) +const userData = process.env.WXE_E2E_USER_DATA +if (!userData) throw new Error('WXE_E2E_USER_DATA is required') +app.setPath('userData', userData) +app.setPath('logs', path.join(userData, 'logs')) +app.commandLine.appendSwitch('disable-gpu') + +const VALID_KEY = 'a'.repeat(64) +const imageData = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9ZQmcAAAAASUVORK5CYII=' +const voiceData = 'UklGRiQAAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YQAAAAA=' +const reportJson = JSON.stringify({ + overview: '固定脱敏日报', + hero: { + headline: '产品测试群日报', + summary: '测试消息已完成自动整理。', + keyTakeaway: '核心流程可用', + pendingNote: '', + statusLine: '今日形成 1 个结论' + }, + topics: [ + { + title: '自动化测试', + timeRange: '10:00-10:02', + heat: '中', + participants: ['测试成员'], + summary: '讨论了脱敏自动化测试。', + conclusions: [{ text: '核心流程可用', sourceMessageIds: ['msg-text'] }], + keywords: ['测试'], + sourceMessageIds: ['msg-text'] + } + ], + resources: [], + importantMessages: [], + quotes: [], + qa: [], + todos: [], + unresolved: [], + storylines: [], + reversals: [], + participantChains: [], + keywords: ['测试'] +}) + +let connected = process.env.WXE_E2E_MODE !== 'disconnected' +let savedKey = connected ? VALID_KEY : '' +let settings = { + dbRoot: 'fixture-account', + apiEnabled: false, + apiHost: '127.0.0.1', + apiPort: 5031, + imageKeyRoot: 'fixture-account', + ffmpegPath: '', + recallProtectionEnabled: false, + debugEnabled: false, + autoLogin: connected, + autoLoginPreferenceSet: true, + appearanceTheme: 'light', + compactMode: false, + showStartupProgress: false, + imageXorKey: '0x40', + imageAesKey: '0123456789abcdef' +} + +const extraContacts = Number(process.env.WXE_E2E_LARGE_CONTACTS || 0) +const contacts = [...fixture.contacts] +for (let index = 0; index < extraContacts; index += 1) { + contacts.push({ + m_nsUsrName: `fixture_${index}`, + m_nsNickName: `性能样本 ${index}`, + md5: `fixture-contact-${index}`, + type: index % 5 === 0 ? 'group' : 'user' + }) +} + +const handlers = new Map() +const handle = (channel, fn) => { + handlers.set(channel, fn) + ipcMain.handle(channel, async (event, ...args) => fn(...args)) +} + +const startupCache = () => ({ + self: fixture.self, + contacts, + updatedAt: 1785553200000 +}) + +handle('settings:get', () => ({ settings, settingsPath: path.join(userData, 'settings.json') })) +handle('settings:set', (patch) => { + settings = { ...settings, ...patch } + return { settings, settingsPath: path.join(userData, 'settings.json') } +}) +handle('key:getSavedDbKey', () => ({ + success: true, + key: savedKey || undefined, + saved: Boolean(savedKey), + encryptionAvailable: true +})) +handle('key:saveDbKey', (key) => { + savedKey = String(key || '') + return { success: true, key: savedKey, saved: true, encryptionAvailable: true } +}) +handle('key:clearSavedDbKey', () => { + savedKey = '' + return { success: true } +}) +handle('key:getEnvironment', () => ({ + platform: process.platform, + autoDetectSupported: true, + wechatRunning: true, + accountIdentified: connected, + dbConnected: connected, + encryptionAvailable: true +})) +handle('key:readClipboardDbKey', () => ({ success: true, value: VALID_KEY })) +handle('key:pasteAndSaveDbKey', () => ({ success: true, key: VALID_KEY })) +handle('key:autoGetDbKey', () => ({ success: true, key: VALID_KEY, saved: false })) +handle('key:autoGetImageKey', () => ({ + success: true, + xorKey: 64, + aesKey: '0123456789abcdef', + verified: true +})) + +handle('db:init', (key) => { + if (key !== VALID_KEY) { + connected = false + return { success: false, error: '数据库密钥无效', monitoring: false } + } + connected = true + return { success: true, monitoring: true } +}) +handle('db:testConnection', (key) => + key === VALID_KEY + ? { success: true, wxid: fixture.self.wxid, accountRoot: fixture.self.accountRoot } + : { success: false, code: 'DATABASE_OPEN_FAILED', error: '数据库密钥无效' } +) +handle('db:disconnect', () => { + connected = false + return { success: true } +}) +handle('db:getStartupCache', () => + process.env.WXE_E2E_CORRUPT_CACHE === '1' ? null : startupCache() +) +handle('db:getBootstrapCache', () => + process.env.WXE_E2E_CORRUPT_CACHE === '1' ? null : startupCache() +) +handle('db:getContacts', (filter) => { + const query = String(filter || '').toLowerCase() + return query + ? contacts.filter((contact) => contact.m_nsNickName.toLowerCase().includes(query)) + : contacts +}) +handle('db:getContactAvatars', (usernames) => + Object.fromEntries( + contacts + .filter((contact) => usernames.includes(contact.m_nsUsrName) && contact.avatar) + .map((contact) => [contact.m_nsUsrName, contact.avatar]) + ) +) +handle('settings:getSelf', () => ({ ready: true, info: fixture.self })) +handle('db:getCachedMessages', (md5) => fixture.messages[md5] || []) +handle('db:getCachedMessagePage', (md5) => ({ + hit: true, + messages: fixture.messages[md5] || [], + groupSnapshot: null +})) +handle('db:getMessages', (md5, startTime, endTime, options) => { + let messages = fixture.messages[md5] || [] + if (startTime) messages = messages.filter((message) => (message.createTime || 0) >= startTime) + if (endTime) messages = messages.filter((message) => (message.createTime || 0) <= endTime) + if (options && options.limit) messages = messages.slice(-options.limit) + return messages +}) +handle('db:getGroupSnapshot', (md5) => + md5.startsWith('group-') + ? { + roomId: md5, + memberCount: 1, + members: [ + { + wxid: 'wxid_fixture_member', + nickname: '测试成员', + groupNickname: '测试成员', + wechatNickname: '测试成员', + remark: '', + avatar: '' + } + ] + } + : null +) +handle('db:getImage', (md5, datName, sessionId, options) => + md5 === 'unsupported' + ? { success: false, error: '不支持的 DAT 版本' } + : { + success: true, + data: imageData, + isThumb: !options?.force, + filePath: path.join(userData, options?.force ? 'original.png' : 'thumbnail.png') + } +) +handle('db:getVoiceData', () => ({ success: true, data: voiceData })) +handle('db:getSticker', (url) => + String(url || '').includes('403') + ? { + success: false, + error: '表情链接已失效或需要微信授权', + failureCode: 'access_denied', + httpStatus: 403 + } + : { success: true, data: imageData } +) +handle('db:parseMessage', (content, messageType) => + messageType === 1 + ? { type: 'text', content: String(content) } + : { type: 'unknown', raw: String(content), messageType } +) + +handle('ai:getRuntimeConfig', () => ({ + providerId: 'fixture-provider', + providerName: '本地假服务', + model: 'fixture-model', + modelName: '固定响应模型', + configured: true, + status: 'connected', + timeoutMs: 5000 +})) +handle('ai:listProviders', () => ({ + success: true, + providers: [], + defaultProviderId: 'fixture-provider' +})) +handle('ai:migrateLegacy', () => ({ success: true, providers: [] })) +handle('ai:chat', (messages) => { + const failure = process.env.WXE_E2E_AI_FAILURE + if (failure) return { success: false, error: `本地假服务错误 ${failure}` } + const system = String(messages?.[0]?.content || '') + if (system.includes('本地聊天检索规划器')) { + return { + success: true, + data: '{"intent":"general","keywords":["测试"],"variants":[]}' + } + } + if (system.includes('微信群聊日报编辑') || system.includes('JSON 格式修复器')) { + return { success: true, data: reportJson, usage: { input: 10, output: 20, total: 30 } } + } + return { success: true, data: '固定假回答:测试数据中的核心流程正常。' } +}) + +handle('report:export', () => { + const htmlPath = path.join(userData, 'fixture-report.html') + const pngPath = path.join(userData, 'fixture-report.png') + fs.writeFileSync(htmlPath, '

固定脱敏日报

', 'utf8') + fs.writeFileSync(pngPath, Buffer.from(imageData.split(',')[1], 'base64')) + return { success: true, imageDataUrl: imageData, htmlPath, pngPath } +}) +handle('report:listGenerated', () => ({ success: true, reports: [] })) +handle('report:saveGenerated', (request) => ({ + success: true, + record: { id: 'fixture-report-record', ...request } +})) +handle('report:deleteGenerated', () => ({ success: true })) +handle('report:reveal', () => ({ success: true })) +handle('copy-image', () => ({ success: true })) +handle('api:copyText', () => ({ success: true })) +handle('app-log:write', (entry) => { + const safe = JSON.stringify(entry) + .replace(/\b(?:0x)?[a-f0-9]{64}\b/gi, '***') + .replace(/\bsk-[a-z0-9_-]{8,}\b/gi, '***') + fs.mkdirSync(path.join(userData, 'logs'), { recursive: true }) + fs.appendFileSync(path.join(userData, 'logs', 'e2e.log'), `${safe}\n`, 'utf8') +}) +handle('app-log:getPath', () => path.join(userData, 'logs', 'e2e.log')) +handle('app-log:reveal', () => undefined) +handle('cache:getSummary', () => ({ bootstrapBytes: 0, electronBytes: 0, totalBytes: 0 })) +handle('cache:clear', () => ({ bootstrapBytes: 0, electronBytes: 0, totalBytes: 0 })) +handle('api:getStatus', () => ({ running: false, host: settings.apiHost, port: settings.apiPort })) +handle('api:start', () => ({ running: true, host: settings.apiHost, port: settings.apiPort })) +handle('api:stop', () => ({ running: false, host: settings.apiHost, port: settings.apiPort })) +handle('api:toggle', (enabled) => ({ + running: enabled, + host: settings.apiHost, + port: settings.apiPort +})) +handle('image:getConfig', () => ({ + success: true, + configured: true, + saved: true, + encryptionAvailable: true, + source: 'secure-storage', + resourceRoot: settings.imageKeyRoot, + xorKey: settings.imageXorKey, + aesKey: settings.imageAesKey +})) +handle('image:saveConfig', (request) => ({ + success: true, + configured: true, + saved: true, + encryptionAvailable: true, + source: 'secure-storage', + ...request +})) +handle('image:testConfig', () => ({ + success: true, + fileFound: true, + decrypted: true, + readable: true +})) +handle('image:clearConfig', () => ({ success: true })) +handle('image:getDecoderStatus', () => ({ + installed: true, + available: true, + source: 'system', + selected: false +})) +handle('image:getStatus', () => ({ + configured: true, + saved: true, + encryptionAvailable: true, + source: 'secure-storage', + resourceRoot: settings.imageKeyRoot, + platform: process.platform, + autoDetectSupported: true, + wechatRunning: true, + accountIdentified: true, + cacheState: 'normal', + decoder: { installed: true, available: true, source: 'system', selected: false }, + resources: Object.fromEntries( + ['imageIndex', 'imageDirectory', 'thumbnail', 'original', 'sticker', 'video'].map((name) => [ + name, + { state: 'available', detail: 'fixture' } + ]) + ) +})) +handle('agent-hub:getStatus', () => ({ state: 'disconnected', connected: false })) +handle('agent-hub:getLogs', () => []) +handle('app-update:getState', () => ({ status: 'idle', currentVersion: '2.1.6' })) + +for (const channel of [ + 'export:start', + 'export:cancel', + 'export:reveal', + 'settings:selectDbRoot', + 'settings:openAccountRoot', + 'db:reopenWithRoot', + 'api:skillStatus', + 'api:readSkill', + 'api:revealSkill', + 'api:openSkillGithub', + 'api:testLocalRequest', + 'image:selectDecoder', + 'image:openDecoderDownload', + 'app-update:check', + 'app-update:download', + 'app-update:install', + 'agent-hub:clearLogs', + 'agent-hub:startLogin', + 'agent-hub:cancelLogin', + 'agent-hub:reconnect', + 'agent-hub:disconnect', + 'agent-hub:selectTestImage', + 'image:listCandidates', + 'image:analyze', + 'image:getInsight', + 'image:listInsights', + 'db:search', + 'db:getVideo' +]) { + if (!handlers.has(channel)) + handle(channel, () => ({ success: true, candidates: [], insights: [] })) +} + +app.whenReady().then(() => { + const window = new BrowserWindow({ + width: 1440, + height: 960, + show: false, + backgroundColor: '#ffffff', + webPreferences: { + preload: path.join(root, 'out/preload/index.js'), + contextIsolation: true, + nodeIntegration: false, + sandbox: false + } + }) + window.once('ready-to-show', () => window.show()) + window.loadFile(path.join(root, 'out/renderer/index.html')) +}) + +app.on('window-all-closed', () => app.quit()) diff --git a/tests/e2e/support/electron.ts b/tests/e2e/support/electron.ts new file mode 100644 index 0000000..c8d7d75 --- /dev/null +++ b/tests/e2e/support/electron.ts @@ -0,0 +1,57 @@ +import { _electron as electron, type ElectronApplication, type Page } from '@playwright/test' +import { mkdtempSync, rmSync } from 'fs' +import { tmpdir } from 'os' +import { resolve } from 'path' +import { loadEnv } from 'vite' + +const DEFAULT_WINDOW_CLOSE_DELAY_MS = 2000 + +export interface TestApplication { + app: ElectronApplication + page: Page + userData: string + close: () => Promise +} + +export async function launchTestApp( + options: { + mode?: 'connected' | 'disconnected' + userData?: string + largeContacts?: number + corruptCache?: boolean + aiFailure?: string + } = {} +): Promise { + const ownsDirectory = !options.userData + const userData = options.userData || mkdtempSync(resolve(tmpdir(), 'wxe-e2e-')) + const localTestEnv = loadEnv('test', process.cwd(), 'WXE_E2E_') + const configuredCloseDelay = Number( + process.env.WXE_E2E_CLOSE_DELAY_MS ?? localTestEnv.WXE_E2E_CLOSE_DELAY_MS + ) + const closeDelayMs = Number.isFinite(configuredCloseDelay) + ? Math.max(0, configuredCloseDelay) + : DEFAULT_WINDOW_CLOSE_DELAY_MS + const app = await electron.launch({ + args: [resolve('tests/e2e/support/electron-main.cjs')], + env: { + ...process.env, + WXE_E2E_USER_DATA: userData, + WXE_E2E_MODE: options.mode || 'connected', + WXE_E2E_LARGE_CONTACTS: String(options.largeContacts || 0), + WXE_E2E_CORRUPT_CACHE: options.corruptCache ? '1' : '0', + WXE_E2E_AI_FAILURE: options.aiFailure || '' + } + }) + const page = await app.firstWindow() + await page.waitForLoadState('domcontentloaded') + return { + app, + page, + userData, + close: async () => { + if (!page.isClosed() && closeDelayMs > 0) await page.waitForTimeout(closeDelayMs) + await app.close().catch(() => undefined) + if (ownsDirectory) rmSync(userData, { recursive: true, force: true }) + } + } +} diff --git a/tests/e2e/visual.spec.ts b/tests/e2e/visual.spec.ts new file mode 100644 index 0000000..964f2b4 --- /dev/null +++ b/tests/e2e/visual.spec.ts @@ -0,0 +1,37 @@ +import { expect, test } from '@playwright/test' +import { existsSync } from 'fs' +import { resolve } from 'path' +import { launchTestApp } from './support/electron' + +const baselineDirectory = resolve(`tests/e2e/__screenshots__/${process.platform}/visual.spec.ts`) +test.skip( + !existsSync(baselineDirectory) && process.env.WXE_UPDATE_VISUAL_BASELINES !== '1', + `No reviewed ${process.platform} visual baseline is committed yet` +) + +test('NAV-01 login page visual @visual', async () => { + const fixture = await launchTestApp({ mode: 'disconnected' }) + try { + await expect(fixture.page.getByRole('heading', { name: 'WechatExplorer' })).toBeVisible() + await expect(fixture.page).toHaveScreenshot('login-page.png', { + animations: 'disabled', + caret: 'hide' + }) + } finally { + await fixture.close() + } +}) + +test('ARCH-01 archive page visual @visual', async () => { + const fixture = await launchTestApp() + try { + await fixture.page.getByText('产品测试群', { exact: true }).click() + await expect(fixture.page.getByText('这是一条脱敏测试消息', { exact: true })).toBeVisible() + await expect(fixture.page).toHaveScreenshot('archive-page.png', { + animations: 'disabled', + caret: 'hide' + }) + } finally { + await fixture.close() + } +}) diff --git a/tests/fixtures/chat-data.json b/tests/fixtures/chat-data.json new file mode 100644 index 0000000..08b9a0f --- /dev/null +++ b/tests/fixtures/chat-data.json @@ -0,0 +1,97 @@ +{ + "self": { + "wxid": "wxid_fixture_self", + "nickname": "测试账号", + "accountRoot": "fixture-account" + }, + "contacts": [ + { + "m_nsUsrName": "group_regular@chatroom", + "m_nsNickName": "产品测试群", + "md5": "group-regular-md5", + "type": "group", + "avatar": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9ZQmcAAAAASUVORK5CYII=" + }, + { + "m_nsUsrName": "group_folded@chatroom", + "m_nsNickName": "折叠群聊样本", + "md5": "group-folded-md5", + "type": "group", + "isFolded": true + }, + { + "m_nsUsrName": "filehelper", + "m_nsNickName": "文件传输助手", + "md5": "file-helper-md5", + "type": "user" + } + ], + "messages": { + "group-regular-md5": [ + { + "id": "msg-text", + "from": "user", + "type": "普通文本", + "datetime": "2026-08-01 10:00:00", + "content": "这是一条脱敏测试消息", + "isSender": false, + "name": "测试成员", + "senderId": "wxid_fixture_member", + "createTime": 1785549600, + "contentData": { "type": "text", "content": "这是一条脱敏测试消息" } + }, + { + "id": "msg-image", + "from": "user", + "type": "图片", + "datetime": "2026-08-01 10:01:00", + "content": "[图片]", + "isSender": false, + "localId": 2, + "createTime": 1785549660, + "sessionId": "group_regular@chatroom", + "contentData": { "type": "image", "md5": "fixture-image-md5", "datName": "fixture.dat" } + }, + { + "id": "msg-unknown", + "from": "user", + "type": "不支持的消息", + "datetime": "2026-08-01 10:02:00", + "content": "[未知消息]", + "isSender": false, + "createTime": 1785549720, + "contentData": { "type": "unknown", "raw": "fixture-unknown" } + } + ], + "file-helper-md5": [ + { + "id": "msg-voice", + "from": "user", + "type": "语音", + "datetime": "2026-08-01 11:00:00", + "content": "[语音]", + "isSender": false, + "localId": 11, + "createTime": 1785553200, + "sessionId": "filehelper", + "contentData": { "type": "voice", "duration": 1 } + }, + { + "id": "msg-forward", + "from": "user", + "type": "合并转发", + "datetime": "2026-08-01 11:01:00", + "content": "[合并转发]", + "isSender": false, + "createTime": 1785553260, + "contentData": { + "type": "forwardBundle", + "title": "转发多条内容", + "items": [ + { "messageType": 1, "sender": "测试成员", "sentAt": "11:00", "text": "脱敏转发内容" } + ] + } + } + ] + } +} diff --git a/tests/integration/preload-contract.test.ts b/tests/integration/preload-contract.test.ts new file mode 100644 index 0000000..62d7b83 --- /dev/null +++ b/tests/integration/preload-contract.test.ts @@ -0,0 +1,76 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const invoke = vi.fn() +const on = vi.fn() +const removeListener = vi.fn() +const exposeInMainWorld = vi.fn() + +vi.mock('electron', () => ({ + contextBridge: { exposeInMainWorld }, + ipcRenderer: { invoke, on, removeListener } +})) +vi.mock('@electron-toolkit/preload', () => ({ electronAPI: { fixture: true } })) + +async function loadApi(): Promise { + vi.resetModules() + exposeInMainWorld.mockClear() + Object.defineProperty(process, 'contextIsolated', { configurable: true, value: true }) + await import('../../src/preload/index') + const exposed = exposeInMainWorld.mock.calls.find(([name]) => name === 'api') + if (!exposed) throw new Error('preload did not expose api') + return exposed[1] as typeof window.api +} + +describe('preload IPC contract', () => { + beforeEach(() => { + invoke.mockReset() + on.mockReset() + removeListener.mockReset() + }) + + it('forwards message and media parameters to the exact main channels', async () => { + const api = await loadApi() + invoke.mockResolvedValue({ success: true }) + + await api.getMessages('fixture-user', 10, 20, { limit: 50 }) + expect(invoke).toHaveBeenLastCalledWith('db:getMessages', 'fixture-user', 10, 20, { + limit: 50 + }) + + await api.getImage('fixture-md5', 'fixture.dat', 'fixture-session', { + force: true, + priority: 0 + }) + expect(invoke).toHaveBeenLastCalledWith( + 'db:getImage', + 'fixture-md5', + 'fixture.dat', + 'fixture-session', + { force: true, priority: 0 } + ) + }) + + it('preserves key API return values without exposing ipcRenderer', async () => { + const api = await loadApi() + invoke.mockResolvedValueOnce({ success: false, code: 'DATABASE_OPEN_FAILED' }) + await expect(api.testConnection('b'.repeat(64), 'fixture-root')).resolves.toEqual({ + success: false, + code: 'DATABASE_OPEN_FAILED' + }) + expect(invoke).toHaveBeenCalledWith('db:testConnection', 'b'.repeat(64), 'fixture-root') + expect(api).not.toHaveProperty('ipcRenderer') + expect(api).not.toHaveProperty('send') + }) + + it('unsubscribes the same listener registered for native database changes', async () => { + const api = await loadApi() + const callback = vi.fn() + const unsubscribe = api.onWcdbChange(callback) + expect(on).toHaveBeenCalledWith('wcdb-change', expect.any(Function)) + const listener = on.mock.calls.at(-1)?.[1] + listener({}, { type: 'insert', json: '{"fixture":true}' }) + expect(callback).toHaveBeenCalledWith({ type: 'insert', json: '{"fixture":true}' }) + unsubscribe() + expect(removeListener).toHaveBeenCalledWith('wcdb-change', listener) + }) +}) diff --git a/tests/setup/component.ts b/tests/setup/component.ts new file mode 100644 index 0000000..0e6c9df --- /dev/null +++ b/tests/setup/component.ts @@ -0,0 +1,15 @@ +import '@testing-library/jest-dom/vitest' +import { cleanup } from '@testing-library/react' +import { afterEach, vi } from 'vitest' + +afterEach(() => cleanup()) + +Object.defineProperty(globalThis.URL, 'createObjectURL', { + configurable: true, + value: vi.fn(() => 'blob:wxe-test-audio') +}) + +Object.defineProperty(globalThis.URL, 'revokeObjectURL', { + configurable: true, + value: vi.fn() +}) diff --git a/tests/smoke/native-environment.test.mjs b/tests/smoke/native-environment.test.mjs new file mode 100644 index 0000000..d27a2e4 --- /dev/null +++ b/tests/smoke/native-environment.test.mjs @@ -0,0 +1,23 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import test from 'node:test' + +const enabled = process.env.WXE_REAL_DATA_SMOKE === '1' + +test( + 'native WCDB opens a disposable fixture account on this machine', + { skip: enabled ? false : 'set WXE_REAL_DATA_SMOKE=1 and WXE_SMOKE_DB_ROOT to opt in' }, + () => { + const root = process.env.WXE_SMOKE_DB_ROOT || '' + assert.ok(root, 'WXE_SMOKE_DB_ROOT is required') + assert.ok(fs.existsSync(root), 'WXE_SMOKE_DB_ROOT must exist') + } +) + +test('system permission prompts are verified manually on a clean OS account', { + skip: 'manual smoke checklist: docs/testing.md' +}) + +test('signed installer install, upgrade and uninstall are verified manually', { + skip: 'manual smoke checklist: docs/testing.md' +}) diff --git a/tests/stability-compat.test.mjs b/tests/stability-compat.test.mjs deleted file mode 100644 index dc1b5ed..0000000 --- a/tests/stability-compat.test.mjs +++ /dev/null @@ -1,64 +0,0 @@ -import assert from 'node:assert/strict' -import test from 'node:test' -import { parseMessageContent } from '../src/main/message-parser.ts' -import { classifyStickerHttpFailure } from '../src/shared/sticker.ts' - -test('merged forwarding messages expose expandable record items', () => { - const content = ` - 项目讨论记录19 - - - - 2026-08-01 10:00 - - - - - 2026-08-01 10:01 - - - ]]> - ` - - const parsed = parseMessageContent(content, 49) - assert.equal(parsed.type, 'forwardBundle') - assert.equal(parsed.title, '项目讨论记录') - assert.deepEqual( - parsed.items.map((item) => [item.sender, item.text]), - [ - ['张三', '第一条消息'], - ['李四', '[图片]'] - ] - ) -}) - -test('unknown message types are not misclassified as text', () => { - const parsed = parseMessageContent('1', 9999) - assert.equal(parsed.type, 'unknown') - assert.equal(parsed.messageType, 9999) -}) - -test('sticker 403 with expired timestamp is classified as an expired link', () => { - const result = classifyStickerHttpFailure( - 403, - 'https://example.invalid/sticker?expire=1700000000', - 1_800_000_000_000 - ) - assert.equal(result.code, 'link_expired') -}) - -test('sticker authorization and removal failures remain distinct', () => { - assert.equal( - classifyStickerHttpFailure(401, 'https://example.invalid/sticker').code, - 'authentication_required' - ) - assert.equal( - classifyStickerHttpFailure(403, 'https://example.invalid/sticker').code, - 'access_denied' - ) - assert.equal( - classifyStickerHttpFailure(404, 'https://example.invalid/sticker').code, - 'resource_removed' - ) -}) diff --git a/tests/unit/bootstrap-cache.test.ts b/tests/unit/bootstrap-cache.test.ts new file mode 100644 index 0000000..5e09045 --- /dev/null +++ b/tests/unit/bootstrap-cache.test.ts @@ -0,0 +1,81 @@ +import { mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import type { Contact, Message } from '../../src/shared/types' + +const userData = mkdtempSync(join(tmpdir(), 'wxe-bootstrap-test-')) + +vi.mock('electron', () => ({ + app: { getPath: () => userData } +})) + +import { + clearBootstrapCache, + flushBootstrapCacheWritesSync, + getBootstrapCache, + getCachedMessages, + saveBootstrapContacts, + saveCachedMessages +} from '../../src/main/services/bootstrap-cache' + +const accountRoot = 'fixture-account-root' +const contact: Contact = { + m_nsUsrName: 'fixture-user', + m_nsNickName: '脱敏联系人', + md5: 'fixture-md5', + type: 'user' +} + +function findFile(name: string): string { + const visit = (directory: string): string | null => { + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const file = join(directory, entry.name) + if (entry.isDirectory()) { + const nested = visit(file) + if (nested) return nested + } else if (entry.name === name) return file + } + return null + } + const result = visit(userData) + if (!result) throw new Error(`${name} was not written`) + return result +} + +describe('bootstrap cache', () => { + beforeAll(() => rmSync(userData, { recursive: true, force: true })) + beforeEach(() => clearBootstrapCache()) + afterAll(() => rmSync(userData, { recursive: true, force: true })) + + it('persists contacts and caps each message bucket', () => { + saveBootstrapContacts(accountRoot, [contact]) + const messages: Message[] = Array.from({ length: 140 }, (_, index) => ({ + id: String(index), + from: 'user', + type: '文本', + datetime: '2026-08-01 10:00:00', + content: `fixture-${index}`, + isSender: false, + createTime: index + 1 + })) + saveCachedMessages(accountRoot, contact.md5, undefined, undefined, messages) + flushBootstrapCacheWritesSync() + clearBootstrapCache() + + expect(getBootstrapCache(accountRoot)?.contacts).toEqual([contact]) + const cached = getCachedMessages(accountRoot, contact.md5) + expect(cached).toHaveLength(120) + expect(cached[0].id).toBe('20') + }) + + it('degrades to a cache miss when persisted JSON is corrupted', () => { + saveBootstrapContacts(accountRoot, [contact]) + flushBootstrapCacheWritesSync() + const startup = findFile('startup.json') + expect(readFileSync(startup, 'utf8')).toContain('fixture-user') + writeFileSync(startup, '{broken', 'utf8') + clearBootstrapCache() + expect(getBootstrapCache(accountRoot)).toBeNull() + }) +}) diff --git a/tests/unit/image-decrypt.test.ts b/tests/unit/image-decrypt.test.ts new file mode 100644 index 0000000..7de42c7 --- /dev/null +++ b/tests/unit/image-decrypt.test.ts @@ -0,0 +1,56 @@ +import crypto from 'crypto' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' + +const root = mkdtempSync(join(tmpdir(), 'wxe-image-test-')) + +vi.mock('electron', () => ({ app: { getPath: () => root } })) +vi.mock('../../src/main/services/settings-store', () => ({ + loadSettings: () => ({ ffmpegPath: '' }) +})) +vi.mock('../../src/main/wcdb4-client', () => ({ Wcdb4Client: class {} })) + +import { ImageDecryptService } from '../../src/main/image-decrypt-service' + +const aesKey = '0123456789abcdef' +const xorKey = 0x40 + +function writeV2Dat(file: string): Buffer { + const aesPlain = Buffer.from([0x89, 0x50, 0x4e, 0x47, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]) + const padded = Buffer.concat([aesPlain, Buffer.alloc(16, 16)]) + const cipher = crypto.createCipheriv('aes-128-ecb', Buffer.from(aesKey, 'ascii'), null) + cipher.setAutoPadding(false) + const encrypted = Buffer.concat([cipher.update(padded), cipher.final()]) + const raw = Buffer.from([13, 14]) + const tailPlain = Buffer.from([15, 16]) + const tailCipher = Buffer.from(tailPlain.map((value) => value ^ xorKey)) + const header = Buffer.alloc(15) + Buffer.from([0x07, 0x08, 0x56, 0x32, 0x08, 0x07]).copy(header) + header.writeInt32LE(aesPlain.length, 6) + header.writeInt32LE(tailPlain.length, 10) + writeFileSync(file, Buffer.concat([header, encrypted, raw, tailCipher])) + return Buffer.concat([aesPlain, raw, tailPlain]) +} + +describe('DAT image decryption', () => { + beforeAll(() => mkdirSync(root, { recursive: true })) + afterAll(() => rmSync(root, { recursive: true, force: true })) + + it('decrypts a synthetic V2 AES/raw/XOR fixture', () => { + const file = join(root, 'fixture.dat') + const expected = writeV2Dat(file) + expect(new ImageDecryptService('0x40', aesKey).decryptImage(file)).toEqual(expected) + }) + + it('rejects the wrong AES key and unsupported legacy signatures accurately', () => { + const file = join(root, 'fixture.dat') + writeV2Dat(file) + expect(new ImageDecryptService('0x40', 'fedcba9876543210').decryptImage(file)).toBeNull() + + const legacy = join(root, 'legacy.dat') + writeFileSync(legacy, Buffer.from([0xff, 0xd8, 0xff, 0x00])) + expect(new ImageDecryptService('0x40', aesKey).decryptImage(legacy)).toBeNull() + }) +}) diff --git a/tests/unit/key-validation.test.ts b/tests/unit/key-validation.test.ts new file mode 100644 index 0000000..a8f34af --- /dev/null +++ b/tests/unit/key-validation.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => ({ + app: { getPath: () => 'fixture-settings' }, + safeStorage: { isEncryptionAvailable: () => false } +})) +import { + isDatabaseKeyFormatValid, + mapAutoDetectPhase, + normalizeDatabaseKey +} from '../../src/renderer/src/features/settings/database-key/utils' +import { + normalizeImageXorKey, + validateImageKeyRequest +} from '../../src/main/services/image-key-config-service' + +describe('database key validation', () => { + it('normalizes a prefixed key without accepting the wrong length', () => { + const key = `0x${'a'.repeat(64)}` + expect(normalizeDatabaseKey(key)).toBe('a'.repeat(64)) + expect(isDatabaseKeyFormatValid(key)).toBe(true) + expect(isDatabaseKeyFormatValid('a'.repeat(63))).toBe(false) + expect(isDatabaseKeyFormatValid('z'.repeat(64))).toBe(false) + }) + + it('maps automatic detection progress into stable phases', () => { + expect(mapAutoDetectPhase('正在查找微信进程')).toBeGreaterThan(0) + expect(mapAutoDetectPhase('已获取数据库密钥')).toBe(5) + }) +}) + +describe('image key validation', () => { + it.each([ + [64, '0x40'], + ['64', '0x40'], + ['0xff', '0xFF'], + ['', '0x40'] + ])('normalizes %s to %s', (input, expected) => { + expect(normalizeImageXorKey(input)).toBe(expected) + }) + + it('keeps database and image key validation independent', () => { + const result = validateImageKeyRequest({ + resourceRoot: ' fixture-root ', + xorKey: '64', + aesKey: '0123456789abcdef' + }) + expect(result).toEqual({ + success: true, + resourceRoot: 'fixture-root', + xorKey: '0x40', + aesKey: '0123456789abcdef' + }) + expect( + validateImageKeyRequest({ resourceRoot: 'fixture-root', xorKey: '999', aesKey: 'short' }) + .success + ).toBe(false) + }) +}) diff --git a/tests/unit/message-pagination.test.ts b/tests/unit/message-pagination.test.ts new file mode 100644 index 0000000..c6a836f --- /dev/null +++ b/tests/unit/message-pagination.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest' +import type { Message } from '../../src/shared/types' +import { mergeMessagePages } from '../../src/renderer/src/utils/message-pages' + +const makeMessage = (id: string, createTime: number): Message => ({ + id, + from: 'user', + type: '文本', + datetime: new Date(createTime * 1000).toISOString(), + content: id, + isSender: false, + createTime +}) + +describe('message pagination', () => { + it('sorts older pages and removes overlapping records', () => { + const merged = mergeMessagePages( + [makeMessage('oldest', 1), makeMessage('overlap', 2)], + [makeMessage('overlap', 2), makeMessage('latest', 3)] + ) + expect(merged.map((message) => message.id)).toEqual(['oldest', 'overlap', 'latest']) + }) +}) diff --git a/tests/unit/message-parser.test.ts b/tests/unit/message-parser.test.ts new file mode 100644 index 0000000..7c91dd6 --- /dev/null +++ b/tests/unit/message-parser.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest' +import { parseMessageContent } from '../../src/main/message-parser' + +describe('message parser', () => { + it('parses image, voice and sticker messages without confusing their types', () => { + expect(parseMessageContent('', 3)).toMatchObject({ + type: 'image', + md5: '0123456789abcdef0123456789abcdef' + }) + expect(parseMessageContent('voice fixture', 34)).toEqual({ type: 'voice' }) + expect( + parseMessageContent( + '', + 47 + ) + ).toMatchObject({ type: 'sticker', md5: 'abcdefabcdefabcdefabcdefabcdefab' }) + }) + + it('parses merged forwards and preserves nested visible text', () => { + const parsed = parseMessageContent( + '19转发多条内容测试成员脱敏内容', + 49 + ) + expect(parsed.type).toBe('forwardBundle') + if (parsed.type === 'forwardBundle') { + expect(parsed.title).toBe('转发多条内容') + expect(parsed.items.map((item) => item.text).join(' ')).toContain('脱敏内容') + } + }) + + it('uses an explicit unknown type for unsupported messages', () => { + expect(parseMessageContent('opaque fixture payload', 999)).toEqual({ + type: 'unknown', + raw: 'opaque fixture payload', + messageType: 999 + }) + }) +}) diff --git a/tests/unit/message-state.test.ts b/tests/unit/message-state.test.ts new file mode 100644 index 0000000..b0f974a --- /dev/null +++ b/tests/unit/message-state.test.ts @@ -0,0 +1,81 @@ +// @vitest-environment jsdom +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { Message } from '../../src/shared/types' +import { + buildMessageGroups, + formatMessageTime +} from '../../src/renderer/src/components/chat/messageGrouping' +import { + buildSearchCacheKey, + parseSearchCacheKey, + readSearchCache, + writeSearchCache +} from '../../src/renderer/src/components/search/searchUtils' + +const message = (id: string, createTime: number, from = 'user'): Message => ({ + id, + from, + type: '文本', + datetime: new Date(createTime * 1000).toISOString(), + content: id, + isSender: from === 'assistant', + senderId: from, + createTime +}) + +describe('message grouping and dates', () => { + it('groups adjacent messages but keeps system and distant messages separate', () => { + const groups = buildMessageGroups([ + message('one', 1000), + message('two', 1060), + { ...message('system', 1070, 'system'), type: '系统消息' }, + message('three', 2000) + ]) + expect(groups.map((group) => group.messages.map((item) => item.id))).toEqual([ + ['one', 'two'], + ['system'], + ['three'] + ]) + }) + + it('formats today and yesterday deterministically', () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-08-01T12:00:00+08:00')) + expect( + formatMessageTime( + message('today', Math.floor(Date.parse('2026-08-01T10:00:00+08:00') / 1000)) + ) + ).toContain('今天') + expect( + formatMessageTime( + message('yesterday', Math.floor(Date.parse('2026-07-31T10:00:00+08:00') / 1000)) + ) + ).toContain('昨天') + vi.useRealTimers() + }) +}) + +describe('search cache', () => { + beforeEach(() => localStorage.clear()) + + it('normalizes the key and survives invalid persisted state', () => { + const key = buildSearchCacheKey('global', '', '7d', ' Windows 性能 ') + expect(parseSearchCacheKey(key)).toMatchObject({ query: 'windows 性能', range: '7d' }) + localStorage.setItem('wxe_ai_search_cache_v1', '{broken') + expect(readSearchCache(key)).toBeNull() + }) + + it('writes and reads an isolated cache record', () => { + const key = buildSearchCacheKey('conversation', 'fixture-contact', 'today', '图片') + const record = { + version: 1 as const, + key, + query: '图片', + answer: '固定假回答', + evidence: [], + createdAt: 1 + } + writeSearchCache(record) + expect(readSearchCache(key)).toMatchObject({ answer: '固定假回答' }) + }) +}) diff --git a/tests/unit/security-and-errors.test.ts b/tests/unit/security-and-errors.test.ts new file mode 100644 index 0000000..e4aea7e --- /dev/null +++ b/tests/unit/security-and-errors.test.ts @@ -0,0 +1,53 @@ +import { mkdtempSync, readFileSync, rmSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { afterAll, describe, expect, it, vi } from 'vitest' +import { classifyStickerHttpFailure } from '../../src/shared/sticker' + +const logs = mkdtempSync(join(tmpdir(), 'wxe-log-test-')) +vi.mock('electron', () => ({ + app: { getPath: () => logs, isPackaged: true }, + shell: { showItemInFolder: vi.fn() } +})) + +import { AppLogger } from '../../src/main/app-logger' + +describe('sensitive logging', () => { + afterAll(() => rmSync(logs, { recursive: true, force: true })) + + it('does not persist database keys, API keys or bearer tokens', () => { + const databaseKey = 'a'.repeat(64) + const logger = new AppLogger() + logger.write({ + level: 'error', + scope: 'fixture', + message: `database open failed key=${databaseKey}`, + details: { + databaseKey, + apiKey: 'sk-fixture-secret-value', + authorization: 'Bearer fixture-token-value' + } + }) + const persisted = readFileSync(logger.logPath, 'utf8') + expect(persisted).not.toContain(databaseKey) + expect(persisted).not.toContain('sk-fixture-secret-value') + expect(persisted).not.toContain('fixture-token-value') + expect(persisted).toContain('***') + }) +}) + +describe('sticker HTTP failures', () => { + it('distinguishes expired, unauthorized, removed and rate-limited resources', () => { + expect(classifyStickerHttpFailure(403, 'https://fixture.invalid/a?expires=1', 2_000).code).toBe( + 'link_expired' + ) + expect(classifyStickerHttpFailure(403, 'https://fixture.invalid/a').code).toBe('access_denied') + expect(classifyStickerHttpFailure(401, 'https://fixture.invalid/a').code).toBe( + 'authentication_required' + ) + expect(classifyStickerHttpFailure(410, 'https://fixture.invalid/a').code).toBe( + 'resource_removed' + ) + expect(classifyStickerHttpFailure(429, 'https://fixture.invalid/a').code).toBe('rate_limited') + }) +}) diff --git a/vitest.component.config.ts b/vitest.component.config.ts new file mode 100644 index 0000000..ba821c6 --- /dev/null +++ b/vitest.component.config.ts @@ -0,0 +1,20 @@ +import { resolve } from 'path' +import react from '@vitejs/plugin-react' +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + plugins: [react()], + resolve: { + alias: { + '@renderer': resolve(__dirname, 'src/renderer/src') + } + }, + test: { + name: 'component', + environment: 'jsdom', + include: ['tests/component/**/*.test.tsx'], + setupFiles: ['tests/setup/component.ts'], + clearMocks: true, + restoreMocks: true + } +}) diff --git a/vitest.integration.config.ts b/vitest.integration.config.ts new file mode 100644 index 0000000..6448a66 --- /dev/null +++ b/vitest.integration.config.ts @@ -0,0 +1,17 @@ +import { resolve } from 'path' +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + resolve: { + alias: { + '@renderer': resolve(__dirname, 'src/renderer/src') + } + }, + test: { + name: 'integration', + environment: 'node', + include: ['tests/integration/**/*.test.ts'], + clearMocks: true, + restoreMocks: true + } +}) diff --git a/vitest.unit.config.ts b/vitest.unit.config.ts new file mode 100644 index 0000000..9cce389 --- /dev/null +++ b/vitest.unit.config.ts @@ -0,0 +1,17 @@ +import { resolve } from 'path' +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + resolve: { + alias: { + '@renderer': resolve(__dirname, 'src/renderer/src') + } + }, + test: { + name: 'unit', + environment: 'node', + include: ['tests/unit/**/*.test.ts'], + clearMocks: true, + restoreMocks: true + } +})