commit 428795c6a5a698042021025129d1ef10b2f16894 Author: ren Date: Wed Oct 15 17:52:44 2025 +0800 feat: 初始化 Gitea Action 发送钉钉机器人消息项目 diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..2886ad1 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,15 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 2 + +[*.{yaml, yml}] +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0df82b5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,149 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) +web_modules/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional stylelint cache +.stylelintcache + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variable files +.env +.env.* +!.env.example + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next +out + +# Nuxt.js build / generate output +.nuxt +dist +.output + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and not Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# vuepress v2.x temp and cache directory +.temp +.cache + +# Sveltekit cache directory +.svelte-kit/ + +# vitepress build output +**/.vitepress/dist + +# vitepress cache directory +**/.vitepress/cache + +# Docusaurus cache and generated files +.docusaurus + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# Firebase cache directory +.firebase/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# yarn v3 +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/sdks +!.yarn/versions + +# Vite files +vite.config.js.timestamp-* +vite.config.ts.timestamp-* +.vite/ + +.DS_Store + +# Editors +.idea +.vscode +.cursor +.trae diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 0000000..13b602e --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,3 @@ +# 运行代码检查 +pnpm run lint +pnpm run format \ No newline at end of file diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..6d7ade5 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,11 @@ +{ + "semi": true, + "trailingComma": "all", + "singleQuote": true, + "printWidth": 100, + "tabWidth": 2, + "useTabs": false, + "bracketSpacing": true, + "arrowParens": "avoid", + "endOfLine": "lf" +} \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..4ab94d9 --- /dev/null +++ b/README.md @@ -0,0 +1,218 @@ +# 钉钉消息推送 Gitea Action + +一个用于向钉钉群发送消息的 Gitea Action,支持文本、Markdown、链接、ActionCard 和 FeedCard 消息类型。 + +## 快速开始 + +### 1. 获取钉钉 Webhook URL + +1. 在钉钉群中添加"自定义机器人" +2. 复制生成的 Webhook URL +3. 配置安全设置 + +### 2. 在 Gitea 仓库中使用 + +创建 `.gitea/workflows/dingtalk-notify.yml` 文件: + +```yaml +name: DingTalk Notification +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + notify: + runs-on: ubuntu-latest + steps: + - name: Send DingTalk Message + uses: actions/dingtalk-bot@v0.1 + with: + webhook_url: ${{ secrets.DINGTALK_WEBHOOK_URL }} + message_type: 'text' + content: '🎉 代码已推送到 ${{ github.repository }} 仓库!' +``` + +## 输入参数 + +| 参数名 | 必需 | 默认值 | 描述 | +| ----------------- | ---- | ------- | -------------------------------------------------------------------- | +| `webhook_url` | 是 | - | 钉钉机器人 Webhook URL | +| `message_type` | 否 | `text` | 消息类型:`text`、`markdown`、`link`、`actionCard`、`feedCard` | +| `content` | 是 | - | 消息内容 | +| `title` | 否 | - | 消息标题(markdown 和 link 类型必需) | +| `at_mobiles` | 否 | - | @的手机号,多个用逗号分隔 | +| `at_all` | 否 | `false` | 是否@所有人 | +| `link_url` | 否 | - | 链接地址(link 类型必需) | +| `pic_url` | 否 | - | 图片地址(link 类型可选) | +| `btn_orientation` | 否 | `0` | actionCard 按钮排列方向:`0`竖直、`1`水平 | +| `single_title` | 否 | - | actionCard 单按钮标题(整体跳转) | +| `single_url` | 否 | - | actionCard 单按钮链接(整体跳转) | +| `buttons` | 否 | - | actionCard 多按钮配置(JSON 数组),每项 `{ title, actionURL }` | +| `feed_links` | 否 | - | feedCard 链接列表(JSON 数组),每项 `{ title, messageURL, picURL }` | + +## 输出参数 + +| 参数名 | 描述 | +| --------------- | -------------------- | +| `success` | 是否发送成功 | +| `message_id` | 消息ID(如果可用) | +| `error_message` | 错误信息(如果失败) | + +## 使用示例 + +### 文本消息 + +```yaml +- name: Send Text Message + uses: actions/dingtalk-bot@v0.1 + with: + webhook_url: ${{ secrets.DINGTALK_WEBHOOK_URL }} + message_type: 'text' + content: '✅ 构建成功!提交者:${{ github.actor }}' + at_mobiles: '13800138000,13900139000' +``` + +### Markdown 消息 + +```yaml +- name: Send Markdown Message + uses: actions/dingtalk-bot@v0.1 + with: + webhook_url: ${{ secrets.DINGTALK_WEBHOOK_URL }} + message_type: 'markdown' + title: '构建报告' + content: | + ## 🎉 构建成功 + + **仓库**: ${{ github.repository }} + **分支**: ${{ github.ref_name }} + **提交者**: ${{ github.actor }} + **提交信息**: ${{ github.event.head_commit.message }} + + [查看详情](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) + at_all: true +``` + +### 链接消息 + +```yaml +- name: Send Link Message + uses: actions/dingtalk-bot@v0.1 + with: + webhook_url: ${{ secrets.DINGTALK_WEBHOOK_URL }} + message_type: 'link' + title: '新版本发布' + content: '项目已发布新版本,点击查看详情' + link_url: 'https://github.com/${{ github.repository }}/releases/latest' + pic_url: 'https://avatars.githubusercontent.com/u/${{ github.actor_id }}' +``` + +### ActionCard(整体跳转单按钮) + +```yaml +- name: Send ActionCard Single Button + uses: actions/dingtalk-bot@v0.1 + with: + webhook_url: ${{ secrets.DINGTALK_WEBHOOK_URL }} + message_type: 'actionCard' + title: '乔布斯 20 年' + content: | + ![乔布斯](https://example.com/steve.jpg) + + 乔布斯 20 年 + btn_orientation: '0' + single_title: '阅读全文' + single_url: 'https://www.dingtalk.com/' +``` + +### ActionCard(独立跳转多按钮) + +```yaml +- name: Send ActionCard Multiple Buttons + uses: actions/dingtalk-bot@v0.1 + with: + webhook_url: ${{ secrets.DINGTALK_WEBHOOK_URL }} + message_type: 'actionCard' + title: '构建结果' + content: | + ## 本次构建完成 + - 版本:${{ github.ref_name }} + - 提交者:${{ github.actor }} + btn_orientation: '1' + buttons: >- + [ + { "title": "查看日志", "actionURL": "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" }, + { "title": "查看发布页", "actionURL": "https://github.com/${{ github.repository }}/releases/latest" } + ] +``` + +### FeedCard(图文链接) + +```yaml +- name: Send FeedCard + uses: actions/dingtalk-bot@v0.1 + with: + webhook_url: ${{ secrets.DINGTALK_WEBHOOK_URL }} + message_type: 'feedCard' + feed_links: >- + [ + { + "title": "时代的火车向前开", + "messageURL": "https://www.dingtalk.com/", + "picURL": "https://static.dingtalk.com/media/xxx1.jpg" + }, + { + "title": "正当防卫体育大战", + "messageURL": "https://www.dingtalk.com/", + "picURL": "https://static.dingtalk.com/media/xxx2.jpg" + } + ] +``` + +## 高级配置 + +### 条件发送 + +```yaml +# 构建失败时发送通知 +- name: Send Notification on Failure + if: failure() + uses: actions/dingtalk-bot@v0.1 + with: + webhook_url: ${{ secrets.DINGTALK_WEBHOOK_URL }} + message_type: 'text' + content: '❌ 构建失败!请检查代码。' + at_all: true + +# 构建成功时发送通知 +- name: Send Notification on Success + if: success() + uses: actions/dingtalk-bot@v0.1 + with: + webhook_url: ${{ secrets.DINGTALK_WEBHOOK_URL }} + message_type: 'markdown' + title: '✅ 构建成功' + content: | + ## 构建完成 + + **项目**: ${{ github.repository }} + **分支**: ${{ github.ref_name }} + **提交者**: ${{ github.actor }} + **提交信息**: ${{ github.event.head_commit.message }} + + [查看详情](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) + at_mobiles: ['${{ secrets.NOTIFY_MOBILE }}'] +``` + +### 多环境配置 + +```yaml +- name: Send to Different Groups + uses: actions/dingtalk-bot@v0.1 + with: + webhook_url: ${{ github.ref == 'refs/heads/main' && secrets.PROD_DINGTALK_WEBHOOK || secrets.DEV_DINGTALK_WEBHOOK }} + message_type: 'text' + content: '部署到 ${{ github.ref == "refs/heads/main" && "生产环境" || "测试环境" }} 完成' +``` diff --git a/action.yml b/action.yml new file mode 100644 index 0000000..8463d07 --- /dev/null +++ b/action.yml @@ -0,0 +1,64 @@ +name: 'DingTalk Notification' +description: 'Send notifications to DingTalk Bot via webhook' +author: 'DingTalk Action Team' +branding: + icon: 'message-circle' + color: 'blue' + +inputs: + webhook_url: + description: 'DingTalk webhook URL' + required: true + message_type: + description: 'Message type (text, markdown, link, actionCard, feedCard)' + required: false + default: 'text' + content: + description: 'Message content' + required: true + title: + description: 'Message title (required for markdown and link types)' + required: false + at_mobiles: + description: 'Mobile numbers to mention (comma separated)' + required: false + at_all: + description: 'Whether to mention all members' + required: false + default: 'false' + link_url: + description: 'Link URL (required for link type messages)' + required: false + pic_url: + description: 'Picture URL (optional for link type messages)' + required: false + # ActionCard inputs + btn_orientation: + description: "Button orientation for actionCard ('0' vertical, '1' horizontal)" + required: false + default: '0' + single_title: + description: 'Single button title for actionCard (overall jump)' + required: false + single_url: + description: 'Single button URL for actionCard (overall jump)' + required: false + buttons: + description: 'ActionCard multiple buttons JSON array: [{title, actionURL}]' + required: false + # FeedCard inputs + feed_links: + description: 'FeedCard links JSON array: [{title, messageURL, picURL}]' + required: false + +outputs: + success: + description: 'Whether the message was sent successfully' + message_id: + description: 'DingTalk message ID if available' + error_message: + description: 'Error message if sending failed' + +runs: + using: 'node20' + main: 'src/index.js' diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..9f5ee13 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,71 @@ +import js from '@eslint/js'; +import prettier from 'eslint-config-prettier'; +import jsdoc from 'eslint-plugin-jsdoc'; + +export default [ + js.configs.recommended, + jsdoc.configs['flat/recommended'], + { + languageOptions: { + ecmaVersion: 2020, + sourceType: 'module', + globals: { + console: 'readonly', + process: 'readonly', + Buffer: 'readonly', + __dirname: 'readonly', + __filename: 'readonly', + global: 'readonly', + module: 'readonly', + require: 'readonly', + exports: 'readonly', + setTimeout: 'readonly', + clearTimeout: 'readonly', + setInterval: 'readonly', + clearInterval: 'readonly', + setImmediate: 'readonly', + clearImmediate: 'readonly', + URL: 'readonly', + fetch: 'readonly', + AbortController: 'readonly', + }, + }, + plugins: { + jsdoc, + }, + rules: { + 'jsdoc/require-description': 'warn', + 'jsdoc/require-description-complete-sentence': 'off', + 'jsdoc/require-param-description': 'warn', + 'jsdoc/require-returns-description': 'warn', + 'jsdoc/check-param-names': 'error', + 'jsdoc/check-tag-names': 'error', + 'jsdoc/check-types': 'error', + 'jsdoc/no-undefined-types': 'warn', + 'jsdoc/valid-types': 'error', + 'jsdoc/tag-lines': ['error', 'any', { startLines: 1 }], + 'no-console': 'off', + 'no-unused-vars': ['error', { argsIgnorePattern: '^_' }], + 'no-var': 'error', + 'prefer-const': 'error', + 'prefer-arrow-callback': 'error', + 'arrow-spacing': 'error', + 'object-shorthand': 'error', + 'prefer-template': 'error', + semi: ['error', 'always'], + quotes: ['error', 'single'], + 'comma-dangle': ['error', 'always-multiline'], + indent: ['error', 2, { SwitchCase: 0 }], + 'max-len': ['warn', { code: 100, ignoreUrls: true }], + 'no-throw-literal': 'error', + 'prefer-promise-reject-errors': 'error', + 'no-eval': 'error', + 'no-implied-eval': 'error', + 'no-new-func': 'error', + }, + }, + { + files: ['src/**/*.js', 'tests/**/*.js'], + }, + prettier, +]; diff --git a/package.json b/package.json new file mode 100644 index 0000000..5e43459 --- /dev/null +++ b/package.json @@ -0,0 +1,41 @@ +{ + "name": "dingtalk-bot", + "version": "0.1.0", + "description": "A Gitea Action for sending notifications to DingTalk", + "main": "src/index.js", + "type": "module", + "scripts": { + "lint": "eslint src/**/*.js", + "lint:fix": "eslint src/**/*.js --fix", + "format": "prettier --write src/**/*.js", + "format:check": "prettier --check src/**/*.js", + "check": "pnpm run lint && pnpm run format:check", + "pre-commit": "pnpm run lint && pnpm run format", + "test": "vitest run", + "test:unit": "vitest tests/unit", + "test:integration": "vitest tests/integration", + "test:coverage": "vitest --coverage", + "test:watch": "vitest --watch", + "test:run": "vitest run", + "prepare": "husky" + }, + "keywords": [ + "gitea", + "action", + "dingtalk", + "notification" + ], + "engines": { + "node": ">=20.0.0" + }, + "devDependencies": { + "@eslint/js": "^9.37.0", + "@vitest/coverage-v8": "^3.2.4", + "eslint": "^9.37.0", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-jsdoc": "^50.0.0", + "husky": "^9.1.7", + "prettier": "^3.6.2", + "vitest": "^3.2.4" + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..90e1a04 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,2124 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + '@eslint/js': + specifier: ^9.37.0 + version: 9.37.0 + '@vitest/coverage-v8': + specifier: ^3.2.4 + version: 3.2.4(vitest@3.2.4) + eslint: + specifier: ^9.37.0 + version: 9.37.0 + eslint-config-prettier: + specifier: ^10.1.8 + version: 10.1.8(eslint@9.37.0) + eslint-plugin-jsdoc: + specifier: ^50.0.0 + version: 50.0.0(eslint@9.37.0) + husky: + specifier: ^9.1.7 + version: 9.1.7 + prettier: + specifier: ^3.6.2 + version: 3.6.2 + vitest: + specifier: ^3.2.4 + version: 3.2.4 + +packages: + + '@ampproject/remapping@2.3.0': + resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} + engines: {node: '>=6.0.0'} + + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.27.1': + resolution: {integrity: sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.28.4': + resolution: {integrity: sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/types@7.28.4': + resolution: {integrity: sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==} + engines: {node: '>=6.9.0'} + + '@bcoe/v8-coverage@1.0.2': + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} + engines: {node: '>=18'} + + '@es-joy/jsdoccomment@0.46.0': + resolution: {integrity: sha512-C3Axuq1xd/9VqFZpW4YAzOx5O9q/LP46uIQy/iNDpHG3fmPa6TBtvfglMCs3RBiBxAIi0Go97r8+jvTt55XMyQ==} + engines: {node: '>=16'} + + '@esbuild/aix-ppc64@0.25.11': + resolution: {integrity: sha512-Xt1dOL13m8u0WE8iplx9Ibbm+hFAO0GsU2P34UNoDGvZYkY8ifSiy6Zuc1lYxfG7svWE2fzqCUmFp5HCn51gJg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.25.11': + resolution: {integrity: sha512-9slpyFBc4FPPz48+f6jyiXOx/Y4v34TUeDDXJpZqAWQn/08lKGeD8aDp9TMn9jDz2CiEuHwfhRmGBvpnd/PWIQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.25.11': + resolution: {integrity: sha512-uoa7dU+Dt3HYsethkJ1k6Z9YdcHjTrSb5NUy66ZfZaSV8hEYGD5ZHbEMXnqLFlbBflLsl89Zke7CAdDJ4JI+Gg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.25.11': + resolution: {integrity: sha512-Sgiab4xBjPU1QoPEIqS3Xx+R2lezu0LKIEcYe6pftr56PqPygbB7+szVnzoShbx64MUupqoE0KyRlN7gezbl8g==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.25.11': + resolution: {integrity: sha512-VekY0PBCukppoQrycFxUqkCojnTQhdec0vevUL/EDOCnXd9LKWqD/bHwMPzigIJXPhC59Vd1WFIL57SKs2mg4w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.11': + resolution: {integrity: sha512-+hfp3yfBalNEpTGp9loYgbknjR695HkqtY3d3/JjSRUyPg/xd6q+mQqIb5qdywnDxRZykIHs3axEqU6l1+oWEQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.25.11': + resolution: {integrity: sha512-CmKjrnayyTJF2eVuO//uSjl/K3KsMIeYeyN7FyDBjsR3lnSJHaXlVoAK8DZa7lXWChbuOk7NjAc7ygAwrnPBhA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.11': + resolution: {integrity: sha512-Dyq+5oscTJvMaYPvW3x3FLpi2+gSZTCE/1ffdwuM6G1ARang/mb3jvjxs0mw6n3Lsw84ocfo9CrNMqc5lTfGOw==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.25.11': + resolution: {integrity: sha512-Qr8AzcplUhGvdyUF08A1kHU3Vr2O88xxP0Tm8GcdVOUm25XYcMPp2YqSVHbLuXzYQMf9Bh/iKx7YPqECs6ffLA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.25.11': + resolution: {integrity: sha512-TBMv6B4kCfrGJ8cUPo7vd6NECZH/8hPpBHHlYI3qzoYFvWu2AdTvZNuU/7hsbKWqu/COU7NIK12dHAAqBLLXgw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.25.11': + resolution: {integrity: sha512-TmnJg8BMGPehs5JKrCLqyWTVAvielc615jbkOirATQvWWB1NMXY77oLMzsUjRLa0+ngecEmDGqt5jiDC6bfvOw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.25.11': + resolution: {integrity: sha512-DIGXL2+gvDaXlaq8xruNXUJdT5tF+SBbJQKbWy/0J7OhU8gOHOzKmGIlfTTl6nHaCOoipxQbuJi7O++ldrxgMw==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.25.11': + resolution: {integrity: sha512-Osx1nALUJu4pU43o9OyjSCXokFkFbyzjXb6VhGIJZQ5JZi8ylCQ9/LFagolPsHtgw6himDSyb5ETSfmp4rpiKQ==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.25.11': + resolution: {integrity: sha512-nbLFgsQQEsBa8XSgSTSlrnBSrpoWh7ioFDUmwo158gIm5NNP+17IYmNWzaIzWmgCxq56vfr34xGkOcZ7jX6CPw==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.11': + resolution: {integrity: sha512-HfyAmqZi9uBAbgKYP1yGuI7tSREXwIb438q0nqvlpxAOs3XnZ8RsisRfmVsgV486NdjD7Mw2UrFSw51lzUk1ww==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.25.11': + resolution: {integrity: sha512-HjLqVgSSYnVXRisyfmzsH6mXqyvj0SA7pG5g+9W7ESgwA70AXYNpfKBqh1KbTxmQVaYxpzA/SvlB9oclGPbApw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.25.11': + resolution: {integrity: sha512-HSFAT4+WYjIhrHxKBwGmOOSpphjYkcswF449j6EjsjbinTZbp8PJtjsVK1XFJStdzXdy/jaddAep2FGY+wyFAQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.25.11': + resolution: {integrity: sha512-hr9Oxj1Fa4r04dNpWr3P8QKVVsjQhqrMSUzZzf+LZcYjZNqhA3IAfPQdEh1FLVUJSiu6sgAwp3OmwBfbFgG2Xg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.11': + resolution: {integrity: sha512-u7tKA+qbzBydyj0vgpu+5h5AeudxOAGncb8N6C9Kh1N4n7wU1Xw1JDApsRjpShRpXRQlJLb9wY28ELpwdPcZ7A==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.25.11': + resolution: {integrity: sha512-Qq6YHhayieor3DxFOoYM1q0q1uMFYb7cSpLD2qzDSvK1NAvqFi8Xgivv0cFC6J+hWVw2teCYltyy9/m/14ryHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.11': + resolution: {integrity: sha512-CN+7c++kkbrckTOz5hrehxWN7uIhFFlmS/hqziSFVWpAzpWrQoAG4chH+nN3Be+Kzv/uuo7zhX716x3Sn2Jduw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.25.11': + resolution: {integrity: sha512-rOREuNIQgaiR+9QuNkbkxubbp8MSO9rONmwP5nKncnWJ9v5jQ4JxFnLu4zDSRPf3x4u+2VN4pM4RdyIzDty/wQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.25.11': + resolution: {integrity: sha512-nq2xdYaWxyg9DcIyXkZhcYulC6pQ2FuCgem3LI92IwMgIZ69KHeY8T4Y88pcwoLIjbed8n36CyKoYRDygNSGhA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.25.11': + resolution: {integrity: sha512-3XxECOWJq1qMZ3MN8srCJ/QfoLpL+VaxD/WfNRm1O3B4+AZ/BnLVgFbUV3eiRYDMXetciH16dwPbbHqwe1uU0Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.25.11': + resolution: {integrity: sha512-3ukss6gb9XZ8TlRyJlgLn17ecsK4NSQTmdIXRASVsiS2sQ6zPPZklNJT5GR5tE/MUarymmy8kCEf5xPCNCqVOA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.25.11': + resolution: {integrity: sha512-D7Hpz6A2L4hzsRpPaCYkQnGOotdUpDzSGRIv9I+1ITdHROSFUWW95ZPZWQmGka1Fg7W3zFJowyn9WGwMJ0+KPA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@eslint-community/eslint-utils@4.9.0': + resolution: {integrity: sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.1': + resolution: {integrity: sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/config-array@0.21.0': + resolution: {integrity: sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/config-helpers@0.4.0': + resolution: {integrity: sha512-WUFvV4WoIwW8Bv0KeKCIIEgdSiFOsulyN0xrMu+7z43q/hkOLXjvb5u7UC9jDxvRzcrbEmuZBX5yJZz1741jog==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/core@0.16.0': + resolution: {integrity: sha512-nmC8/totwobIiFcGkDza3GIKfAw1+hLiYVrh3I1nIomQ8PEr5cxg34jnkmGawul/ep52wGRAcyeDCNtWKSOj4Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/eslintrc@3.3.1': + resolution: {integrity: sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/js@9.37.0': + resolution: {integrity: sha512-jaS+NJ+hximswBG6pjNX0uEJZkrT0zwpVi3BA3vX22aFGjJjmgSTSmPpZCRKmoBL5VY/M6p0xsSJx7rk7sy5gg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/object-schema@2.1.6': + resolution: {integrity: sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/plugin-kit@0.4.0': + resolution: {integrity: sha512-sB5uyeq+dwCWyPi31B2gQlVlo+j5brPlWx4yZBrEaRo/nhdDE8Xke1gsGgtiBdaBTxuTkceLVuVt/pclrasb0A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@humanfs/core@0.19.1': + resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.7': + resolution: {integrity: sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + + '@isaacs/cliui@8.0.2': + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + + '@istanbuljs/schema@0.1.3': + resolution: {integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==} + engines: {node: '>=8'} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + + '@pkgr/core@0.1.2': + resolution: {integrity: sha512-fdDH1LSGfZdTH2sxdpVMw31BanV28K/Gry0cVFxaNP77neJSkd82mM8ErPNYs9e+0O7SdHBLTDzDgwUuy18RnQ==} + engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + + '@rollup/rollup-android-arm-eabi@4.52.4': + resolution: {integrity: sha512-BTm2qKNnWIQ5auf4deoetINJm2JzvihvGb9R6K/ETwKLql/Bb3Eg2H1FBp1gUb4YGbydMA3jcmQTR73q7J+GAA==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.52.4': + resolution: {integrity: sha512-P9LDQiC5vpgGFgz7GSM6dKPCiqR3XYN1WwJKA4/BUVDjHpYsf3iBEmVz62uyq20NGYbiGPR5cNHI7T1HqxNs2w==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.52.4': + resolution: {integrity: sha512-QRWSW+bVccAvZF6cbNZBJwAehmvG9NwfWHwMy4GbWi/BQIA/laTIktebT2ipVjNncqE6GLPxOok5hsECgAxGZg==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.52.4': + resolution: {integrity: sha512-hZgP05pResAkRJxL1b+7yxCnXPGsXU0fG9Yfd6dUaoGk+FhdPKCJ5L1Sumyxn8kvw8Qi5PvQ8ulenUbRjzeCTw==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.52.4': + resolution: {integrity: sha512-xmc30VshuBNUd58Xk4TKAEcRZHaXlV+tCxIXELiE9sQuK3kG8ZFgSPi57UBJt8/ogfhAF5Oz4ZSUBN77weM+mQ==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.52.4': + resolution: {integrity: sha512-WdSLpZFjOEqNZGmHflxyifolwAiZmDQzuOzIq9L27ButpCVpD7KzTRtEG1I0wMPFyiyUdOO+4t8GvrnBLQSwpw==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.52.4': + resolution: {integrity: sha512-xRiOu9Of1FZ4SxVbB0iEDXc4ddIcjCv2aj03dmW8UrZIW7aIQ9jVJdLBIhxBI+MaTnGAKyvMwPwQnoOEvP7FgQ==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.52.4': + resolution: {integrity: sha512-FbhM2p9TJAmEIEhIgzR4soUcsW49e9veAQCziwbR+XWB2zqJ12b4i/+hel9yLiD8pLncDH4fKIPIbt5238341Q==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.52.4': + resolution: {integrity: sha512-4n4gVwhPHR9q/g8lKCyz0yuaD0MvDf7dV4f9tHt0C73Mp8h38UCtSCSE6R9iBlTbXlmA8CjpsZoujhszefqueg==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.52.4': + resolution: {integrity: sha512-u0n17nGA0nvi/11gcZKsjkLj1QIpAuPFQbR48Subo7SmZJnGxDpspyw2kbpuoQnyK+9pwf3pAoEXerJs/8Mi9g==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.52.4': + resolution: {integrity: sha512-0G2c2lpYtbTuXo8KEJkDkClE/+/2AFPdPAbmaHoE870foRFs4pBrDehilMcrSScrN/fB/1HTaWO4bqw+ewBzMQ==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-gnu@4.52.4': + resolution: {integrity: sha512-teSACug1GyZHmPDv14VNbvZFX779UqWTsd7KtTM9JIZRDI5NUwYSIS30kzI8m06gOPB//jtpqlhmraQ68b5X2g==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-gnu@4.52.4': + resolution: {integrity: sha512-/MOEW3aHjjs1p4Pw1Xk4+3egRevx8Ji9N6HUIA1Ifh8Q+cg9dremvFCUbOX2Zebz80BwJIgCBUemjqhU5XI5Eg==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.52.4': + resolution: {integrity: sha512-1HHmsRyh845QDpEWzOFtMCph5Ts+9+yllCrREuBR/vg2RogAQGGBRC8lDPrPOMnrdOJ+mt1WLMOC2Kao/UwcvA==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.52.4': + resolution: {integrity: sha512-seoeZp4L/6D1MUyjWkOMRU6/iLmCU2EjbMTyAG4oIOs1/I82Y5lTeaxW0KBfkUdHAWN7j25bpkt0rjnOgAcQcA==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.52.4': + resolution: {integrity: sha512-Wi6AXf0k0L7E2gteNsNHUs7UMwCIhsCTs6+tqQ5GPwVRWMaflqGec4Sd8n6+FNFDw9vGcReqk2KzBDhCa1DLYg==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.52.4': + resolution: {integrity: sha512-dtBZYjDmCQ9hW+WgEkaffvRRCKm767wWhxsFW3Lw86VXz/uJRuD438/XvbZT//B96Vs8oTA8Q4A0AfHbrxP9zw==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openharmony-arm64@4.52.4': + resolution: {integrity: sha512-1ox+GqgRWqaB1RnyZXL8PD6E5f7YyRUJYnCqKpNzxzP0TkaUh112NDrR9Tt+C8rJ4x5G9Mk8PQR3o7Ku2RKqKA==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.52.4': + resolution: {integrity: sha512-8GKr640PdFNXwzIE0IrkMWUNUomILLkfeHjXBi/nUvFlpZP+FA8BKGKpacjW6OUUHaNI6sUURxR2U2g78FOHWQ==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.52.4': + resolution: {integrity: sha512-AIy/jdJ7WtJ/F6EcfOb2GjR9UweO0n43jNObQMb6oGxkYTfLcnN7vYYpG+CN3lLxrQkzWnMOoNSHTW54pgbVxw==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.52.4': + resolution: {integrity: sha512-UF9KfsH9yEam0UjTwAgdK0anlQ7c8/pWPU2yVjyWcF1I1thABt6WXE47cI71pGiZ8wGvxohBoLnxM04L/wj8mQ==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.52.4': + resolution: {integrity: sha512-bf9PtUa0u8IXDVxzRToFQKsNCRz9qLYfR/MpECxl4mRoWYjAeFjgxj1XdZr2M/GNVpT05p+LgQOHopYDlUu6/w==} + cpu: [x64] + os: [win32] + + '@types/chai@5.2.2': + resolution: {integrity: sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@vitest/coverage-v8@3.2.4': + resolution: {integrity: sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ==} + peerDependencies: + '@vitest/browser': 3.2.4 + vitest: 3.2.4 + peerDependenciesMeta: + '@vitest/browser': + optional: true + + '@vitest/expect@3.2.4': + resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} + + '@vitest/mocker@3.2.4': + resolution: {integrity: sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@3.2.4': + resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==} + + '@vitest/runner@3.2.4': + resolution: {integrity: sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==} + + '@vitest/snapshot@3.2.4': + resolution: {integrity: sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==} + + '@vitest/spy@3.2.4': + resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==} + + '@vitest/utils@3.2.4': + resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.15.0: + resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} + engines: {node: '>=0.4.0'} + hasBin: true + + ajv@6.12.6: + resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + + are-docs-informative@0.0.2: + resolution: {integrity: sha512-ixiS0nLNNG5jNQzgZJNoUpBKdo9yTYZMGJ+QgT2jmjR7G7+QHRCc4v6LQ3NgE7EBJq+o0ams3waJwkrlBom8Ig==} + engines: {node: '>=14'} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + ast-v8-to-istanbul@0.3.6: + resolution: {integrity: sha512-9tx1z/7OF/a8EdYL3FKoBhxLf3h3D8fXvuSj0HknsVeli2HE40qbNZxyFhMtnydaRiamwFu9zhb+BsJ5tVPehQ==} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + brace-expansion@1.1.12: + resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} + + brace-expansion@2.0.2: + resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + check-error@2.1.1: + resolution: {integrity: sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==} + engines: {node: '>= 16'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + comment-parser@1.4.1: + resolution: {integrity: sha512-buhp5kePrmda3vhc5B9t7pUQXAb2Tnd0qgpkIhPhkHXxJpiPJ11H0ZEU0oBpJ2QztSbzG/ZxMj/CHsYJqRHmyg==} + engines: {node: '>= 12.0.0'} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + + esbuild@0.25.11: + resolution: {integrity: sha512-KohQwyzrKTQmhXDW1PjCv3Tyspn9n5GcY2RTDqeORIdIJY8yKIF7sTSopFmn/wpMPW4rdPXI0UE5LJLuq3bx0Q==} + engines: {node: '>=18'} + hasBin: true + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + eslint-config-prettier@10.1.8: + resolution: {integrity: sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==} + hasBin: true + peerDependencies: + eslint: '>=7.0.0' + + eslint-plugin-jsdoc@50.0.0: + resolution: {integrity: sha512-czyJ5F7/qY2LIhUD5Bl6q1CCZ8mjvfEA9HQN5nvIp/Pb8VLIlUNd+DMZdA2OKN74QQMS3pobC06hFqAOJyOv5Q==} + engines: {node: '>=18'} + peerDependencies: + eslint: ^7.0.0 || ^8.0.0 || ^9.0.0 + + eslint-scope@8.4.0: + resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@4.2.1: + resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint@9.37.0: + resolution: {integrity: sha512-XyLmROnACWqSxiGYArdef1fItQd47weqB7iwtfr9JHwRrqIXZdcFMvvEcL9xHCmL0SNsOvF0c42lWyM1U5dgig==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@10.4.0: + resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + esquery@1.6.0: + resolution: {integrity: sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + expect-type@1.2.2: + resolution: {integrity: sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==} + engines: {node: '>=12.0.0'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + + flatted@3.3.3: + resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} + + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + glob@10.4.5: + resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==} + hasBin: true + + globals@14.0.0: + resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} + engines: {node: '>=18'} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + + husky@9.1.7: + resolution: {integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==} + engines: {node: '>=18'} + hasBin: true + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + 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'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-lib-source-maps@5.0.6: + resolution: {integrity: sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + + jackspeak@3.4.3: + resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + + js-yaml@4.1.0: + resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} + hasBin: true + + jsdoc-type-pratt-parser@4.0.0: + resolution: {integrity: sha512-YtOli5Cmzy3q4dP26GraSOeAhqecewG04hoO8DY56CH4KJ9Fvv5qKWUCCo3HZob7esJQHCv6/+bnTy72xZZaVQ==} + engines: {node: '>=12.0.0'} + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + + magic-string@0.30.19: + resolution: {integrity: sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==} + + magicast@0.3.5: + resolution: {integrity: sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==} + + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + + minimatch@3.1.2: + resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} + + minimatch@9.0.5: + resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} + engines: {node: '>=16 || 14 >=14.17'} + + minipass@7.1.2: + resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} + engines: {node: '>=16 || 14 >=14.17'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + parse-imports@2.2.1: + resolution: {integrity: sha512-OL/zLggRp8mFhKL0rNORUTR4yBYujK/uU+xZL+/0Rgm2QE4nLO9v8PzEweSJEbMGKmDRjJE4R3IMJlL2di4JeQ==} + engines: {node: '>= 18'} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.3: + resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} + engines: {node: '>=12'} + + postcss@8.5.6: + resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} + engines: {node: ^10 || ^12 || >=14} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + prettier@3.6.2: + resolution: {integrity: sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==} + engines: {node: '>=14'} + hasBin: true + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + rollup@4.52.4: + resolution: {integrity: sha512-CLEVl+MnPAiKh5pl4dEWSyMTpuflgNQiLGhMv8ezD5W/qP8AKvmYpCOKRRNOh7oRKnauBZ4SyeYkMS+1VSyKwQ==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + semver@7.7.3: + resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==} + engines: {node: '>=10'} + hasBin: true + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + slashes@3.0.12: + resolution: {integrity: sha512-Q9VME8WyGkc7pJf6QEkj3wE+2CnvZMI+XJhwdTPR8Z/kWQRXi7boAWLDibRPyHRTUTPx5FaU7MsyrjI3yLB4HA==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + spdx-exceptions@2.5.0: + resolution: {integrity: sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==} + + spdx-expression-parse@4.0.0: + resolution: {integrity: sha512-Clya5JIij/7C6bRR22+tnGXbc4VKlibKSVj2iHvVeX5iMW7s1SIQlqu699JkODJJIhh/pUu8L0/VLh8xflD+LQ==} + + spdx-license-ids@3.0.22: + resolution: {integrity: sha512-4PRT4nh1EImPbt2jASOKHX7PB7I+e4IWNLvkKFDxNhJlfjbYlleYQh285Z/3mPTHSAK/AvdMmw5BNNuYH8ShgQ==} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-ansi@7.1.2: + resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==} + engines: {node: '>=12'} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + + strip-literal@3.1.0: + resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + synckit@0.9.3: + resolution: {integrity: sha512-JJoOEKTfL1urb1mDoEblhD9NhEbWmq9jHEMEnxoC4ujUaZ4itA8vKgwkFAyNClgxplLi9tsUKX+EduK0p/l7sg==} + engines: {node: ^14.18.0 || >=16.0.0} + + test-exclude@7.0.1: + resolution: {integrity: sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==} + engines: {node: '>=18'} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + + tinyglobby@0.2.15: + resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + engines: {node: '>=12.0.0'} + + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@2.0.0: + resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} + engines: {node: '>=14.0.0'} + + tinyspy@4.0.4: + resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} + engines: {node: '>=14.0.0'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + vite-node@3.2.4: + resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + + vite@7.1.10: + resolution: {integrity: sha512-CmuvUBzVJ/e3HGxhg6cYk88NGgTnBoOo7ogtfJJ0fefUWAxN/WDSUa50o+oVBxuIhO8FoEZW0j2eW7sfjs5EtA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@3.2.4: + resolution: {integrity: sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/debug': ^4.1.12 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + '@vitest/browser': 3.2.4 + '@vitest/ui': 3.2.4 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/debug': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + +snapshots: + + '@ampproject/remapping@2.3.0': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@babel/helper-string-parser@7.27.1': {} + + '@babel/helper-validator-identifier@7.27.1': {} + + '@babel/parser@7.28.4': + dependencies: + '@babel/types': 7.28.4 + + '@babel/types@7.28.4': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.27.1 + + '@bcoe/v8-coverage@1.0.2': {} + + '@es-joy/jsdoccomment@0.46.0': + dependencies: + comment-parser: 1.4.1 + esquery: 1.6.0 + jsdoc-type-pratt-parser: 4.0.0 + + '@esbuild/aix-ppc64@0.25.11': + optional: true + + '@esbuild/android-arm64@0.25.11': + optional: true + + '@esbuild/android-arm@0.25.11': + optional: true + + '@esbuild/android-x64@0.25.11': + optional: true + + '@esbuild/darwin-arm64@0.25.11': + optional: true + + '@esbuild/darwin-x64@0.25.11': + optional: true + + '@esbuild/freebsd-arm64@0.25.11': + optional: true + + '@esbuild/freebsd-x64@0.25.11': + optional: true + + '@esbuild/linux-arm64@0.25.11': + optional: true + + '@esbuild/linux-arm@0.25.11': + optional: true + + '@esbuild/linux-ia32@0.25.11': + optional: true + + '@esbuild/linux-loong64@0.25.11': + optional: true + + '@esbuild/linux-mips64el@0.25.11': + optional: true + + '@esbuild/linux-ppc64@0.25.11': + optional: true + + '@esbuild/linux-riscv64@0.25.11': + optional: true + + '@esbuild/linux-s390x@0.25.11': + optional: true + + '@esbuild/linux-x64@0.25.11': + optional: true + + '@esbuild/netbsd-arm64@0.25.11': + optional: true + + '@esbuild/netbsd-x64@0.25.11': + optional: true + + '@esbuild/openbsd-arm64@0.25.11': + optional: true + + '@esbuild/openbsd-x64@0.25.11': + optional: true + + '@esbuild/openharmony-arm64@0.25.11': + optional: true + + '@esbuild/sunos-x64@0.25.11': + optional: true + + '@esbuild/win32-arm64@0.25.11': + optional: true + + '@esbuild/win32-ia32@0.25.11': + optional: true + + '@esbuild/win32-x64@0.25.11': + optional: true + + '@eslint-community/eslint-utils@4.9.0(eslint@9.37.0)': + dependencies: + eslint: 9.37.0 + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.1': {} + + '@eslint/config-array@0.21.0': + dependencies: + '@eslint/object-schema': 2.1.6 + debug: 4.4.3 + minimatch: 3.1.2 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.4.0': + dependencies: + '@eslint/core': 0.16.0 + + '@eslint/core@0.16.0': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/eslintrc@3.3.1': + dependencies: + ajv: 6.12.6 + debug: 4.4.3 + espree: 10.4.0 + globals: 14.0.0 + ignore: 5.3.2 + import-fresh: 3.3.1 + js-yaml: 4.1.0 + minimatch: 3.1.2 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@eslint/js@9.37.0': {} + + '@eslint/object-schema@2.1.6': {} + + '@eslint/plugin-kit@0.4.0': + dependencies: + '@eslint/core': 0.16.0 + levn: 0.4.1 + + '@humanfs/core@0.19.1': {} + + '@humanfs/node@0.16.7': + dependencies: + '@humanfs/core': 0.19.1 + '@humanwhocodes/retry': 0.4.3 + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + + '@isaacs/cliui@8.0.2': + dependencies: + string-width: 5.1.2 + string-width-cjs: string-width@4.2.3 + strip-ansi: 7.1.2 + strip-ansi-cjs: strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: wrap-ansi@7.0.0 + + '@istanbuljs/schema@0.1.3': {} + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@pkgjs/parseargs@0.11.0': + optional: true + + '@pkgr/core@0.1.2': {} + + '@rollup/rollup-android-arm-eabi@4.52.4': + optional: true + + '@rollup/rollup-android-arm64@4.52.4': + optional: true + + '@rollup/rollup-darwin-arm64@4.52.4': + optional: true + + '@rollup/rollup-darwin-x64@4.52.4': + optional: true + + '@rollup/rollup-freebsd-arm64@4.52.4': + optional: true + + '@rollup/rollup-freebsd-x64@4.52.4': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.52.4': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.52.4': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.52.4': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.52.4': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.52.4': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.52.4': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.52.4': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.52.4': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.52.4': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.52.4': + optional: true + + '@rollup/rollup-linux-x64-musl@4.52.4': + optional: true + + '@rollup/rollup-openharmony-arm64@4.52.4': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.52.4': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.52.4': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.52.4': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.52.4': + optional: true + + '@types/chai@5.2.2': + dependencies: + '@types/deep-eql': 4.0.2 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.8': {} + + '@types/json-schema@7.0.15': {} + + '@vitest/coverage-v8@3.2.4(vitest@3.2.4)': + dependencies: + '@ampproject/remapping': 2.3.0 + '@bcoe/v8-coverage': 1.0.2 + ast-v8-to-istanbul: 0.3.6 + debug: 4.4.3 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-lib-source-maps: 5.0.6 + istanbul-reports: 3.2.0 + magic-string: 0.30.19 + magicast: 0.3.5 + std-env: 3.10.0 + test-exclude: 7.0.1 + tinyrainbow: 2.0.0 + vitest: 3.2.4 + transitivePeerDependencies: + - supports-color + + '@vitest/expect@3.2.4': + dependencies: + '@types/chai': 5.2.2 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.3.3 + tinyrainbow: 2.0.0 + + '@vitest/mocker@3.2.4(vite@7.1.10)': + dependencies: + '@vitest/spy': 3.2.4 + estree-walker: 3.0.3 + magic-string: 0.30.19 + optionalDependencies: + vite: 7.1.10 + + '@vitest/pretty-format@3.2.4': + dependencies: + tinyrainbow: 2.0.0 + + '@vitest/runner@3.2.4': + dependencies: + '@vitest/utils': 3.2.4 + pathe: 2.0.3 + strip-literal: 3.1.0 + + '@vitest/snapshot@3.2.4': + dependencies: + '@vitest/pretty-format': 3.2.4 + magic-string: 0.30.19 + pathe: 2.0.3 + + '@vitest/spy@3.2.4': + dependencies: + tinyspy: 4.0.4 + + '@vitest/utils@3.2.4': + dependencies: + '@vitest/pretty-format': 3.2.4 + loupe: 3.2.1 + tinyrainbow: 2.0.0 + + acorn-jsx@5.3.2(acorn@8.15.0): + dependencies: + acorn: 8.15.0 + + acorn@8.15.0: {} + + ajv@6.12.6: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + ansi-regex@5.0.1: {} + + ansi-regex@6.2.2: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@6.2.3: {} + + are-docs-informative@0.0.2: {} + + argparse@2.0.1: {} + + assertion-error@2.0.1: {} + + ast-v8-to-istanbul@0.3.6: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 9.0.1 + + balanced-match@1.0.2: {} + + brace-expansion@1.1.12: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@2.0.2: + dependencies: + balanced-match: 1.0.2 + + cac@6.7.14: {} + + callsites@3.1.0: {} + + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.1 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + check-error@2.1.1: {} + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + comment-parser@1.4.1: {} + + concat-map@0.0.1: {} + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deep-eql@5.0.2: {} + + deep-is@0.1.4: {} + + eastasianwidth@0.2.0: {} + + emoji-regex@8.0.0: {} + + emoji-regex@9.2.2: {} + + es-module-lexer@1.7.0: {} + + esbuild@0.25.11: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.11 + '@esbuild/android-arm': 0.25.11 + '@esbuild/android-arm64': 0.25.11 + '@esbuild/android-x64': 0.25.11 + '@esbuild/darwin-arm64': 0.25.11 + '@esbuild/darwin-x64': 0.25.11 + '@esbuild/freebsd-arm64': 0.25.11 + '@esbuild/freebsd-x64': 0.25.11 + '@esbuild/linux-arm': 0.25.11 + '@esbuild/linux-arm64': 0.25.11 + '@esbuild/linux-ia32': 0.25.11 + '@esbuild/linux-loong64': 0.25.11 + '@esbuild/linux-mips64el': 0.25.11 + '@esbuild/linux-ppc64': 0.25.11 + '@esbuild/linux-riscv64': 0.25.11 + '@esbuild/linux-s390x': 0.25.11 + '@esbuild/linux-x64': 0.25.11 + '@esbuild/netbsd-arm64': 0.25.11 + '@esbuild/netbsd-x64': 0.25.11 + '@esbuild/openbsd-arm64': 0.25.11 + '@esbuild/openbsd-x64': 0.25.11 + '@esbuild/openharmony-arm64': 0.25.11 + '@esbuild/sunos-x64': 0.25.11 + '@esbuild/win32-arm64': 0.25.11 + '@esbuild/win32-ia32': 0.25.11 + '@esbuild/win32-x64': 0.25.11 + + escape-string-regexp@4.0.0: {} + + eslint-config-prettier@10.1.8(eslint@9.37.0): + dependencies: + eslint: 9.37.0 + + eslint-plugin-jsdoc@50.0.0(eslint@9.37.0): + dependencies: + '@es-joy/jsdoccomment': 0.46.0 + are-docs-informative: 0.0.2 + comment-parser: 1.4.1 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint: 9.37.0 + espree: 10.4.0 + esquery: 1.6.0 + parse-imports: 2.2.1 + semver: 7.7.3 + spdx-expression-parse: 4.0.0 + synckit: 0.9.3 + transitivePeerDependencies: + - supports-color + + eslint-scope@8.4.0: + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@4.2.1: {} + + eslint@9.37.0: + dependencies: + '@eslint-community/eslint-utils': 4.9.0(eslint@9.37.0) + '@eslint-community/regexpp': 4.12.1 + '@eslint/config-array': 0.21.0 + '@eslint/config-helpers': 0.4.0 + '@eslint/core': 0.16.0 + '@eslint/eslintrc': 3.3.1 + '@eslint/js': 9.37.0 + '@eslint/plugin-kit': 0.4.0 + '@humanfs/node': 0.16.7 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.8 + '@types/json-schema': 7.0.15 + ajv: 6.12.6 + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 8.4.0 + eslint-visitor-keys: 4.2.1 + espree: 10.4.0 + esquery: 1.6.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + lodash.merge: 4.6.2 + minimatch: 3.1.2 + natural-compare: 1.4.0 + optionator: 0.9.4 + transitivePeerDependencies: + - supports-color + + espree@10.4.0: + dependencies: + acorn: 8.15.0 + acorn-jsx: 5.3.2(acorn@8.15.0) + eslint-visitor-keys: 4.2.1 + + esquery@1.6.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + + esutils@2.0.3: {} + + expect-type@1.2.2: {} + + fast-deep-equal@3.1.3: {} + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fdir@6.5.0(picomatch@4.0.3): + optionalDependencies: + picomatch: 4.0.3 + + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@4.0.1: + dependencies: + flatted: 3.3.3 + keyv: 4.5.4 + + flatted@3.3.3: {} + + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + + fsevents@2.3.3: + optional: true + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + glob@10.4.5: + dependencies: + foreground-child: 3.3.1 + jackspeak: 3.4.3 + minimatch: 9.0.5 + minipass: 7.1.2 + package-json-from-dist: 1.0.1 + path-scurry: 1.11.1 + + globals@14.0.0: {} + + has-flag@4.0.0: {} + + html-escaper@2.0.2: {} + + husky@9.1.7: {} + + ignore@5.3.2: {} + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + imurmurhash@0.1.4: {} + + is-extglob@2.1.1: {} + + is-fullwidth-code-point@3.0.0: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + isexe@2.0.0: {} + + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-lib-source-maps@5.0.6: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + debug: 4.4.3 + istanbul-lib-coverage: 3.2.2 + transitivePeerDependencies: + - supports-color + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + + jackspeak@3.4.3: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + + js-tokens@9.0.1: {} + + js-yaml@4.1.0: + dependencies: + argparse: 2.0.1 + + jsdoc-type-pratt-parser@4.0.0: {} + + json-buffer@3.0.1: {} + + json-schema-traverse@0.4.1: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lodash.merge@4.6.2: {} + + loupe@3.2.1: {} + + lru-cache@10.4.3: {} + + magic-string@0.30.19: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + magicast@0.3.5: + dependencies: + '@babel/parser': 7.28.4 + '@babel/types': 7.28.4 + source-map-js: 1.2.1 + + make-dir@4.0.0: + dependencies: + semver: 7.7.3 + + minimatch@3.1.2: + dependencies: + brace-expansion: 1.1.12 + + minimatch@9.0.5: + dependencies: + brace-expansion: 2.0.2 + + minipass@7.1.2: {} + + ms@2.1.3: {} + + nanoid@3.3.11: {} + + natural-compare@1.4.0: {} + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + package-json-from-dist@1.0.1: {} + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + parse-imports@2.2.1: + dependencies: + es-module-lexer: 1.7.0 + slashes: 3.0.12 + + path-exists@4.0.0: {} + + path-key@3.1.1: {} + + path-scurry@1.11.1: + dependencies: + lru-cache: 10.4.3 + minipass: 7.1.2 + + pathe@2.0.3: {} + + pathval@2.0.1: {} + + picocolors@1.1.1: {} + + picomatch@4.0.3: {} + + postcss@8.5.6: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prelude-ls@1.2.1: {} + + prettier@3.6.2: {} + + punycode@2.3.1: {} + + resolve-from@4.0.0: {} + + rollup@4.52.4: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.52.4 + '@rollup/rollup-android-arm64': 4.52.4 + '@rollup/rollup-darwin-arm64': 4.52.4 + '@rollup/rollup-darwin-x64': 4.52.4 + '@rollup/rollup-freebsd-arm64': 4.52.4 + '@rollup/rollup-freebsd-x64': 4.52.4 + '@rollup/rollup-linux-arm-gnueabihf': 4.52.4 + '@rollup/rollup-linux-arm-musleabihf': 4.52.4 + '@rollup/rollup-linux-arm64-gnu': 4.52.4 + '@rollup/rollup-linux-arm64-musl': 4.52.4 + '@rollup/rollup-linux-loong64-gnu': 4.52.4 + '@rollup/rollup-linux-ppc64-gnu': 4.52.4 + '@rollup/rollup-linux-riscv64-gnu': 4.52.4 + '@rollup/rollup-linux-riscv64-musl': 4.52.4 + '@rollup/rollup-linux-s390x-gnu': 4.52.4 + '@rollup/rollup-linux-x64-gnu': 4.52.4 + '@rollup/rollup-linux-x64-musl': 4.52.4 + '@rollup/rollup-openharmony-arm64': 4.52.4 + '@rollup/rollup-win32-arm64-msvc': 4.52.4 + '@rollup/rollup-win32-ia32-msvc': 4.52.4 + '@rollup/rollup-win32-x64-gnu': 4.52.4 + '@rollup/rollup-win32-x64-msvc': 4.52.4 + fsevents: 2.3.3 + + semver@7.7.3: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + siginfo@2.0.0: {} + + signal-exit@4.1.0: {} + + slashes@3.0.12: {} + + source-map-js@1.2.1: {} + + spdx-exceptions@2.5.0: {} + + spdx-expression-parse@4.0.0: + dependencies: + spdx-exceptions: 2.5.0 + spdx-license-ids: 3.0.22 + + spdx-license-ids@3.0.22: {} + + stackback@0.0.2: {} + + std-env@3.10.0: {} + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.1.2 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-ansi@7.1.2: + dependencies: + ansi-regex: 6.2.2 + + strip-json-comments@3.1.1: {} + + strip-literal@3.1.0: + dependencies: + js-tokens: 9.0.1 + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + synckit@0.9.3: + dependencies: + '@pkgr/core': 0.1.2 + tslib: 2.8.1 + + test-exclude@7.0.1: + dependencies: + '@istanbuljs/schema': 0.1.3 + glob: 10.4.5 + minimatch: 9.0.5 + + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + + tinyglobby@0.2.15: + dependencies: + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + + tinypool@1.1.1: {} + + tinyrainbow@2.0.0: {} + + tinyspy@4.0.4: {} + + tslib@2.8.1: {} + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + vite-node@3.2.4: + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 7.1.10 + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + vite@7.1.10: + dependencies: + esbuild: 0.25.11 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.6 + rollup: 4.52.4 + tinyglobby: 0.2.15 + optionalDependencies: + fsevents: 2.3.3 + + vitest@3.2.4: + dependencies: + '@types/chai': 5.2.2 + '@vitest/expect': 3.2.4 + '@vitest/mocker': 3.2.4(vite@7.1.10) + '@vitest/pretty-format': 3.2.4 + '@vitest/runner': 3.2.4 + '@vitest/snapshot': 3.2.4 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.2.2 + magic-string: 0.30.19 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.15 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 7.1.10 + vite-node: 3.2.4 + why-is-node-running: 2.3.0 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + word-wrap@1.2.5: {} + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.3 + string-width: 5.1.2 + strip-ansi: 7.1.2 + + yocto-queue@0.1.0: {} diff --git a/src/config.js b/src/config.js new file mode 100644 index 0000000..9f5359d --- /dev/null +++ b/src/config.js @@ -0,0 +1,345 @@ +/** + * 配置解析模块 + * 处理 Action 输入参数和环境变量的解析与验证 + */ + +/** + * 支持的消息类型枚举 + * + * @readonly + * @enum {string} + */ +export const MESSAGE_TYPES = { + TEXT: 'text', + MARKDOWN: 'markdown', + LINK: 'link', + ACTION_CARD: 'actionCard', + FEED_CARD: 'feedCard', +}; + +/** + * 配置对象类型定义 + * + * @typedef {object} ActionConfig + * @property {string} webhookUrl - 钉钉 Webhook URL + * @property {string} messageType - 消息类型 + * @property {string} content - 消息内容 + * @property {string} [title] - 消息标题 + * @property {string[]} [atMobiles] - @的手机号列表 + * @property {boolean} [atAll] - 是否@所有人 + * @property {string} [linkUrl] - 链接地址 + * @property {string} [picUrl] - 图片地址 + * @property {string} [btnOrientation] - actionCard 按钮排列方向('0'竖直,'1'水平) + * @property {string} [singleTitle] - actionCard 单按钮标题(整体跳转) + * @property {string} [singleURL] - actionCard 单按钮跳转链接(整体跳转) + * @property {{ title: string, actionURL: string }[]} [buttons] - actionCard 多按钮配置(独立跳转) + * @property {{ title: string, messageURL: string, picURL: string }[]} [feedLinks] - feedCard 链接列表 + */ + +/** + * 从环境变量中获取 Action 输入参数 + * + * @returns {object} 原始输入参数对象 + */ +function getActionInputs() { + return { + webhook_url: process.env.INPUT_WEBHOOK_URL || '', + message_type: process.env.INPUT_MESSAGE_TYPE || MESSAGE_TYPES.TEXT, + content: process.env.INPUT_CONTENT || '', + title: process.env.INPUT_TITLE || '', + at_mobiles: process.env.INPUT_AT_MOBILES || '', + at_all: process.env.INPUT_AT_ALL || 'false', + link_url: process.env.INPUT_LINK_URL || '', + pic_url: process.env.INPUT_PIC_URL || '', + // actionCard 相关 + btn_orientation: process.env.INPUT_BTN_ORIENTATION || '0', + single_title: process.env.INPUT_SINGLE_TITLE || '', + single_url: process.env.INPUT_SINGLE_URL || '', + buttons: process.env.INPUT_BUTTONS || '', + // feedCard 相关 + feed_links: process.env.INPUT_FEED_LINKS || '', + }; +} + +/** + * 验证钉钉 Webhook URL 格式 + * + * @param {string} webhookUrl - 钉钉 Webhook URL + * @returns {boolean} 验证结果 + */ +export function validateWebhookUrl(webhookUrl) { + if (!webhookUrl || typeof webhookUrl !== 'string') { + return false; + } + + // 钉钉 Webhook URL 格式验证 + const dingtalkUrlPattern = /^https:\/\/oapi\.dingtalk\.com\/robot\/send\?access_token=[\w-]+$/; + return dingtalkUrlPattern.test(webhookUrl); +} + +/** + * 验证消息类型 + * + * @param {string} messageType - 消息类型 + * @returns {boolean} 验证结果 + */ +export function validateMessageType(messageType) { + return Object.values(MESSAGE_TYPES).includes(messageType); +} + +/** + * 解析手机号列表 + * + * @param {string} atMobilesStr - 逗号分隔的手机号字符串 + * @returns {string[]} 手机号数组 + */ +function parseAtMobiles(atMobilesStr) { + if (!atMobilesStr || typeof atMobilesStr !== 'string') { + return []; + } + + return atMobilesStr + .split(',') + .map(mobile => mobile.trim()) + .filter(mobile => mobile.length > 0) + .filter(mobile => /^1[3-9]\d{9}$/.test(mobile)); // 简单的手机号验证 +} + +/** + * 解析 actionCard 多按钮配置(JSON 数组) + * + * @param {string} buttonsStr - JSON 字符串,形如 [{"title":"阅读全文","actionURL":"https://..."}] + * @returns {{ title: string, actionURL: string }[]} 按钮数组 + */ +function parseActionCardButtons(buttonsStr) { + if (!buttonsStr || typeof buttonsStr !== 'string') { + return []; + } + + try { + const parsed = JSON.parse(buttonsStr); + if (!Array.isArray(parsed)) return []; + return parsed + .filter(btn => btn && typeof btn === 'object') + .map(btn => ({ title: String(btn.title || ''), actionURL: String(btn.actionURL || '') })) + .filter(btn => btn.title.length > 0 && /^https?:\/\/.+/.test(btn.actionURL)); + } catch { + return []; + } +} + +/** + * 解析 feedCard 链接配置(JSON 数组) + * + * @param {string} linksStr - JSON 字符串,形如 [{"title":"...","messageURL":"https://...","picURL":"https://..."}] + * @returns {{ title: string, messageURL: string, picURL: string }[]} 链接数组 + */ +function parseFeedLinks(linksStr) { + if (!linksStr || typeof linksStr !== 'string') { + return []; + } + + try { + const parsed = JSON.parse(linksStr); + if (!Array.isArray(parsed)) return []; + return parsed + .filter(link => link && typeof link === 'object') + .map(link => ({ + title: String(link.title || ''), + messageURL: String(link.messageURL || ''), + picURL: String(link.picURL || ''), + })) + .filter( + link => + link.title.length > 0 && + /^https?:\/\/.+/.test(link.messageURL) && + /^https?:\/\/.+/.test(link.picURL), + ); + } catch { + return []; + } +} + +/** + * 验证配置参数的完整性 + * + * @param {ActionConfig} config - 配置对象 + * @returns {object} 验证结果 + */ +function validateConfig(config) { + const errors = []; + + // 验证必需参数 + if (!config.webhookUrl) { + errors.push('webhook_url is required'); + } else if (!validateWebhookUrl(config.webhookUrl)) { + errors.push('webhook_url format is invalid'); + } + + // FeedCard 消息类型不需要 content 字段 + if (!config.content && config.messageType !== MESSAGE_TYPES.FEED_CARD) { + errors.push('content is required'); + } + + if (!validateMessageType(config.messageType)) { + errors.push(`message_type must be one of: ${Object.values(MESSAGE_TYPES).join(', ')}`); + } + + // 验证特定消息类型的参数 + if (config.messageType === MESSAGE_TYPES.MARKDOWN && !config.title) { + errors.push('title is required for markdown message type'); + } + + if (config.messageType === MESSAGE_TYPES.LINK) { + if (!config.title) { + errors.push('title is required for link message type'); + } + if (!config.linkUrl) { + errors.push('link_url is required for link message type'); + } else if (!/^https?:\/\/.+/.test(config.linkUrl)) { + errors.push('link_url must be a valid HTTP/HTTPS URL'); + } + } + + // actionCard 校验 + if (config.messageType === MESSAGE_TYPES.ACTION_CARD) { + if (!config.title) { + errors.push('title is required for actionCard message type'); + } + if (!config.content) { + errors.push('content is required for actionCard message type'); + } + + // btnOrientation 必须为 '0' 或 '1' + if (config.btnOrientation !== '0' && config.btnOrientation !== '1') { + errors.push("btn_orientation must be '0' or '1'"); + } + + const hasSingle = !!config.singleTitle || !!config.singleURL; + const hasButtons = Array.isArray(config.buttons) && config.buttons.length > 0; + + if (!hasSingle && !hasButtons) { + errors.push('actionCard requires either single_title/single_url or buttons'); + } + + if (hasSingle) { + if (!config.singleTitle) { + errors.push('single_title is required when using single button'); + } + if (!config.singleURL) { + errors.push('single_url is required when using single button'); + } else if (!/^https?:\/\/.+/.test(config.singleURL)) { + errors.push('single_url must be a valid HTTP/HTTPS URL'); + } + } + + if (hasButtons) { + for (const btn of config.buttons) { + if (!btn.title || !btn.actionURL) { + errors.push('each button must have title and actionURL'); + break; + } + if (!/^https?:\/\/.+/.test(btn.actionURL)) { + errors.push('button.actionURL must be a valid HTTP/HTTPS URL'); + break; + } + } + } + } + + // feedCard 校验 + if (config.messageType === MESSAGE_TYPES.FEED_CARD) { + const links = config.feedLinks || []; + if (!Array.isArray(links) || links.length === 0) { + errors.push('feed_links is required and must be a non-empty array for feedCard'); + } else { + for (const link of links) { + if (!link.title || !link.messageURL || !link.picURL) { + errors.push('each feed link must have title, messageURL and picURL'); + break; + } + if (!/^https?:\/\/.+/.test(link.messageURL) || !/^https?:\/\/.+/.test(link.picURL)) { + errors.push('feed link URLs must be valid HTTP/HTTPS'); + break; + } + } + } + } + + return { + isValid: errors.length === 0, + errors, + }; +} + +/** + * 解析和验证 Action 输入参数 + * + * @returns {Promise} 解析后的配置对象 + * @throws {Error} 当配置验证失败时抛出错误 + */ +export async function parseConfig() { + const inputs = getActionInputs(); + + // 构建配置对象 + const config = { + webhookUrl: inputs.webhook_url, + messageType: inputs.message_type, + content: inputs.content, + title: inputs.title || undefined, + atMobiles: parseAtMobiles(inputs.at_mobiles), + atAll: inputs.at_all.toLowerCase() === 'true', + linkUrl: inputs.link_url || undefined, + picUrl: inputs.pic_url || undefined, + // actionCard + btnOrientation: (inputs.btn_orientation || '0').trim(), + singleTitle: inputs.single_title || undefined, + singleURL: inputs.single_url || undefined, + buttons: parseActionCardButtons(inputs.buttons), + // feedCard + feedLinks: parseFeedLinks(inputs.feed_links), + }; + + // 验证配置 + const validation = validateConfig(config); + if (!validation.isValid) { + const errorMessage = `Configuration validation failed:\n${validation.errors.join('\n')}`; + throw new Error(errorMessage); + } + + return config; +} + +/** + * 输出配置信息到控制台(用于调试) + * + * @param {ActionConfig} config - 配置对象 + */ +export function logConfig(config) { + console.log('=== Action Configuration ==='); + console.log(`Message Type: ${config.messageType}`); + console.log(`Content Length: ${config.content.length} characters`); + + if (config.title) { + console.log(`Title: ${config.title}`); + } + + if (config.atMobiles && config.atMobiles.length > 0) { + console.log(`At Mobiles: ${config.atMobiles.length} numbers`); + } + + if (config.atAll) { + console.log('At All: true'); + } + + if (config.linkUrl) { + console.log(`Link URL: ${config.linkUrl}`); + } + + if (config.picUrl) { + console.log(`Picture URL: ${config.picUrl}`); + } + + console.log('Webhook URL: [HIDDEN FOR SECURITY]'); + console.log('============================='); +} diff --git a/src/http.js b/src/http.js new file mode 100644 index 0000000..42ab98c --- /dev/null +++ b/src/http.js @@ -0,0 +1,227 @@ +/** + * HTTP 客户端模块 + * 使用 Node.js 内置 fetch API 发送 HTTP 请求到钉钉 API + */ + +/** + * HTTP 响应结果 + * + * @typedef {object} HttpResponse + * @property {boolean} success - 请求是否成功 + * @property {number} statusCode - HTTP 状态码 + * @property {object} data - 响应数据 + * @property {string} [error] - 错误信息 + * @property {number} duration - 请求耗时(毫秒) + */ + +/** + * 钉钉 API 响应格式 + * + * @typedef {object} DingTalkResponse + * @property {number} errcode - 错误码,0表示成功 + * @property {string} errmsg - 错误信息 + */ + +/** + * 发送单次 HTTP POST 请求 + * + * @param {string} url - 请求URL + * @param {object} data - 请求数据 + * @returns {Promise} HTTP响应结果 + */ +async function sendSingleRequest(url, data) { + const startTime = Date.now(); + + try { + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'User-Agent': 'DingTalk-Bot/1.0.0', + }, + body: JSON.stringify(data), + }); + + const duration = Date.now() - startTime; + const responseText = await response.text(); + + try { + // 尝试解析 JSON 响应 + const responseData = responseText ? JSON.parse(responseText) : {}; + + return { + success: response.status === 200 && responseData.errcode === 0, + statusCode: response.status, + data: responseData, + duration, + }; + } catch (parseError) { + // JSON 解析失败,返回空对象作为 data(保持与原始实现一致) + return { + success: false, + statusCode: response.status, + data: {}, + error: `JSON parse error: ${parseError.message}`, + duration, + }; + } + } catch (error) { + const duration = Date.now() - startTime; + + // 网络错误或其他错误 + return { + success: false, + statusCode: 0, + data: { errcode: -1, errmsg: 'Network error' }, + error: error.message, + duration, + }; + } +} + +/** + * 发送 POST 请求到钉钉 Webhook + * + * @param {string} url - 钉钉 Webhook URL + * @param {object} data - 消息数据 + * @returns {Promise} HTTP响应结果 + */ +export async function sendRequest(url, data) { + console.log('Sending request to DingTalk webhook...'); + console.log(`URL: ${url.replace(/access_token=[\w-]+/, 'access_token=***')}`); + + try { + const response = await sendSingleRequest(url, data); + + console.log( + `Request completed - Status: ${response.statusCode}, Duration: ${response.duration}ms`, + ); + + if (response.success) { + console.log('✅ Message sent successfully!'); + } else { + // 记录失败信息 + if (response.data && typeof response.data === 'object' && response.data.errmsg) { + console.log( + `❌ DingTalk API error: ${response.data.errmsg} (code: ${response.data.errcode})`, + ); + } + + if (response.error) { + console.log(`❌ Request error: ${response.error}`); + } + } + + return response; + } catch (error) { + console.log(`❌ Unexpected error: ${error.message}`); + + return { + success: false, + statusCode: 0, + data: { errcode: -1, errmsg: 'Unexpected error' }, + error: error.message, + duration: 0, + }; + } +} + +/** + * 验证钉钉 API 响应 + * + * @param {HttpResponse} response - HTTP响应结果 + * @returns {object} 验证结果 + */ +export function validateResponse(response) { + const errors = []; + + if (!response) { + errors.push('Response is null or undefined'); + return { isValid: false, errors }; + } + + if (typeof response.success !== 'boolean') { + errors.push('success must be a boolean'); + } + + if (typeof response.statusCode !== 'number') { + errors.push('statusCode must be a number'); + } + + if (!response.data) { + errors.push('Missing required field: data'); + } else if (typeof response.data === 'object') { + // 只有当 data 是对象时才检查 errcode 和 errmsg + if (!('errcode' in response.data)) { + errors.push('Missing required field: errcode'); + } else if (typeof response.data.errcode !== 'number') { + errors.push('errcode must be a number'); + } + + if (!('errmsg' in response.data)) { + errors.push('Missing required field: errmsg'); + } else if (typeof response.data.errmsg !== 'string') { + errors.push('errmsg must be a string'); + } + } + + return { + isValid: errors.length === 0, + errors, + }; +} + +/** + * 获取钉钉错误码的描述信息 + * + * @param {number} errcode - 钉钉错误码 + * @returns {string} 错误描述 + */ +export function getDingTalkErrorDescription(errcode) { + const errorMap = { + 0: '请求成功', + '-1': '系统繁忙,请稍后再试', + 310000: '无效的 webhook URL 或访问令牌', + 310001: '无效签名', + 310002: '无效时间戳', + 310003: '无效请求格式', + 310004: '消息内容过长', + 310005: '消息发送频率超限', + }; + + // 处理非数字输入 + if (typeof errcode !== 'number' && typeof errcode !== 'string') { + return '未知错误'; + } + + return errorMap[errcode] || '未知错误'; +} + +/** + * 创建请求摘要信息 + * + * @param {HttpResponse} response - HTTP响应结果 + * @returns {string} 请求摘要 + */ +export function createRequestSummary(response) { + if (!response) { + return 'No response data available'; + } + + let summary = `Status: ${response.statusCode}, Duration: ${response.duration}ms`; + + if (response.data && typeof response.data === 'object') { + summary += `, DingTalk Code: ${response.data.errcode}`; + + if (response.data.errcode !== 0) { + const description = getDingTalkErrorDescription(response.data.errcode); + summary += ` (${description})`; + } + } + + if (response.error) { + summary += `, Error: ${response.error}`; + } + + return summary; +} diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..244317b --- /dev/null +++ b/src/index.js @@ -0,0 +1,246 @@ +#!/usr/bin/env node + +/** + * 钉钉消息推送 Gitea Action 主入口文件 + * 整合所有模块,处理主要逻辑流程 + */ + +import { parseConfig, logConfig } from './config.js'; +import { buildMessage, validateMessage, getMessageSummary } from './message.js'; +import { + sendRequest, + validateResponse, + createRequestSummary, + getDingTalkErrorDescription, +} from './http.js'; +import { + logInfo, + logError, + logDebug, + logWarn, + setActionOutput, + setActionFailed, + isDebugMode, + formatDuration, + getTimestamp, +} from './utils.js'; + +/** + * Action 执行结果 + * + * @typedef {object} ActionResult + * @property {boolean} success - 是否成功 + * @property {string} [messageId] - 消息ID + * @property {string} [error] - 错误信息 + * @property {object} [details] - 详细信息 + */ + +/** + * 主执行函数 + * + * @returns {Promise} 执行结果 + */ +async function main() { + const startTime = Date.now(); + + try { + logInfo('🚀 DingTalk Gitea Action started'); + logInfo(`Timestamp: ${getTimestamp()}`); + logInfo(`Debug mode: ${isDebugMode()}`); + + // 步骤 1: 解析和验证配置 + logInfo('📋 Step 1: Parsing configuration...'); + const config = await parseConfig(); + + if (isDebugMode()) { + logConfig(config); + } else { + logInfo(`Configuration loaded - Message type: ${config.messageType}`); + } + + // 步骤 2: 构建消息 + logInfo('📝 Step 2: Building message...'); + const message = buildMessage(config); + + // 验证消息格式 + const messageValidation = validateMessage(message); + if (!messageValidation.isValid) { + throw new Error(`Message validation failed: ${messageValidation.errors.join(', ')}`); + } + + const messageSummary = getMessageSummary(message); + logInfo(`Message built successfully - ${messageSummary}`); + + if (isDebugMode()) { + logDebug('Message payload:', message); + } + + // 步骤 3: 发送消息 + logInfo('📤 Step 3: Sending message to DingTalk...'); + const response = await sendRequest(config.webhookUrl, message); + + // 验证响应 + const responseValidation = validateResponse(response); + if (!responseValidation.isValid) { + logWarn(`Response validation failed: ${responseValidation.errors.join(', ')}`); + } + + const requestSummary = createRequestSummary(response); + logInfo(`Request completed - ${requestSummary}`); + + if (isDebugMode()) { + logDebug('Full response:', response); + } + + // 步骤 4: 处理结果 + const duration = Date.now() - startTime; + logInfo(`⏱️ Total execution time: ${formatDuration(duration)}`); + + if (response.success) { + logInfo('✅ Message sent successfully!'); + + // 设置 Action 输出 + setActionOutput('success', 'true'); + setActionOutput('message_id', response.data.message_id || ''); + setActionOutput('error_message', ''); + + return { + success: true, + messageId: response.data.message_id, + details: { + duration, + statusCode: response.statusCode, + messageSummary, + }, + }; + } else { + // 发送失败 + const errorDescription = getDingTalkErrorDescription(response.data.errcode); + const errorMessage = `Failed to send message: ${errorDescription}`; + + logError(errorMessage); + logError(`DingTalk error code: ${response.data.errcode}`); + logError(`DingTalk error message: ${response.data.errmsg}`); + + if (response.error) { + logError(`Network error: ${response.error}`); + } + + // 设置 Action 输出 + setActionOutput('success', 'false'); + setActionOutput('message_id', ''); + setActionOutput('error_message', errorMessage); + + return { + success: false, + error: errorMessage, + details: { + duration, + statusCode: response.statusCode, + errcode: response.data.errcode, + errmsg: response.data.errmsg, + networkError: response.error, + }, + }; + } + } catch (error) { + const duration = Date.now() - startTime; + const errorMessage = `Action execution failed: ${error.message}`; + + logError(errorMessage); + logError(`Error details: ${error.stack}`); + logError(`Execution time before error: ${formatDuration(duration)}`); + + // 设置 Action 输出 + setActionOutput('success', 'false'); + setActionOutput('message_id', ''); + setActionOutput('error_message', errorMessage); + + return { + success: false, + error: errorMessage, + details: { + duration, + errorType: error.constructor.name, + errorCode: error.code, + errorDetails: error.details, + }, + }; + } +} + +/** + * 处理未捕获的异常 + * + * @param {Error} error - 错误对象 + */ +function handleUncaughtException(error) { + logError('💥 Uncaught exception occurred!'); + logError(`Error: ${error.message}`); + logError(`Stack: ${error.stack}`); + + setActionOutput('success', 'false'); + setActionOutput('message_id', ''); + setActionOutput('error_message', `Uncaught exception: ${error.message}`); + + process.exit(1); +} + +/** + * 处理未处理的 Promise 拒绝 + * + * @param {*} reason - 拒绝原因 + * @param {Promise} promise - Promise 对象 + */ +function handleUnhandledRejection(reason, promise) { + logError('💥 Unhandled promise rejection occurred!'); + logError(`Reason: ${reason}`); + logError(`Promise: ${promise}`); + + setActionOutput('success', 'false'); + setActionOutput('message_id', ''); + setActionOutput('error_message', `Unhandled rejection: ${reason}`); + + process.exit(1); +} + +/** + * 处理进程退出 + * + * @param {number} code - 退出码 + */ +function handleExit(code) { + if (code !== 0) { + logError(`Process exiting with code: ${code}`); + } else { + logInfo('🎉 Action completed successfully'); + } +} + +// 注册全局错误处理器 +process.on('uncaughtException', handleUncaughtException); +process.on('unhandledRejection', handleUnhandledRejection); +process.on('exit', handleExit); + +// 主程序入口 +if (import.meta.url === `file://${process.argv[1]}`) { + main() + .then(result => { + if (result.success) { + logInfo('🎯 Action execution completed successfully'); + process.exit(0); + } else { + logError('❌ Action execution failed'); + setActionFailed(result.error); + } + }) + .catch(error => { + logError('💥 Fatal error in main execution'); + logError(`Error: ${error.message}`); + logError(`Stack: ${error.stack}`); + setActionFailed(`Fatal error: ${error.message}`); + }); +} + +// 导出主函数供测试使用 +export { main }; diff --git a/src/message.js b/src/message.js new file mode 100644 index 0000000..7dd102d --- /dev/null +++ b/src/message.js @@ -0,0 +1,448 @@ +/** + * 消息构建模块 + * 根据不同消息类型构建符合钉钉 API 格式的消息体 + */ + +import { MESSAGE_TYPES } from './config.js'; + +/** + * 钉钉消息基础结构 + * + * @typedef {object} DingTalkMessage + * @property {string} msgtype - 消息类型 + * @property {object} [text] - 文本消息内容 + * @property {object} [markdown] - Markdown 消息内容 + * @property {object} [link] - 链接消息内容 + * @property {object} [actionCard] - ActionCard 消息内容 + * @property {object} [feedCard] - FeedCard 消息内容 + * @property {object} [at] - @ 相关配置 + */ + +/** + * At相关配置 + * + * @typedef {object} AtConfig + * @property {string[]} atMobiles - @的手机号列表 + * @property {boolean} isAtAll - 是否@所有人 + */ + +/** + * 构建@配置对象 + * + * @param {string[]} atMobiles - @的手机号列表 + * @param {boolean} atAll - 是否@所有人 + * @returns {AtConfig|undefined} @配置对象,如果没有@需求则返回undefined + */ +function buildAtConfig(atMobiles = [], atAll = false) { + if (!atAll && (!atMobiles || atMobiles.length === 0)) { + return undefined; + } + + return { + atMobiles: atMobiles || [], + isAtAll: atAll, + }; +} + +/** + * 构建文本消息 + * + * @param {string} content - 消息内容 + * @param {string[]} [atMobiles] - @的手机号列表 + * @param {boolean} [atAll] - 是否@所有人 + * @returns {DingTalkMessage} 钉钉文本消息格式 + */ +export function buildTextMessage(content, atMobiles, atAll) { + if (!content || typeof content !== 'string') { + throw new Error('Content is required for text message'); + } + + const message = { + msgtype: MESSAGE_TYPES.TEXT, + text: { + content, + }, + }; + + const atConfig = buildAtConfig(atMobiles, atAll); + if (atConfig) { + message.at = atConfig; + } + + return message; +} + +/** + * 构建 Markdown 消息 + * + * @param {string} title - 消息标题 + * @param {string} content - Markdown 内容 + * @param {string[]} [atMobiles] - @的手机号列表 + * @param {boolean} [atAll] - 是否@所有人 + * @returns {DingTalkMessage} 钉钉 Markdown 消息格式 + */ +export function buildMarkdownMessage(title, content, atMobiles, atAll) { + if (!title || typeof title !== 'string') { + throw new Error('Title is required for markdown message'); + } + + if (!content || typeof content !== 'string') { + throw new Error('Content is required for markdown message'); + } + + // 如果需要@人员,在 Markdown 内容中添加@信息 + let markdownContent = content; + if (atMobiles && atMobiles.length > 0) { + const atText = atMobiles.map(mobile => `@${mobile}`).join(' '); + markdownContent += `\n\n${atText}`; + } + + if (atAll) { + markdownContent += '\n\n@所有人'; + } + + const message = { + msgtype: MESSAGE_TYPES.MARKDOWN, + markdown: { + title, + text: markdownContent, + }, + }; + + const atConfig = buildAtConfig(atMobiles, atAll); + if (atConfig) { + message.at = atConfig; + } + + return message; +} + +/** + * 构建链接消息 + * + * @param {string} title - 消息标题 + * @param {string} content - 消息内容 + * @param {string} linkUrl - 链接地址 + * @param {string} [picUrl] - 图片地址 + * @returns {DingTalkMessage} 钉钉链接消息格式 + */ +export function buildLinkMessage(title, content, linkUrl, picUrl) { + if (!title || typeof title !== 'string') { + throw new Error('Title is required for link message'); + } + + if (!content || typeof content !== 'string') { + throw new Error('Content is required for link message'); + } + + if (!linkUrl || typeof linkUrl !== 'string') { + throw new Error('Link URL is required for link message'); + } + + // 验证链接格式 + if (!/^https?:\/\/.+/.test(linkUrl)) { + throw new Error('Link URL must be a valid HTTP/HTTPS URL'); + } + + const linkMessage = { + title, + text: content, + messageUrl: linkUrl, + }; + + // 添加图片URL(如果提供) + if (picUrl && typeof picUrl === 'string' && /^https?:\/\/.+/.test(picUrl)) { + linkMessage.picUrl = picUrl; + } + + return { + msgtype: MESSAGE_TYPES.LINK, + link: linkMessage, + }; +} + +/** + * 构建 ActionCard 消息 + * + * @param {string} title - 消息标题 + * @param {string} content - 消息内容(Markdown 支持) + * @param {object} options - 选项 + * @param {string} options.btnOrientation - 按钮排列方向('0'竖直,'1'水平) + * @param {string} [options.singleTitle] - 单按钮标题(整体跳转) + * @param {string} [options.singleURL] - 单按钮链接(整体跳转) + * @param {{ title: string, actionURL: string }[]} [options.buttons] - 多按钮配置(独立跳转) + * @returns {DingTalkMessage} 钉钉 ActionCard 消息格式 + */ +export function buildActionCardMessage(title, content, options) { + if (!title || typeof title !== 'string') { + throw new Error('Title is required for actionCard message'); + } + if (!content || typeof content !== 'string') { + throw new Error('Content is required for actionCard message'); + } + + const { btnOrientation = '0', singleTitle, singleURL, buttons = [] } = options || {}; + + const payload = { + title, + text: content, + btnOrientation, + }; + + const hasSingle = !!singleTitle && !!singleURL; + const hasButtons = Array.isArray(buttons) && buttons.length > 0; + + if (!hasSingle && !hasButtons) { + throw new Error('actionCard requires either singleTitle/singleURL or buttons'); + } + + if (hasSingle) { + payload.singleTitle = singleTitle; + payload.singleURL = singleURL; + } + + if (hasButtons) { + // 钉钉字段为 btns + payload.btns = buttons.map(b => ({ title: b.title, actionURL: b.actionURL })); + } + + return { + msgtype: MESSAGE_TYPES.ACTION_CARD, + actionCard: payload, + }; +} + +/** + * 构建 FeedCard 消息 + * + * @param {{ title: string, messageURL: string, picURL: string }[]} links - 图文链接列表 + * @returns {DingTalkMessage} 钉钉 FeedCard 消息格式 + */ +export function buildFeedCardMessage(links) { + if (!Array.isArray(links) || links.length === 0) { + throw new Error('FeedCard requires a non-empty links array'); + } + + return { + msgtype: MESSAGE_TYPES.FEED_CARD, + feedCard: { + links: links.map(l => ({ title: l.title, messageURL: l.messageURL, picURL: l.picURL })), + }, + }; +} + +/** + * 根据配置构建消息 + * + * @param {object} config - 配置对象 + * @param {string} config.messageType - 消息类型 + * @param {string} config.content - 消息内容 + * @param {string} [config.title] - 消息标题 + * @param {string[]} [config.atMobiles] - @的手机号列表 + * @param {boolean} [config.atAll] - 是否@所有人 + * @param {string} [config.linkUrl] - 链接地址 + * @param {string} [config.picUrl] - 图片地址 + * @returns {DingTalkMessage} 钉钉消息格式 + * @throws {Error} 当消息类型不支持或参数不正确时抛出错误 + */ +export function buildMessage(config) { + const { + messageType, + content, + title, + atMobiles, + atAll, + linkUrl, + picUrl, + btnOrientation, + singleTitle, + singleURL, + buttons, + feedLinks, + } = config; + + switch (messageType) { + case MESSAGE_TYPES.TEXT: + return buildTextMessage(content, atMobiles, atAll); + + case MESSAGE_TYPES.MARKDOWN: + return buildMarkdownMessage(title, content, atMobiles, atAll); + + case MESSAGE_TYPES.LINK: + return buildLinkMessage(title, content, linkUrl, picUrl); + + case MESSAGE_TYPES.ACTION_CARD: + return buildActionCardMessage(title, content, { + btnOrientation, + singleTitle, + singleURL, + buttons, + }); + + case MESSAGE_TYPES.FEED_CARD: + return buildFeedCardMessage(feedLinks); + + default: + throw new Error(`Unsupported message type: ${messageType}`); + } +} + +/** + * 验证消息格式 + * + * @param {DingTalkMessage} message - 钉钉消息对象 + * @returns {object} 验证结果 + */ +export function validateMessage(message) { + const errors = []; + + if (!message || typeof message !== 'object') { + errors.push('Message must be an object'); + return { isValid: false, errors }; + } + + if (!message.msgtype || !Object.values(MESSAGE_TYPES).includes(message.msgtype)) { + errors.push('Invalid or missing msgtype'); + } + + // 根据消息类型验证特定字段 + switch (message.msgtype) { + case MESSAGE_TYPES.TEXT: + if (!message.text || !message.text.content) { + errors.push('Text message must have content'); + } + break; + + case MESSAGE_TYPES.MARKDOWN: + if (!message.markdown || !message.markdown.title || !message.markdown.text) { + errors.push('Markdown message must have title and text'); + } + break; + + case MESSAGE_TYPES.LINK: + if (!message.link || !message.link.title || !message.link.text || !message.link.messageUrl) { + errors.push('Link message must have title, text, and messageUrl'); + } + break; + + case MESSAGE_TYPES.ACTION_CARD: + if (!message.actionCard || !message.actionCard.title || !message.actionCard.text) { + errors.push('ActionCard message must have title and text'); + break; + } + if ( + (message.actionCard.singleTitle && !message.actionCard.singleURL) || + (message.actionCard.singleURL && !message.actionCard.singleTitle) + ) { + errors.push('ActionCard single button requires both singleTitle and singleURL'); + } + if (message.actionCard.btns && Array.isArray(message.actionCard.btns)) { + for (const btn of message.actionCard.btns) { + if (!btn.title || !btn.actionURL) { + errors.push('Each ActionCard button must have title and actionURL'); + break; + } + } + } + break; + + case MESSAGE_TYPES.FEED_CARD: + if ( + !message.feedCard || + !Array.isArray(message.feedCard.links) || + message.feedCard.links.length === 0 + ) { + errors.push('FeedCard must have a non-empty links array'); + break; + } + for (const link of message.feedCard.links) { + if (!link.title || !link.messageURL || !link.picURL) { + errors.push('Each FeedCard link must have title, messageURL and picURL'); + break; + } + } + break; + } + + return { + isValid: errors.length === 0, + errors, + }; +} + +/** + * 获取消息摘要信息(用于日志输出) + * + * @param {DingTalkMessage} message - 钉钉消息对象 + * @returns {string} 消息摘要 + */ +export function getMessageSummary(message) { + if (!message || !message.msgtype) { + return 'Invalid message'; + } + + const type = message.msgtype; + let summary = `Type: ${type}`; + + switch (type) { + case MESSAGE_TYPES.TEXT: + if (message.text && message.text.content) { + const contentPreview = + message.text.content.length > 50 + ? `${message.text.content.substring(0, 50)}...` + : message.text.content; + summary += `, Content: "${contentPreview}"`; + } + break; + + case MESSAGE_TYPES.MARKDOWN: + if (message.markdown) { + summary += `, Title: "${message.markdown.title}"`; + if (message.markdown.text) { + const textPreview = + message.markdown.text.length > 50 + ? `${message.markdown.text.substring(0, 50)}...` + : message.markdown.text; + summary += `, Text: "${textPreview}"`; + } + } + break; + + case MESSAGE_TYPES.LINK: + if (message.link) { + summary += `, Title: "${message.link.title}"`; + summary += `, URL: "${message.link.messageUrl}"`; + } + break; + + case MESSAGE_TYPES.ACTION_CARD: + if (message.actionCard) { + summary += `, Title: "${message.actionCard.title}"`; + if (message.actionCard.singleTitle) { + summary += `, Single: "${message.actionCard.singleTitle}"`; + } + if (message.actionCard.btns) { + summary += `, Buttons: ${message.actionCard.btns.length}`; + } + } + break; + + case MESSAGE_TYPES.FEED_CARD: + if (message.feedCard && Array.isArray(message.feedCard.links)) { + summary += `, Links: ${message.feedCard.links.length}`; + } + break; + } + + // 添加@信息 + if (message.at) { + if (message.at.isAtAll) { + summary += ', @All: true'; + } + if (message.at.atMobiles && message.at.atMobiles.length > 0) { + summary += `, @Mobiles: ${message.at.atMobiles.length}`; + } + } + + return summary; +} diff --git a/src/utils.js b/src/utils.js new file mode 100644 index 0000000..b632e39 --- /dev/null +++ b/src/utils.js @@ -0,0 +1,349 @@ +/** + * 工具函数模块 + * 提供通用的辅助功能和实用工具 + */ + +/** + * 日志级别枚举 + * + * @readonly + * @enum {string} + */ +export const LOG_LEVELS = { + DEBUG: 'DEBUG', + INFO: 'INFO', + WARN: 'WARN', + ERROR: 'ERROR', +}; + +/** + * 获取当前时间戳字符串 + * + * @returns {string} 格式化的时间戳 + */ +export function getTimestamp() { + return new Date().toISOString(); +} + +/** + * 格式化日志消息 + * + * @param {string} level - 日志级别 + * @param {string} message - 日志消息 + * @param {object} [meta] - 额外的元数据 + * @returns {string} 格式化后的日志消息 + */ +export function formatLogMessage(level, message, meta = null) { + const timestamp = getTimestamp(); + let logMessage = `[${timestamp}] [${level}] ${message}`; + + if (meta && typeof meta === 'object') { + logMessage += ` | ${JSON.stringify(meta)}`; + } + + return logMessage; +} + +/** + * 输出调试日志 + * + * @param {string} message - 日志消息 + * @param {object} [meta] - 额外的元数据 + */ +export function logDebug(message, meta) { + if (process.env.DEBUG === 'true' || process.env.NODE_ENV === 'development') { + console.log(formatLogMessage(LOG_LEVELS.DEBUG, message, meta)); + } +} + +/** + * 输出信息日志 + * + * @param {string} message - 日志消息 + * @param {object} [meta] - 额外的元数据 + */ +export function logInfo(message, meta) { + console.log(formatLogMessage(LOG_LEVELS.INFO, message, meta)); +} + +/** + * 输出警告日志 + * + * @param {string} message - 日志消息 + * @param {object} [meta] - 额外的元数据 + */ +export function logWarn(message, meta) { + console.warn(formatLogMessage(LOG_LEVELS.WARN, message, meta)); +} + +/** + * 输出错误日志 + * + * @param {string} message - 日志消息 + * @param {object} [meta] - 额外的元数据 + */ +export function logError(message, meta) { + console.error(formatLogMessage(LOG_LEVELS.ERROR, message, meta)); +} + +/** + * 安全地解析 JSON 字符串 + * + * @param {string} jsonString - JSON 字符串 + * @param {*} [defaultValue] - 解析失败时的默认值 + * @returns {*} 解析结果或默认值 + */ +export function safeJsonParse(jsonString, defaultValue = null) { + try { + return JSON.parse(jsonString); + } catch (error) { + logWarn(`Failed to parse JSON: ${error.message}`, { jsonString }); + return defaultValue; + } +} + +/** + * 安全地序列化对象为 JSON 字符串 + * + * @param {*} obj - 要序列化的对象 + * @param {string} [defaultValue] - 序列化失败时的默认值 + * @returns {string} JSON 字符串或默认值 + */ +export function safeJsonStringify(obj, defaultValue = '{}') { + try { + return JSON.stringify(obj, null, 2); + } catch (error) { + // 避免循环引用问题,不传递 obj 到 logWarn + logWarn(`Failed to stringify object: ${error.message}`); + return defaultValue; + } +} + +/** + * 检查字符串是否为空或只包含空白字符 + * + * @param {string} str - 要检查的字符串 + * @returns {boolean} 是否为空 + */ +export function isEmpty(str) { + return !str || typeof str !== 'string' || str.trim().length === 0; +} + +/** + * 截断字符串到指定长度 + * + * @param {string} str - 原字符串 + * @param {number} maxLength - 最大长度 + * @param {string} [suffix] - 截断后的后缀 + * @returns {string} 截断后的字符串 + */ +export function truncateString(str, maxLength, suffix = '...') { + if (!str || typeof str !== 'string') { + return ''; + } + + if (str.length <= maxLength) { + return str; + } + + return str.substring(0, maxLength - suffix.length) + suffix; +} + +/** + * 清理和验证手机号 + * + * @param {string} mobile - 手机号字符串 + * @returns {string|null} 清理后的手机号或null + */ +export function cleanMobile(mobile) { + if (!mobile || typeof mobile !== 'string') { + return null; + } + + // 移除所有非数字字符 + const cleaned = mobile.replace(/\D/g, ''); + + // 验证中国大陆手机号格式 + if (/^1[3-9]\d{9}$/.test(cleaned)) { + return cleaned; + } + + return null; +} + +/** + * 验证 URL 格式 + * + * @param {string} url - URL 字符串 + * @returns {boolean} 是否为有效的 URL + */ +export function isValidUrl(url) { + if (!url || typeof url !== 'string') { + return false; + } + + try { + new URL(url); + return true; + } catch { + return false; + } +} + +/** + * 验证 HTTP/HTTPS URL + * + * @param {string} url - URL 字符串 + * @returns {boolean} 是否为有效的 HTTP/HTTPS URL + */ +export function isValidHttpUrl(url) { + if (!isValidUrl(url)) { + return false; + } + + return /^https?:\/\/.+/.test(url); +} + +/** + * 创建错误对象 + * + * @param {string} message - 错误消息 + * @param {string} [code] - 错误代码 + * @param {*} [details] - 错误详情 + * @returns {Error} 错误对象 + */ +export function createError(message, code = null, details = null) { + const error = new Error(message); + + if (code) { + error.code = code; + } + + if (details) { + error.details = details; + } + + return error; +} + +/** + * 设置 GitHub Actions 输出 + * + * @param {string} name - 输出名称 + * @param {string} value - 输出值 + */ +export function setActionOutput(name, value) { + if (process.env.GITHUB_OUTPUT) { + // GitHub Actions 新格式 + const fs = require('fs'); + fs.appendFileSync(process.env.GITHUB_OUTPUT, `${name}=${value}\n`); + } else { + // 兼容旧格式 + console.log(`::set-output name=${name}::${value}`); + } +} + +/** + * 设置 GitHub Actions 失败状态 + * + * @param {string} message - 失败消息 + */ +export function setActionFailed(message) { + console.log(`::error::${message}`); + process.exit(1); +} + +/** + * 获取环境变量,支持默认值 + * + * @param {string} name - 环境变量名 + * @param {string} [defaultValue] - 默认值 + * @returns {string} 环境变量值或默认值 + */ +export function getEnv(name, defaultValue = '') { + return process.env[name] || defaultValue; +} + +/** + * 检查是否在调试模式 + * + * @returns {boolean} 是否为调试模式 + */ +export function isDebugMode() { + return getEnv('DEBUG', 'false').toLowerCase() === 'true' || getEnv('NODE_ENV') === 'development'; +} + +/** + * 格式化持续时间 + * + * @param {number} milliseconds - 毫秒数 + * @returns {string} 格式化的持续时间 + */ +export function formatDuration(milliseconds) { + if (milliseconds < 1000) { + return `${milliseconds}ms`; + } + + const seconds = (milliseconds / 1000).toFixed(2); + return `${seconds}s`; +} + +/** + * 创建重试配置 + * + * @param {number} [maxRetries] - 最大重试次数 + * @param {number} [delay] - 重试延迟(毫秒) + * @param {number} [backoffMultiplier] - 退避乘数 + * @returns {object} 重试配置 + */ +export function createRetryConfig(maxRetries = 3, delay = 1000, backoffMultiplier = 1.5) { + return { + maxRetries, + delay, + backoffMultiplier, + getDelay: attempt => Math.floor(delay * Math.pow(backoffMultiplier, attempt)), + }; +} + +/** + * 掩码敏感信息 + * + * @param {string} str - 原字符串 + * @param {number} [visibleStart] - 开始可见字符数 + * @param {number} [visibleEnd] - 结尾可见字符数 + * @param {string} [maskChar] - 掩码字符 + * @returns {string} 掩码后的字符串 + */ +export function maskSensitiveInfo(str, visibleStart = 4, visibleEnd = 4, maskChar = '*') { + if (!str || typeof str !== 'string') { + return ''; + } + + if (str.length <= visibleStart + visibleEnd) { + return maskChar.repeat(str.length); + } + + const start = str.substring(0, visibleStart); + const end = str.substring(str.length - visibleEnd); + const maskLength = str.length - visibleStart - visibleEnd; + + return start + maskChar.repeat(maskLength) + end; +} + +/** + * 验证必需的环境变量 + * + * @param {string[]} requiredVars - 必需的环境变量名列表 + * @throws {Error} 当缺少必需的环境变量时抛出错误 + */ +export function validateRequiredEnvVars(requiredVars) { + const missing = requiredVars.filter(varName => !process.env[varName]); + + if (missing.length > 0) { + throw createError( + `Missing required environment variables: ${missing.join(', ')}`, + 'MISSING_ENV_VARS', + { missing }, + ); + } +} diff --git a/tests/fixtures/configs/invalid-configs.js b/tests/fixtures/configs/invalid-configs.js new file mode 100644 index 0000000..771f6c9 --- /dev/null +++ b/tests/fixtures/configs/invalid-configs.js @@ -0,0 +1,407 @@ +/** + * 无效配置数据夹具 + * 包含各种无效的配置组合用于测试错误处理 + */ + +export const invalidConfigs = { + // 环境变量格式的无效配置 + envVars: { + missingWebhook: { + INPUT_WEBHOOK_URL: '', + INPUT_MESSAGE_TYPE: 'text', + INPUT_CONTENT: 'Hello World', + INPUT_TITLE: '', + INPUT_AT_MOBILES: '', + INPUT_AT_ALL: 'false', + INPUT_LINK_URL: '', + INPUT_PIC_URL: '', + INPUT_BTN_ORIENTATION: '0', + INPUT_SINGLE_TITLE: '', + INPUT_SINGLE_URL: '', + INPUT_BUTTONS: '', + INPUT_FEED_LINKS: '' + }, + + invalidWebhook: { + INPUT_WEBHOOK_URL: 'not-a-valid-url', + INPUT_MESSAGE_TYPE: 'text', + INPUT_CONTENT: 'Hello World', + INPUT_TITLE: '', + INPUT_AT_MOBILES: '', + INPUT_AT_ALL: 'false', + INPUT_LINK_URL: '', + INPUT_PIC_URL: '', + INPUT_BTN_ORIENTATION: '0', + INPUT_SINGLE_TITLE: '', + INPUT_SINGLE_URL: '', + INPUT_BUTTONS: '', + INPUT_FEED_LINKS: '' + }, + + emptyContent: { + INPUT_WEBHOOK_URL: 'https://oapi.dingtalk.com/robot/send?access_token=test123', + INPUT_MESSAGE_TYPE: 'text', + INPUT_CONTENT: '', + INPUT_TITLE: '', + INPUT_AT_MOBILES: '', + INPUT_AT_ALL: 'false', + INPUT_LINK_URL: '', + INPUT_PIC_URL: '', + INPUT_BTN_ORIENTATION: '0', + INPUT_SINGLE_TITLE: '', + INPUT_SINGLE_URL: '', + INPUT_BUTTONS: '', + INPUT_FEED_LINKS: '' + } + }, + + // 缺少 webhook_url + missingWebhookUrl: { + webhook_url: '', + message_type: 'text', + content: 'Hello, World!', + title: '', + at_mobiles: '', + at_all: false, + link_url: '', + pic_url: '', + btn_orientation: '0', + single_title: '', + single_url: '', + buttons: '', + feed_links: '' + }, + + // 无效的 webhook_url + invalidWebhookUrl: { + webhook_url: 'not-a-valid-url', + message_type: 'text', + content: 'Hello, World!', + title: '', + at_mobiles: '', + at_all: false, + link_url: '', + pic_url: '', + btn_orientation: '0', + single_title: '', + single_url: '', + buttons: '', + feed_links: '' + }, + + // 不支持的消息类型 + unsupportedMessageType: { + webhook_url: 'https://oapi.dingtalk.com/robot/send?access_token=test123', + message_type: 'unsupported', + content: 'Hello, World!', + title: '', + at_mobiles: '', + at_all: false, + link_url: '', + pic_url: '', + btn_orientation: '0', + single_title: '', + single_url: '', + buttons: '', + feed_links: '' + }, + + // 空的消息内容 + emptyContent: { + webhook_url: 'https://oapi.dingtalk.com/robot/send?access_token=test123', + message_type: 'text', + content: '', + title: '', + at_mobiles: '', + at_all: false, + link_url: '', + pic_url: '', + btn_orientation: '0', + single_title: '', + single_url: '', + buttons: '', + feed_links: '' + }, + + // Markdown 消息缺少标题 + markdownMissingTitle: { + webhook_url: 'https://oapi.dingtalk.com/robot/send?access_token=test123', + message_type: 'markdown', + content: '## 内容', + title: '', + at_mobiles: '', + at_all: false, + link_url: '', + pic_url: '', + btn_orientation: '0', + single_title: '', + single_url: '', + buttons: '', + feed_links: '' + }, + + // Link 消息缺少 URL + linkMissingUrl: { + webhook_url: 'https://oapi.dingtalk.com/robot/send?access_token=test123', + message_type: 'link', + content: '链接描述', + title: '链接标题', + at_mobiles: '', + at_all: false, + link_url: '', + pic_url: '', + btn_orientation: '0', + single_title: '', + single_url: '', + buttons: '', + feed_links: '' + }, + + // Link 消息无效的 URL + linkInvalidUrl: { + webhook_url: 'https://oapi.dingtalk.com/robot/send?access_token=test123', + message_type: 'link', + content: '链接描述', + title: '链接标题', + at_mobiles: '', + at_all: false, + link_url: 'not-a-valid-url', + pic_url: '', + btn_orientation: '0', + single_title: '', + single_url: '', + buttons: '', + feed_links: '' + }, + + // ActionCard 缺少标题 + actionCardMissingTitle: { + webhook_url: 'https://oapi.dingtalk.com/robot/send?access_token=test123', + message_type: 'actionCard', + content: 'ActionCard 内容', + title: '', + at_mobiles: '', + at_all: false, + link_url: '', + pic_url: '', + btn_orientation: '0', + single_title: '按钮', + single_url: 'https://example.com', + buttons: '', + feed_links: '' + }, + + // ActionCard 既没有单按钮也没有多按钮 + actionCardNoButtons: { + webhook_url: 'https://oapi.dingtalk.com/robot/send?access_token=test123', + message_type: 'actionCard', + content: 'ActionCard 内容', + title: 'ActionCard 标题', + at_mobiles: '', + at_all: false, + link_url: '', + pic_url: '', + btn_orientation: '0', + single_title: '', + single_url: '', + buttons: '', + feed_links: '' + }, + + // ActionCard 单按钮缺少 URL + actionCardSingleMissingUrl: { + webhook_url: 'https://oapi.dingtalk.com/robot/send?access_token=test123', + message_type: 'actionCard', + content: 'ActionCard 内容', + title: 'ActionCard 标题', + at_mobiles: '', + at_all: false, + link_url: '', + pic_url: '', + btn_orientation: '0', + single_title: '按钮', + single_url: '', + buttons: '', + feed_links: '' + }, + + // ActionCard 多按钮格式错误 + actionCardInvalidButtons: { + webhook_url: 'https://oapi.dingtalk.com/robot/send?access_token=test123', + message_type: 'actionCard', + content: 'ActionCard 内容', + title: 'ActionCard 标题', + at_mobiles: '', + at_all: false, + link_url: '', + pic_url: '', + btn_orientation: '0', + single_title: '', + single_url: '', + buttons: 'invalid-json', + feed_links: '' + }, + + // ActionCard 多按钮为空数组 + actionCardEmptyButtons: { + webhook_url: 'https://oapi.dingtalk.com/robot/send?access_token=test123', + message_type: 'actionCard', + content: 'ActionCard 内容', + title: 'ActionCard 标题', + at_mobiles: '', + at_all: false, + link_url: '', + pic_url: '', + btn_orientation: '0', + single_title: '', + single_url: '', + buttons: '[]', + feed_links: '' + }, + + // ActionCard 按钮缺少必需字段 + actionCardButtonMissingFields: { + webhook_url: 'https://oapi.dingtalk.com/robot/send?access_token=test123', + message_type: 'actionCard', + content: 'ActionCard 内容', + title: 'ActionCard 标题', + at_mobiles: '', + at_all: false, + link_url: '', + pic_url: '', + btn_orientation: '0', + single_title: '', + single_url: '', + buttons: JSON.stringify([ + { title: '按钮1' }, // 缺少 actionURL + { actionURL: 'https://example.com' } // 缺少 title + ]), + feed_links: '' + }, + + // FeedCard 缺少链接 + feedCardMissingLinks: { + webhook_url: 'https://oapi.dingtalk.com/robot/send?access_token=test123', + message_type: 'feedCard', + content: '', + title: '', + at_mobiles: '', + at_all: false, + link_url: '', + pic_url: '', + btn_orientation: '0', + single_title: '', + single_url: '', + buttons: '', + feed_links: '' + }, + + // FeedCard 链接格式错误 + feedCardInvalidLinks: { + webhook_url: 'https://oapi.dingtalk.com/robot/send?access_token=test123', + message_type: 'feedCard', + content: '', + title: '', + at_mobiles: '', + at_all: false, + link_url: '', + pic_url: '', + btn_orientation: '0', + single_title: '', + single_url: '', + buttons: '', + feed_links: 'invalid-json' + }, + + // FeedCard 空链接数组 + feedCardEmptyLinks: { + webhook_url: 'https://oapi.dingtalk.com/robot/send?access_token=test123', + message_type: 'feedCard', + content: '', + title: '', + at_mobiles: '', + at_all: false, + link_url: '', + pic_url: '', + btn_orientation: '0', + single_title: '', + single_url: '', + buttons: '', + feed_links: '[]' + }, + + // FeedCard 链接缺少必需字段 + feedCardLinkMissingFields: { + webhook_url: 'https://oapi.dingtalk.com/robot/send?access_token=test123', + message_type: 'feedCard', + content: '', + title: '', + at_mobiles: '', + at_all: false, + link_url: '', + pic_url: '', + btn_orientation: '0', + single_title: '', + single_url: '', + buttons: '', + feed_links: JSON.stringify([ + { title: '链接1' }, // 缺少 messageURL + { messageURL: 'https://example.com' } // 缺少 title + ]) + }, + + // 无效的手机号格式 + invalidMobileFormat: { + webhook_url: 'https://oapi.dingtalk.com/robot/send?access_token=test123', + message_type: 'text', + content: 'Hello, World!', + title: '', + at_mobiles: 'invalid-mobile', + at_all: false, + link_url: '', + pic_url: '', + btn_orientation: '0', + single_title: '', + single_url: '', + buttons: '', + feed_links: '' + }, + + // 无效的按钮方向 + invalidBtnOrientation: { + webhook_url: 'https://oapi.dingtalk.com/robot/send?access_token=test123', + message_type: 'actionCard', + content: 'ActionCard 内容', + title: 'ActionCard 标题', + at_mobiles: '', + at_all: false, + link_url: '', + pic_url: '', + btn_orientation: '2', // 只支持 0 和 1 + single_title: '按钮', + single_url: 'https://example.com', + buttons: '', + feed_links: '' + } +}; + +// 环境变量格式的无效配置 +export const invalidEnvConfigs = { + missingWebhookUrl: { + INPUT_WEBHOOK_URL: '', + INPUT_MESSAGE_TYPE: 'text', + INPUT_CONTENT: 'Hello, World!' + }, + + unsupportedMessageType: { + INPUT_WEBHOOK_URL: 'https://oapi.dingtalk.com/robot/send?access_token=test123', + INPUT_MESSAGE_TYPE: 'unsupported', + INPUT_CONTENT: 'Hello, World!' + }, + + emptyContent: { + INPUT_WEBHOOK_URL: 'https://oapi.dingtalk.com/robot/send?access_token=test123', + INPUT_MESSAGE_TYPE: 'text', + INPUT_CONTENT: '' + } +}; \ No newline at end of file diff --git a/tests/fixtures/configs/valid-configs.js b/tests/fixtures/configs/valid-configs.js new file mode 100644 index 0000000..d1f1ce2 --- /dev/null +++ b/tests/fixtures/configs/valid-configs.js @@ -0,0 +1,378 @@ +/** + * 有效配置数据夹具 + * 包含各种有效的配置组合用于测试 + */ + +export const validConfigs = { + // 环境变量格式的配置 + envVars: { + text: { + INPUT_WEBHOOK_URL: 'https://oapi.dingtalk.com/robot/send?access_token=test123', + INPUT_MESSAGE_TYPE: 'text', + INPUT_CONTENT: 'Hello World', + INPUT_TITLE: '', + INPUT_AT_MOBILES: '', + INPUT_AT_ALL: 'false', + INPUT_LINK_URL: '', + INPUT_PIC_URL: '', + INPUT_BTN_ORIENTATION: '0', + INPUT_SINGLE_TITLE: '', + INPUT_SINGLE_URL: '', + INPUT_BUTTONS: '', + INPUT_FEED_LINKS: '', + }, + + markdown: { + INPUT_WEBHOOK_URL: 'https://oapi.dingtalk.com/robot/send?access_token=test456', + INPUT_MESSAGE_TYPE: 'markdown', + INPUT_CONTENT: '# Hello\n\nThis is **markdown** content.', + INPUT_TITLE: 'Test Title', + INPUT_AT_MOBILES: '', + INPUT_AT_ALL: 'false', + INPUT_LINK_URL: '', + INPUT_PIC_URL: '', + INPUT_BTN_ORIENTATION: '0', + INPUT_SINGLE_TITLE: '', + INPUT_SINGLE_URL: '', + INPUT_BUTTONS: '', + INPUT_FEED_LINKS: '', + }, + + link: { + INPUT_WEBHOOK_URL: 'https://oapi.dingtalk.com/robot/send?access_token=test789', + INPUT_MESSAGE_TYPE: 'link', + INPUT_CONTENT: 'This is a test link', + INPUT_TITLE: 'Test Link', + INPUT_AT_MOBILES: '', + INPUT_AT_ALL: 'false', + INPUT_LINK_URL: 'https://example.com', + INPUT_PIC_URL: '', + INPUT_BTN_ORIENTATION: '0', + INPUT_SINGLE_TITLE: '', + INPUT_SINGLE_URL: '', + INPUT_BUTTONS: '', + INPUT_FEED_LINKS: '', + }, + + actionCard: { + INPUT_WEBHOOK_URL: 'https://oapi.dingtalk.com/robot/send?access_token=test101', + INPUT_MESSAGE_TYPE: 'actionCard', + INPUT_CONTENT: 'Action card content', + INPUT_TITLE: 'Action Card Title', + INPUT_AT_MOBILES: '', + INPUT_AT_ALL: 'false', + INPUT_LINK_URL: '', + INPUT_PIC_URL: '', + INPUT_BTN_ORIENTATION: '0', + INPUT_SINGLE_TITLE: '', + INPUT_SINGLE_URL: '', + INPUT_BUTTONS: JSON.stringify([{ title: 'Button 1', actionURL: 'https://example.com/1' }]), + INPUT_FEED_LINKS: '', + }, + + feedCard: { + INPUT_WEBHOOK_URL: 'https://oapi.dingtalk.com/robot/send?access_token=test103', + INPUT_MESSAGE_TYPE: 'feedCard', + INPUT_CONTENT: '', + INPUT_TITLE: '', + INPUT_AT_MOBILES: '', + INPUT_AT_ALL: 'false', + INPUT_LINK_URL: '', + INPUT_PIC_URL: '', + INPUT_BTN_ORIENTATION: '0', + INPUT_SINGLE_TITLE: '', + INPUT_SINGLE_URL: '', + INPUT_BUTTONS: '', + INPUT_FEED_LINKS: JSON.stringify([ + { + title: '新闻1', + messageURL: 'https://example.com/news1', + picURL: 'https://example.com/pic1.jpg', + }, + ]), + }, + }, + + // 基础文本消息配置 + basicText: { + webhook_url: 'https://oapi.dingtalk.com/robot/send?access_token=test123', + message_type: 'text', + content: 'Hello, World!', + title: '', + at_mobiles: '', + at_all: false, + link_url: '', + pic_url: '', + btn_orientation: '0', + single_title: '', + single_url: '', + buttons: '', + feed_links: '', + }, + + // Markdown 消息配置 + markdown: { + webhook_url: 'https://oapi.dingtalk.com/robot/send?access_token=test456', + message_type: 'markdown', + content: '## 标题\n这是一个 **Markdown** 消息', + title: 'Markdown 标题', + at_mobiles: '', + at_all: false, + link_url: '', + pic_url: '', + btn_orientation: '0', + single_title: '', + single_url: '', + buttons: '', + feed_links: '', + }, + + // 链接消息配置 + link: { + webhook_url: 'https://oapi.dingtalk.com/robot/send?access_token=test789', + message_type: 'link', + content: '这是一个链接消息的描述', + title: '链接标题', + at_mobiles: '', + at_all: false, + link_url: 'https://example.com', + pic_url: 'https://example.com/image.jpg', + btn_orientation: '0', + single_title: '', + single_url: '', + buttons: '', + feed_links: '', + }, + + // 单按钮 ActionCard 配置 + singleActionCard: { + webhook_url: 'https://oapi.dingtalk.com/robot/send?access_token=test101', + message_type: 'actionCard', + content: '这是一个单按钮 ActionCard', + title: 'ActionCard 标题', + at_mobiles: '', + at_all: false, + link_url: '', + pic_url: '', + btn_orientation: '0', + single_title: '查看详情', + single_url: 'https://example.com/detail', + buttons: '', + feed_links: '', + }, + + // 多按钮 ActionCard 配置 + multiActionCard: { + webhook_url: 'https://oapi.dingtalk.com/robot/send?access_token=test102', + message_type: 'actionCard', + content: '这是一个多按钮 ActionCard', + title: 'ActionCard 标题', + at_mobiles: '', + at_all: false, + link_url: '', + pic_url: '', + btn_orientation: '1', + single_title: '', + single_url: '', + buttons: JSON.stringify([ + { title: '按钮1', actionURL: 'https://example.com/action1' }, + { title: '按钮2', actionURL: 'https://example.com/action2' }, + ]), + feed_links: '', + }, + + // FeedCard 配置 + feedCard: { + webhook_url: 'https://oapi.dingtalk.com/robot/send?access_token=test103', + message_type: 'feedCard', + content: '', + title: '', + at_mobiles: '', + at_all: false, + link_url: '', + pic_url: '', + btn_orientation: '0', + single_title: '', + single_url: '', + buttons: '', + feed_links: JSON.stringify([ + { + title: '新闻1', + messageURL: 'https://example.com/news1', + picURL: 'https://example.com/pic1.jpg', + }, + { + title: '新闻2', + messageURL: 'https://example.com/news2', + picURL: 'https://example.com/pic2.jpg', + }, + ]), + }, + + // 带 @ 功能的文本消息 + textWithAt: { + webhook_url: 'https://oapi.dingtalk.com/robot/send?access_token=test104', + message_type: 'text', + content: '这是一个带 @ 功能的消息', + title: '', + at_mobiles: '13800138000,13900139000', + at_all: false, + link_url: '', + pic_url: '', + btn_orientation: '0', + single_title: '', + single_url: '', + buttons: '', + feed_links: '', + }, + + // @ 所有人的消息 + textWithAtAll: { + webhook_url: 'https://oapi.dingtalk.com/robot/send?access_token=test105', + message_type: 'text', + content: '这是一个 @ 所有人的消息', + title: '', + at_mobiles: '', + at_all: true, + link_url: '', + pic_url: '', + btn_orientation: '0', + single_title: '', + single_url: '', + buttons: '', + feed_links: '', + }, + + // 复杂的 Markdown 消息 + complexMarkdown: { + webhook_url: 'https://oapi.dingtalk.com/robot/send?access_token=test106', + message_type: 'markdown', + content: `# 构建报告 +## 构建状态: ✅ 成功 +- **项目**: dingtalk-bot +- **分支**: main +- **提交**: abc123 +- **时间**: 2024-01-01 12:00:00 + +### 测试结果 +- 单元测试: 100% 通过 +- 覆盖率: 95% + +[查看详细报告](https://example.com/report)`, + title: '构建报告', + at_mobiles: '13800138000', + at_all: false, + link_url: '', + pic_url: '', + btn_orientation: '0', + single_title: '', + single_url: '', + buttons: '', + feed_links: '', + }, + + // 垂直排列的多按钮 ActionCard + verticalActionCard: { + webhook_url: 'https://oapi.dingtalk.com/robot/send?access_token=test107', + message_type: 'actionCard', + content: '请选择操作', + title: '操作选择', + at_mobiles: '', + at_all: false, + link_url: '', + pic_url: '', + btn_orientation: '1', + single_title: '', + single_url: '', + buttons: JSON.stringify([ + { title: '同意', actionURL: 'https://example.com/approve' }, + { title: '拒绝', actionURL: 'https://example.com/reject' }, + { title: '查看详情', actionURL: 'https://example.com/detail' }, + ]), + feed_links: '', + }, + + // 多链接 FeedCard + multiFeedCard: { + webhook_url: 'https://oapi.dingtalk.com/robot/send?access_token=test108', + message_type: 'feedCard', + content: '', + title: '', + at_mobiles: '', + at_all: false, + link_url: '', + pic_url: '', + btn_orientation: '0', + single_title: '', + single_url: '', + buttons: '', + feed_links: JSON.stringify([ + { + title: '技术文档', + messageURL: 'https://docs.example.com', + picURL: 'https://example.com/doc.jpg', + }, + { + title: 'API 参考', + messageURL: 'https://api.example.com', + picURL: 'https://example.com/api.jpg', + }, + { + title: '示例代码', + messageURL: 'https://github.com/example', + picURL: 'https://example.com/code.jpg', + }, + ]), + }, +}; + +// 环境变量格式的配置(用于测试 config.js) +export const envConfigs = { + basicText: { + INPUT_WEBHOOK_URL: 'https://oapi.dingtalk.com/robot/send?access_token=test123', + INPUT_MESSAGE_TYPE: 'text', + INPUT_CONTENT: 'Hello, World!', + INPUT_TITLE: '', + INPUT_AT_MOBILES: '', + INPUT_AT_ALL: 'false', + INPUT_LINK_URL: '', + INPUT_PIC_URL: '', + INPUT_BTN_ORIENTATION: '0', + INPUT_SINGLE_TITLE: '', + INPUT_SINGLE_URL: '', + INPUT_BUTTONS: '', + INPUT_FEED_LINKS: '', + }, + + markdown: { + INPUT_WEBHOOK_URL: 'https://oapi.dingtalk.com/robot/send?access_token=test456', + INPUT_MESSAGE_TYPE: 'markdown', + INPUT_CONTENT: '## 标题\n这是一个 **Markdown** 消息', + INPUT_TITLE: 'Markdown 标题', + INPUT_AT_MOBILES: '', + INPUT_AT_ALL: 'false', + INPUT_LINK_URL: '', + INPUT_PIC_URL: '', + INPUT_BTN_ORIENTATION: '0', + INPUT_SINGLE_TITLE: '', + INPUT_SINGLE_URL: '', + INPUT_BUTTONS: '', + INPUT_FEED_LINKS: '', + }, + + textWithAtAll: { + INPUT_WEBHOOK_URL: 'https://oapi.dingtalk.com/robot/send?access_token=test105', + INPUT_MESSAGE_TYPE: 'text', + INPUT_CONTENT: '这是一个 @ 所有人的消息', + INPUT_TITLE: '', + INPUT_AT_MOBILES: '', + INPUT_AT_ALL: 'true', + INPUT_LINK_URL: '', + INPUT_PIC_URL: '', + INPUT_BTN_ORIENTATION: '0', + INPUT_SINGLE_TITLE: '', + INPUT_SINGLE_URL: '', + INPUT_BUTTONS: '', + INPUT_FEED_LINKS: '', + }, +}; diff --git a/tests/fixtures/responses/dingtalk-responses.js b/tests/fixtures/responses/dingtalk-responses.js new file mode 100644 index 0000000..7f9f162 --- /dev/null +++ b/tests/fixtures/responses/dingtalk-responses.js @@ -0,0 +1,366 @@ +/** + * 钉钉 API 响应数据夹具 + * 包含各种钉钉 API 响应用于测试 + */ + +// 成功响应 +export const successResponses = { + // 标准成功响应 + standard: { + errcode: 0, + errmsg: 'ok', + }, + + // 带消息 ID 的成功响应 + withMessageId: { + errcode: 0, + errmsg: 'ok', + message_id: 'msg789012', + }, +}; + +// 错误响应 +export const errorResponses = { + // 无效的 access_token + invalidToken: { + errcode: 310000, + errmsg: 'keywords not in content', + }, + + // 消息内容中不包含任何关键词 + noKeywords: { + errcode: 310000, + errmsg: 'keywords not in content', + }, + + // 消息发送频率超过限制 + rateLimited: { + errcode: 130101, + errmsg: 'send too fast', + }, + + // 机器人被停用 + robotDisabled: { + errcode: 300001, + errmsg: 'robot is disabled', + }, + + // 签名验证失败 + signatureError: { + errcode: 310000, + errmsg: 'sign not match', + }, + + // IP 地址不在白名单中 + ipNotAllowed: { + errcode: 310000, + errmsg: 'ip not allow', + }, + + // 消息格式错误 + invalidFormat: { + errcode: 300002, + errmsg: 'param is invalid', + }, + + // 消息内容为空 + emptyContent: { + errcode: 300003, + errmsg: 'content is empty', + }, + + // 消息长度超过限制 + contentTooLong: { + errcode: 300004, + errmsg: 'content too long', + }, + + // @ 的手机号格式错误 + invalidMobile: { + errcode: 300005, + errmsg: 'mobile format error', + }, + + // ActionCard 按钮数量超过限制 + tooManyButtons: { + errcode: 300006, + errmsg: 'too many buttons', + }, + + // FeedCard 链接数量超过限制 + tooManyLinks: { + errcode: 300007, + errmsg: 'too many links', + }, + + // 服务器内部错误 + internalError: { + errcode: 500000, + errmsg: 'internal server error', + }, + + // 服务不可用 + serviceUnavailable: { + errcode: 503000, + errmsg: 'service unavailable', + }, +}; + +// HTTP 状态码响应 +export const httpStatusResponses = { + // 200 OK + ok: { + statusCode: 200, + data: successResponses.standard, + }, + + // 400 Bad Request + badRequest: { + statusCode: 400, + data: { + errcode: 400000, + errmsg: 'bad request', + }, + }, + + // 401 Unauthorized + unauthorized: { + statusCode: 401, + data: { + errcode: 401000, + errmsg: 'unauthorized', + }, + }, + + // 403 Forbidden + forbidden: { + statusCode: 403, + data: { + errcode: 403000, + errmsg: 'forbidden', + }, + }, + + // 404 Not Found + notFound: { + statusCode: 404, + data: { + errcode: 404000, + errmsg: 'not found', + }, + }, + + // 429 Too Many Requests + tooManyRequests: { + statusCode: 429, + data: errorResponses.rateLimited, + }, + + // 500 Internal Server Error + internalServerError: { + statusCode: 500, + data: errorResponses.internalError, + }, + + // 502 Bad Gateway + badGateway: { + statusCode: 502, + data: { + errcode: 502000, + errmsg: 'bad gateway', + }, + }, + + // 503 Service Unavailable + serviceUnavailable: { + statusCode: 503, + data: errorResponses.serviceUnavailable, + }, + + // 504 Gateway Timeout + gatewayTimeout: { + statusCode: 504, + data: { + errcode: 504000, + errmsg: 'gateway timeout', + }, + }, +}; + +// 网络错误响应 +export const networkErrors = { + // 连接被拒绝 + connectionRefused: { + code: 'ECONNREFUSED', + message: 'connect ECONNREFUSED 127.0.0.1:443', + }, + + // 连接超时 + connectionTimeout: { + code: 'ETIMEDOUT', + message: 'connect ETIMEDOUT', + }, + + // DNS 解析失败 + dnsError: { + code: 'ENOTFOUND', + message: 'getaddrinfo ENOTFOUND oapi.dingtalk.com', + }, + + // 网络不可达 + networkUnreachable: { + code: 'ENETUNREACH', + message: 'network is unreachable', + }, + + // 连接被重置 + connectionReset: { + code: 'ECONNRESET', + message: 'socket hang up', + }, + + // SSL/TLS 错误 + sslError: { + code: 'DEPTH_ZERO_SELF_SIGNED_CERT', + message: 'self signed certificate', + }, +}; + +// 特殊响应场景 +export const specialResponses = { + // 空响应体 + emptyBody: { + statusCode: 200, + data: '', + }, + + // 非 JSON 响应 + nonJsonResponse: { + statusCode: 200, + data: 'This is not JSON', + }, + + // 格式错误的 JSON + malformedJson: { + statusCode: 200, + data: '{"errcode": 0, "errmsg": "ok"', + }, + + // 缺少必需字段的响应 + missingFields: { + statusCode: 200, + data: { + errcode: 0, + // 缺少 errmsg + }, + }, + + // 字段类型错误的响应 + wrongFieldTypes: { + statusCode: 200, + data: { + errcode: '0', // 应该是数字 + errmsg: 123, // 应该是字符串 + }, + }, +}; + +// 响应时间模拟 +export const responseTimings = { + // 快速响应 (< 100ms) + fast: 50, + + // 正常响应 (100-500ms) + normal: 200, + + // 慢响应 (500-2000ms) + slow: 1000, + + // 非常慢的响应 (> 2000ms) + verySlow: 3000, + + // 超时响应 (> 5000ms) + timeout: 6000, +}; + +// 批量响应场景 +export const batchResponses = { + // 全部成功 + allSuccess: [ + successResponses.standard, + successResponses.withMessageId, + successResponses.standard, + ], + + // 部分失败 + partialFailure: [ + successResponses.standard, + errorResponses.rateLimited, + successResponses.standard, + ], + + // 全部失败 + allFailure: [ + errorResponses.invalidToken, + errorResponses.rateLimited, + errorResponses.robotDisabled, + ], +}; + +// 用于测试的完整响应对象 +export const fullResponses = { + textSuccess: { + request: { + msgtype: 'text', + text: { + content: 'Hello, World!', + }, + at: { + atMobiles: [], + atUserIds: [], + isAtAll: false, + }, + }, + response: successResponses.standard, + }, + + markdownSuccess: { + request: { + msgtype: 'markdown', + markdown: { + title: 'Markdown 标题', + text: '## 标题\n这是一个 **Markdown** 消息', + }, + at: { + atMobiles: [], + atUserIds: [], + isAtAll: false, + }, + }, + response: successResponses.withMessageId, + }, + + actionCardError: { + request: { + msgtype: 'actionCard', + actionCard: { + title: 'ActionCard 标题', + text: 'ActionCard 内容', + singleTitle: '查看详情', + singleURL: 'https://example.com', + }, + }, + response: errorResponses.noKeywords, + }, +}; + +export const dingTalkResponses = { + success: successResponses, + error: errorResponses, + httpStatus: httpStatusResponses, + network: networkErrors, + special: specialResponses, + timing: responseTimings, + batch: batchResponses, + full: fullResponses, +}; diff --git a/tests/helpers/assertions.js b/tests/helpers/assertions.js new file mode 100644 index 0000000..d5d8eee --- /dev/null +++ b/tests/helpers/assertions.js @@ -0,0 +1,243 @@ +/** + * 自定义断言工具 + * 提供项目特定的断言方法 + */ + +import { expect } from 'vitest'; + +/** + * 验证钉钉消息格式 + * + * @param {object} message - 消息对象 + * @param {string} expectedType - 期望的消息类型 + */ +export function assertDingTalkMessage(message, expectedType) { + expect(message).toBeDefined(); + expect(message).toBeTypeOf('object'); + expect(message.msgtype).toBe(expectedType); + + switch (expectedType) { + case 'text': + expect(message.text).toBeDefined(); + expect(message.text.content).toBeTypeOf('string'); + break; + case 'markdown': + expect(message.markdown).toBeDefined(); + expect(message.markdown.title).toBeTypeOf('string'); + expect(message.markdown.text).toBeTypeOf('string'); + break; + case 'link': + expect(message.link).toBeDefined(); + expect(message.link.title).toBeTypeOf('string'); + expect(message.link.text).toBeTypeOf('string'); + expect(message.link.messageUrl).toBeTypeOf('string'); + break; + case 'actionCard': + expect(message.actionCard).toBeDefined(); + expect(message.actionCard.title).toBeTypeOf('string'); + expect(message.actionCard.text).toBeTypeOf('string'); + break; + case 'feedCard': + expect(message.feedCard).toBeDefined(); + expect(message.feedCard.links).toBeInstanceOf(Array); + break; + } +} + +/** + * 验证 @ 功能配置 + * + * @param {object} message - 消息对象 + * @param {object} expectedAt - 期望的 @ 配置 + */ +export function assertAtConfiguration(message, expectedAt) { + expect(message.at).toBeDefined(); + + if (expectedAt.atMobiles) { + expect(message.at.atMobiles).toEqual(expectedAt.atMobiles); + } + + if (expectedAt.atUserIds) { + expect(message.at.atUserIds).toEqual(expectedAt.atUserIds); + } + + if (expectedAt.isAtAll !== undefined) { + expect(message.at.isAtAll).toBe(expectedAt.isAtAll); + } +} + +/** + * 验证 ActionCard 按钮配置 + * + * @param {object} actionCard - ActionCard 对象 + * @param {object} expectedButtons - 期望的按钮配置 + */ +export function assertActionCardButtons(actionCard, expectedButtons) { + if (expectedButtons.single) { + expect(actionCard.singleTitle).toBe(expectedButtons.single.title); + expect(actionCard.singleURL).toBe(expectedButtons.single.url); + expect(actionCard.btns).toBeUndefined(); + } else if (expectedButtons.multiple) { + expect(actionCard.btns).toBeInstanceOf(Array); + expect(actionCard.btns).toHaveLength(expectedButtons.multiple.length); + + expectedButtons.multiple.forEach((expectedBtn, index) => { + expect(actionCard.btns[index].title).toBe(expectedBtn.title); + expect(actionCard.btns[index].actionURL).toBe(expectedBtn.actionURL); + }); + + expect(actionCard.singleTitle).toBeUndefined(); + expect(actionCard.singleURL).toBeUndefined(); + } + + if (expectedButtons.orientation !== undefined) { + expect(actionCard.btnOrientation).toBe(expectedButtons.orientation); + } +} + +/** + * 验证 FeedCard 链接配置 + * + * @param {object} feedCard - FeedCard 对象 + * @param {Array} expectedLinks - 期望的链接配置 + */ +export function assertFeedCardLinks(feedCard, expectedLinks) { + expect(feedCard.links).toBeInstanceOf(Array); + expect(feedCard.links).toHaveLength(expectedLinks.length); + + expectedLinks.forEach((expectedLink, index) => { + const link = feedCard.links[index]; + expect(link.title).toBe(expectedLink.title); + expect(link.messageURL).toBe(expectedLink.messageURL); + + if (expectedLink.picURL) { + expect(link.picURL).toBe(expectedLink.picURL); + } + }); +} + +/** + * 验证配置对象结构 + * + * @param {object} config - 配置对象 + * @param {Array} requiredFields - 必需字段列表 + */ +export function assertConfigStructure(config, requiredFields = []) { + expect(config).toBeDefined(); + expect(config).toBeTypeOf('object'); + + requiredFields.forEach(field => { + expect(config).toHaveProperty(field); + }); +} + +/** + * 验证错误对象 + * + * @param {Error} error - 错误对象 + * @param {string} expectedMessage - 期望的错误消息(可选) + * @param {string} expectedCode - 期望的错误代码(可选) + */ +export function assertError(error, expectedMessage, expectedCode) { + expect(error).toBeInstanceOf(Error); + + if (expectedMessage) { + expect(error.message).toContain(expectedMessage); + } + + if (expectedCode) { + expect(error.code).toBe(expectedCode); + } +} + +/** + * 验证 HTTP 请求选项 + * + * @param {object} options - 请求选项 + * @param {object} expected - 期望的选项 + */ +export function assertHttpOptions(options, expected) { + expect(options).toBeDefined(); + expect(options).toBeTypeOf('object'); + + if (expected.hostname) { + expect(options.hostname).toBe(expected.hostname); + } + + if (expected.path) { + expect(options.path).toBe(expected.path); + } + + if (expected.method) { + expect(options.method).toBe(expected.method); + } + + if (expected.headers) { + expect(options.headers).toEqual(expect.objectContaining(expected.headers)); + } +} + +/** + * 验证 URL 格式 + * + * @param {string} url - URL 字符串 + */ +export function assertValidUrl(url) { + expect(url).toBeTypeOf('string'); + expect(url.length).toBeGreaterThan(0); + expect(() => new URL(url)).not.toThrow(); +} + +/** + * 验证 JSON 字符串 + * + * @param {string} jsonString - JSON 字符串 + * @returns {object} 解析后的对象 + */ +export function assertValidJson(jsonString) { + expect(jsonString).toBeTypeOf('string'); + + let parsed; + expect(() => { + parsed = JSON.parse(jsonString); + }).not.toThrow(); + + return parsed; +} + +/** + * 验证数组包含特定元素 + * + * @param {Array} array - 数组 + * @param {*} element - 要查找的元素 + */ +export function assertArrayContains(array, element) { + expect(array).toBeInstanceOf(Array); + expect(array).toContain(element); +} + +/** + * 验证对象深度相等 + * + * @param {object} actual - 实际对象 + * @param {object} expected - 期望对象 + */ +export function assertDeepEqual(actual, expected) { + expect(actual).toEqual(expected); +} + +/** + * 验证函数抛出特定错误 + * + * @param {Function} fn - 要测试的函数 + * @param {string|RegExp} expectedError - 期望的错误消息或正则表达式 + */ +export function assertThrows(fn, expectedError) { + if (typeof expectedError === 'string') { + expect(fn).toThrow(expectedError); + } else if (expectedError instanceof RegExp) { + expect(fn).toThrow(expectedError); + } else { + expect(fn).toThrow(); + } +} diff --git a/tests/helpers/env-mock.js b/tests/helpers/env-mock.js new file mode 100644 index 0000000..ca68de0 --- /dev/null +++ b/tests/helpers/env-mock.js @@ -0,0 +1,111 @@ +/** + * 环境变量 Mock 工具 + * 用于在测试中模拟和管理环境变量 + */ + +/** + * Mock 环境变量 + * + * @param {object} envVars - 要设置的环境变量 + * @returns {Function} 恢复函数 + */ +export function mockEnvVars(envVars) { + const originalEnv = {}; + + // 保存原始值 + for (const key in envVars) { + originalEnv[key] = process.env[key]; + process.env[key] = envVars[key]; + } + + // 返回恢复函数 + return () => { + for (const key in envVars) { + if (originalEnv[key] === undefined) { + delete process.env[key]; + } else { + process.env[key] = originalEnv[key]; + } + } + }; +} + +/** + * 创建标准的 Action 环境变量 + * + * @param {object} overrides - 覆盖的环境变量 + * @returns {object} 环境变量对象 + */ +export function createActionEnv(overrides = {}) { + return { + INPUT_WEBHOOK_URL: 'https://oapi.dingtalk.com/robot/send?access_token=test123', + INPUT_MESSAGE_TYPE: 'text', + INPUT_CONTENT: 'Test message', + INPUT_TITLE: '', + INPUT_AT_MOBILES: '', + INPUT_AT_ALL: 'false', + INPUT_LINK_URL: '', + INPUT_PIC_URL: '', + INPUT_BTN_ORIENTATION: '0', + INPUT_SINGLE_TITLE: '', + INPUT_SINGLE_URL: '', + INPUT_BUTTONS: '', + INPUT_FEED_LINKS: '', + ...overrides, + }; +} + +/** + * 清理所有 INPUT_ 开头的环境变量 + * + * @returns {Function} 恢复函数 + */ +export function clearActionEnv() { + const originalEnv = {}; + const inputKeys = Object.keys(process.env).filter(key => key.startsWith('INPUT_')); + + inputKeys.forEach(key => { + originalEnv[key] = process.env[key]; + delete process.env[key]; + }); + + return () => { + Object.assign(process.env, originalEnv); + }; +} + +/** + * 创建测试用的环境变量快照 + * + * @returns {object} 当前环境变量的副本 + */ +export function createEnvSnapshot() { + return { ...process.env }; +} + +/** + * 恢复环境变量到指定快照 + * + * @param {object} snapshot - 环境变量快照 + */ +export function restoreEnvSnapshot(snapshot) { + // 清除当前环境变量 + for (const key in process.env) { + if (!(key in snapshot)) { + delete process.env[key]; + } + } + + // 恢复快照中的环境变量 + Object.assign(process.env, snapshot); +} + +/** + * 创建环境变量 Mock(别名函数) + * + * @param {object} envVars - 要设置的环境变量 + * @returns {Function} 恢复函数 + */ +export function createEnvMock(envVars) { + return mockEnvVars(envVars); +} diff --git a/tests/helpers/http-mock.js b/tests/helpers/http-mock.js new file mode 100644 index 0000000..dbb366b --- /dev/null +++ b/tests/helpers/http-mock.js @@ -0,0 +1,267 @@ +/** + * HTTP Mock 工具 + * 用于在测试中模拟 HTTP 请求和响应 + */ + +import { EventEmitter } from 'events'; +import { vi } from 'vitest'; + +/** + * Mock HTTP 响应 + */ +export class MockResponse extends EventEmitter { + constructor(statusCode = 200, data = {}) { + super(); + this.statusCode = statusCode; + this.data = data; + this.headers = {}; + } + + emit(event, ...args) { + // 异步触发事件,模拟真实网络延迟 + setImmediate(() => super.emit(event, ...args)); + } + + simulateResponse() { + // 如果 data 已经是字符串,直接使用;否则进行 JSON 序列化 + const responseData = typeof this.data === 'string' ? this.data : JSON.stringify(this.data); + this.emit('data', responseData); + this.emit('end'); + } + + simulateError(error) { + this.emit('error', error); + } + + simulateTimeout() { + this.emit('timeout'); + } + + setHeader(name, value) { + this.headers[name] = value; + } + + getHeader(name) { + return this.headers[name]; + } +} + +/** + * Mock HTTP 请求 + */ +export class MockRequest extends EventEmitter { + constructor() { + super(); + this.written = []; + this.ended = false; + this.destroyed = false; + this.headers = {}; + } + + write(data) { + this.written.push(data); + return true; + } + + end(data) { + if (data) { + this.write(data); + } + this.ended = true; + this.emit('end'); + } + + destroy() { + this.destroyed = true; + this.emit('close'); + } + + setTimeout(timeout, callback) { + setTimeout(() => { + if (!this.ended && !this.destroyed) { + this.emit('timeout'); + if (callback) callback(); + } + }, timeout); + } + + setHeader(name, value) { + this.headers[name] = value; + } + + getHeader(name) { + return this.headers[name]; + } + + getWrittenData() { + return this.written.join(''); + } +} + +/** + * 创建模拟的 HTTPS 模块用于测试 + * + * @typedef {object} HttpsMockModule + * @property {import('vitest').Mock} request - 模拟的 HTTPS 请求方法 + * @property {function(): Array} getRequests - 获取所有已发送的请求列表 + * @property {function(number, any): void} addResponse - 添加预定义的响应 + * @property {function(Error): void} addErrorResponse - 添加错误响应 + * @property {function(): void} addTimeoutResponse - 添加超时响应 + * @property {function(any): void} mockRequestOnce - 为单次请求设置响应 + * @property {function(): void} reset - 重置所有请求和响应状态 + * @property {function(): object} getLastRequest - 获取最后一次请求的详细信息 + * @property {function(): number} getRequestCount - 获取已发送请求的数量 + * + * @returns {HttpsMockModule} 模拟的 HTTPS 模块对象 + */ +export function createHttpsMock() { + const requests = []; + const responses = []; + + const mockHttps = { + request: vi.fn((options, callback) => { + const req = new MockRequest(); + const res = responses.shift() || new MockResponse(); + + requests.push({ options, req, res }); + + // 异步调用回调 + setImmediate(() => { + if (callback) callback(res); + res.simulateResponse(); + }); + + return req; + }), + + // 测试辅助方法 + getRequests: () => requests, + addResponse: (statusCode, data) => { + responses.push(new MockResponse(statusCode, data)); + }, + addErrorResponse: error => { + const res = new MockResponse(); + responses.push(res); + setImmediate(() => res.simulateError(error)); + }, + addTimeoutResponse: () => { + const res = new MockResponse(); + responses.push(res); + setImmediate(() => res.simulateTimeout()); + }, + + // 添加单次请求响应 + mockRequestOnce: responseData => { + if (typeof responseData === 'object' && responseData.statusCode !== undefined) { + // 如果传入的是包含 statusCode 的对象 + responses.push( + new MockResponse(responseData.statusCode, responseData.data || responseData), + ); + } else { + // 如果传入的是普通响应数据,默认使用 200 状态码 + responses.push(new MockResponse(200, responseData)); + } + }, + + reset: () => { + requests.length = 0; + responses.length = 0; + mockHttps.request.mockClear(); + }, + + // 获取最后一次请求的详细信息 + getLastRequest: () => { + return requests[requests.length - 1]; + }, + + // 获取请求数量 + getRequestCount: () => requests.length, + }; + + return mockHttps; +} + +/** + * 创建简单的 HTTP Mock + * + * @param {number|object} statusCodeOrResponse - 状态码或包含 statusCode 和 data 的响应对象 + * @param {object} data - 响应数据(当第一个参数是状态码时使用) + * @returns {object} Mock 对象 + */ +export function createSimpleHttpMock(statusCodeOrResponse = 200, data = {}) { + const mockHttps = createHttpsMock(); + + if (typeof statusCodeOrResponse === 'object' && statusCodeOrResponse !== null) { + // 如果传入的是响应对象,检查是否有 statusCode 和 data 属性 + if (statusCodeOrResponse.statusCode && statusCodeOrResponse.data) { + mockHttps.addResponse(statusCodeOrResponse.statusCode, statusCodeOrResponse.data); + } else { + // 如果只是数据对象,默认使用 200 状态码 + mockHttps.addResponse(200, statusCodeOrResponse); + } + } else { + // 传统的调用方式:状态码和数据分开传递 + mockHttps.addResponse(statusCodeOrResponse, data); + } + + return mockHttps; +} + +/** + * 创建网络错误 Mock + * + * @param {string} errorCode - 错误代码 + * @returns {object} Mock 对象 + */ +export function createNetworkErrorMock(errorCode = 'ECONNREFUSED') { + const mockHttps = createHttpsMock(); + const error = new Error(`Network error: ${errorCode}`); + error.code = errorCode; + mockHttps.addErrorResponse(error); + return mockHttps; +} + +/** + * 创建超时 Mock + * + * @returns {object} Mock 对象 + */ +export function createTimeoutMock() { + const mockHttps = createHttpsMock(); + mockHttps.addTimeoutResponse(); + return mockHttps; +} + +/** + * 验证请求选项 + * + * @param {object} request - 请求对象 + * @param {object} expectedOptions - 期望的选项 + */ +export function assertRequestOptions(request, expectedOptions) { + const { options } = request; + + for (const [key, value] of Object.entries(expectedOptions)) { + if (options[key] !== value) { + throw new Error(`Expected ${key} to be ${value}, but got ${options[key]}`); + } + } +} + +/** + * 验证请求体 + * + * @param {object} request - 请求对象 + * @param {object} expectedBody - 期望的请求体 + */ +export function assertRequestBody(request, expectedBody) { + const actualBody = JSON.parse(request.req.getWrittenData()); + + for (const [key, value] of Object.entries(expectedBody)) { + if (JSON.stringify(actualBody[key]) !== JSON.stringify(value)) { + throw new Error( + `Expected ${key} to be ${JSON.stringify(value)}, but got ${JSON.stringify(actualBody[key])}`, + ); + } + } +} diff --git a/tests/integration/index.test.js b/tests/integration/index.test.js new file mode 100644 index 0000000..cca8927 --- /dev/null +++ b/tests/integration/index.test.js @@ -0,0 +1,495 @@ +/** + * index.js 集成测试 + * 测试主入口文件的完整执行流程 + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { main } from '../../src/index.js'; +import { invalidConfigs } from '../fixtures/configs/invalid-configs.js'; +import { validConfigs } from '../fixtures/configs/valid-configs.js'; +import { dingTalkResponses } from '../fixtures/responses/dingtalk-responses.js'; +import { + clearActionEnv, + createEnvMock, + createEnvSnapshot, + restoreEnvSnapshot, +} from '../helpers/env-mock.js'; + +// Mock fetch API +const mockFetch = vi.fn(); +vi.stubGlobal('fetch', mockFetch); + +describe('index.js 集成测试', () => { + let envSnapshot; + let consoleSpy; + + /** + * 设置 HTTP Mock 的辅助函数 + * + * @param {object} mockResponse - 模拟的 HTTP 响应,格式: { statusCode, data } + */ + async function setupHttpMock(mockResponse) { + const { statusCode = 200, data = {} } = mockResponse; + const responseText = typeof data === 'string' ? data : JSON.stringify(data); + + mockFetch.mockResolvedValueOnce({ + status: statusCode, + text: async () => responseText, + }); + } + + /** + * 设置网络错误 Mock 的辅助函数 + * + * @param {string} errorMessage - 错误消息 + */ + async function setupNetworkErrorMock(errorMessage = 'Network error') { + mockFetch.mockRejectedValueOnce(new Error(errorMessage)); + } + + beforeEach(async () => { + // 保存环境变量快照 + envSnapshot = createEnvSnapshot(); + + // 清理 Action 环境变量 + clearActionEnv(); + + // Mock console 方法 + consoleSpy = { + log: vi.spyOn(console, 'log').mockImplementation(() => {}), + error: vi.spyOn(console, 'error').mockImplementation(() => {}), + warn: vi.spyOn(console, 'warn').mockImplementation(() => {}), + debug: vi.spyOn(console, 'debug').mockImplementation(() => {}), + }; + + // Mock process.exit + vi.spyOn(process, 'exit').mockImplementation(() => {}); + + // Mock Date.now for consistent timing + vi.spyOn(Date, 'now').mockReturnValue(1640995200000); // 2022-01-01 00:00:00 + }); + + afterEach(() => { + // 恢复环境变量 + restoreEnvSnapshot(envSnapshot); + + // 清理 fetch mock + mockFetch.mockClear(); + + // 恢复所有 mocks + vi.restoreAllMocks(); + }); + + describe('成功场景测试', () => { + it('应该成功发送文本消息', async () => { + // 设置环境变量 + createEnvMock(validConfigs.envVars.text); + + // Mock HTTP 请求 + await setupHttpMock({ statusCode: 200, data: dingTalkResponses.success.withMessageId }); + + // 执行主函数 + const result = await main(); + + // 验证结果 + expect(result.success).toBe(true); + expect(result.messageId).toBe('msg789012'); + expect(result.details).toMatchObject({ + duration: expect.any(Number), + statusCode: 200, + messageSummary: expect.stringContaining('text'), + }); + + // 验证日志输出 + expect(consoleSpy.log).toHaveBeenCalledWith( + expect.stringContaining('🚀 DingTalk Gitea Action started'), + ); + expect(consoleSpy.log).toHaveBeenCalledWith( + expect.stringContaining('✅ Message sent successfully!'), + ); + + // 验证 fetch 被调用 + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it('应该成功发送 Markdown 消息', async () => { + // 设置环境变量 + createEnvMock(validConfigs.envVars.markdown); + + // Mock HTTP 请求 + await setupHttpMock({ statusCode: 200, data: dingTalkResponses.success.standard }); + + // 执行主函数 + const result = await main(); + + // 验证结果 + expect(result.success).toBe(true); + expect(result.details.messageSummary).toContain('markdown'); + + // 验证 fetch 被调用 + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it('应该成功发送 Link 消息', async () => { + // 设置环境变量 + createEnvMock(validConfigs.envVars.link); + + // Mock HTTP 请求 + await setupHttpMock({ statusCode: 200, data: dingTalkResponses.success.standard }); + + // 执行主函数 + const result = await main(); + + // 验证结果 + expect(result.success).toBe(true); + expect(result.details.messageSummary).toContain('link'); + + // 验证 fetch 被调用 + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it('应该成功发送 ActionCard 消息', async () => { + // 设置环境变量 + createEnvMock(validConfigs.envVars.actionCard); + + // Mock HTTP 请求 + await setupHttpMock({ statusCode: 200, data: dingTalkResponses.success.standard }); + + // 执行主函数 + const result = await main(); + + // 验证结果 + expect(result.success).toBe(true); + expect(result.details.messageSummary).toContain('actionCard'); + + // 验证 fetch 被调用 + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it('应该成功发送 FeedCard 消息', async () => { + // 设置环境变量 + createEnvMock(validConfigs.envVars.feedCard); + + // Mock HTTP 请求 + await setupHttpMock({ statusCode: 200, data: dingTalkResponses.success.standard }); + + // 执行主函数 + const result = await main(); + + // 验证结果 + expect(result.success).toBe(true); + expect(result.details.messageSummary).toContain('feedCard'); + + // 验证 fetch 被调用 + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it('应该在调试模式下输出详细信息', async () => { + // 设置环境变量(包含调试模式) + createEnvMock({ + ...validConfigs.envVars.text, + DEBUG: 'true', + }); + + // Mock HTTP 请求 + await setupHttpMock({ statusCode: 200, data: dingTalkResponses.success.standard }); + + // 执行主函数 + const result = await main(); + + // 验证结果 + expect(result.success).toBe(true); + + // 验证调试日志 + expect(consoleSpy.log).toHaveBeenCalledWith( + expect.stringContaining('[INFO] Debug mode: true'), + ); + expect(consoleSpy.log).toHaveBeenCalledWith( + expect.stringContaining('[DEBUG] Message payload:'), + ); + expect(consoleSpy.log).toHaveBeenCalledWith( + expect.stringContaining('[DEBUG] Full response:'), + ); + }); + }); + + describe('失败场景测试', () => { + it('应该处理配置解析错误', async () => { + // 设置无效的环境变量 + createEnvMock(invalidConfigs.envVars.missingWebhook); + + // 执行主函数 + const result = await main(); + + // 验证结果 + expect(result.success).toBe(false); + expect(result.error).toContain('Action execution failed'); + expect(result.details.errorType).toBe('Error'); + + // 验证错误日志 + expect(consoleSpy.error).toHaveBeenCalledWith( + expect.stringContaining('Action execution failed'), + ); + }); + + it('应该处理消息验证错误', async () => { + // 设置会导致消息验证失败的环境变量 + createEnvMock({ + INPUT_WEBHOOK_URL: 'https://oapi.dingtalk.com/robot/send?access_token=test', + INPUT_MESSAGE_TYPE: 'text', + INPUT_CONTENT: '', // 空内容会导致验证失败 + }); + + // 执行主函数 + const result = await main(); + + // 验证结果 + expect(result.success).toBe(false); + expect(result.error).toContain('Action execution failed'); + }); + + it('应该处理 DingTalk API 错误', async () => { + // 设置环境变量 + createEnvMock(validConfigs.envVars.text); + + // Mock HTTP 请求返回错误 + await setupHttpMock({ statusCode: 200, data: dingTalkResponses.error.invalidToken }); + + // 执行主函数 + const result = await main(); + + // 验证结果 + expect(result.success).toBe(false); + expect(result.error).toContain('Failed to send message'); + expect(result.details.errcode).toBe(310000); + expect(result.details.errmsg).toBe('keywords not in content'); + + // 验证错误日志 + expect(consoleSpy.error).toHaveBeenCalledWith( + expect.stringContaining('Failed to send message'), + ); + expect(consoleSpy.error).toHaveBeenCalledWith( + expect.stringContaining('DingTalk error code: 310000'), + ); + }); + + it('应该处理网络错误', async () => { + // 设置环境变量 + createEnvMock(validConfigs.envVars.text); + + // Mock 网络错误 + await setupNetworkErrorMock('ECONNREFUSED'); + + // 执行主函数 + const result = await main(); + + // 验证结果 + expect(result.success).toBe(false); + expect(result.details.networkError).toContain('ECONNREFUSED'); + + // 验证错误日志 + expect(consoleSpy.error).toHaveBeenCalledWith(expect.stringContaining('Network error')); + }); + + it('应该处理超时错误', async () => { + // 设置环境变量 + createEnvMock(validConfigs.envVars.text); + + // Mock 超时错误 + await setupNetworkErrorMock('ETIMEDOUT'); + + // 执行主函数 + const result = await main(); + + // 验证结果 + expect(result.success).toBe(false); + expect(result.details.networkError).toContain('ETIMEDOUT'); + }); + }); + + describe('边界条件测试', () => { + it('应该处理响应验证失败但仍然成功的情况', async () => { + // 设置环境变量 + createEnvMock(validConfigs.envVars.text); + + // Mock 返回格式不完整但成功的响应(缺少 errmsg 字段) + await setupHttpMock({ + statusCode: 200, + data: { + errcode: 0, + // 缺少 errmsg 字段,这会触发验证失败 + }, + }); + + // 执行主函数 + const result = await main(); + + // 验证结果 + expect(result.success).toBe(true); + expect(result.messageId).toBeUndefined(); + + // 验证警告日志 + expect(consoleSpy.warn).toHaveBeenCalledWith( + expect.stringContaining('Response validation failed'), + ); + }); + + it('应该处理空的错误消息', async () => { + // 设置环境变量 + createEnvMock(validConfigs.envVars.text); + + // Mock 返回空错误消息的失败响应 + await setupHttpMock({ + statusCode: 400, + data: { + errcode: 300001, + errmsg: '', + }, + }); + + // 执行主函数 + const result = await main(); + + // 验证结果 + expect(result.success).toBe(false); + expect(result.error).toContain('Failed to send message'); + }); + + it('应该正确计算执行时间', async () => { + // 设置环境变量 + createEnvMock(validConfigs.envVars.text); + + // Mock HTTP 请求 + await setupHttpMock({ statusCode: 200, data: dingTalkResponses.success.standard }); + + // Mock Date.now 返回递增的时间 + let callCount = 0; + vi.spyOn(Date, 'now').mockImplementation(() => { + callCount++; + return 1640995200000 + callCount * 1000; // 每次调用增加1秒 + }); + + // 执行主函数 + const result = await main(); + + // 验证结果 + expect(result.success).toBe(true); + expect(result.details.duration).toBeGreaterThan(0); + + // 验证执行时间日志 + expect(consoleSpy.log).toHaveBeenCalledWith(expect.stringContaining('Total execution time:')); + }); + }); + + describe('Action 输出测试', () => { + it('应该在成功时设置正确的 Action 输出', async () => { + // 设置环境变量 + createEnvMock(validConfigs.envVars.text); + + // Mock HTTP 请求 + await setupHttpMock({ statusCode: 200, data: dingTalkResponses.success.withMessageId }); + + // Mock GitHub Actions 输出 + const outputSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + // 执行主函数 + const result = await main(); + + // 验证结果 + expect(result.success).toBe(true); + + // 验证 Action 输出 + expect(outputSpy).toHaveBeenCalledWith('::set-output name=success::true'); + expect(outputSpy).toHaveBeenCalledWith('::set-output name=message_id::msg789012'); + expect(outputSpy).toHaveBeenCalledWith('::set-output name=error_message::'); + }); + + it('应该在失败时设置正确的 Action 输出', async () => { + // 设置环境变量 + createEnvMock(validConfigs.envVars.text); + + // Mock HTTP 请求返回错误 + await setupHttpMock({ statusCode: 200, data: dingTalkResponses.error.invalidToken }); + + // Mock GitHub Actions 输出 + const outputSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + // 执行主函数 + const result = await main(); + + // 验证结果 + expect(result.success).toBe(false); + + // 验证 Action 输出 + expect(outputSpy).toHaveBeenCalledWith('::set-output name=success::false'); + expect(outputSpy).toHaveBeenCalledWith('::set-output name=message_id::'); + expect(outputSpy).toHaveBeenCalledWith( + expect.stringMatching(/::set-output name=error_message::Failed to send message/), + ); + }); + + it('应该在异常时设置正确的 Action 输出', async () => { + // 设置无效的环境变量 + createEnvMock(invalidConfigs.envVars.missingWebhook); + + // Mock GitHub Actions 输出 (使用 console.log 而不是 process.stdout.write) + const outputSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + // 执行主函数 + const result = await main(); + + // 验证结果 + expect(result.success).toBe(false); + + // 验证 Action 输出 + expect(outputSpy).toHaveBeenCalledWith('::set-output name=success::false'); + expect(outputSpy).toHaveBeenCalledWith('::set-output name=message_id::'); + expect(outputSpy).toHaveBeenCalledWith( + expect.stringMatching(/::set-output name=error_message::Action execution failed/), + ); + }); + }); + + describe('性能测试', () => { + it('应该在合理时间内完成执行', async () => { + // 设置环境变量 + createEnvMock(validConfigs.envVars.text); + + // Mock HTTP 请求 + await setupHttpMock({ statusCode: 200, data: dingTalkResponses.success.standard }); + + // 记录开始时间 + const startTime = Date.now(); + + // 执行主函数 + const result = await main(); + + // 计算执行时间 + const executionTime = Date.now() - startTime; + + // 验证结果 + expect(result.success).toBe(true); + expect(executionTime).toBeLessThan(5000); // 应该在5秒内完成 + }); + + it('应该正确处理大量日志输出', async () => { + // 设置环境变量(启用调试模式) + createEnvMock({ + ...validConfigs.envVars.text, + DEBUG: 'true', + }); + + // Mock HTTP 请求 + await setupHttpMock({ statusCode: 200, data: dingTalkResponses.success.standard }); + + // 执行主函数 + const result = await main(); + + // 验证结果 + expect(result.success).toBe(true); + + // 验证日志调用次数 + expect(consoleSpy.log.mock.calls.length).toBeGreaterThan(10); + }); + }); +}); diff --git a/tests/setup/global-setup.js b/tests/setup/global-setup.js new file mode 100644 index 0000000..e3d39ca --- /dev/null +++ b/tests/setup/global-setup.js @@ -0,0 +1,44 @@ +/** + * 全局测试设置文件 + * 在所有测试运行前执行的初始化代码 + */ + +import { afterAll, afterEach, beforeAll, beforeEach } from 'vitest'; + +// 全局变量存储原始环境变量 +let originalEnv = {}; + +beforeAll(() => { + // 保存原始环境变量 + originalEnv = { ...process.env }; + + // 设置测试环境标识 + process.env.NODE_ENV = 'test'; + + // 禁用网络请求(可选) + process.env.DISABLE_NETWORK = 'true'; +}); + +afterAll(() => { + // 恢复原始环境变量 + process.env = originalEnv; +}); + +beforeEach(() => { + // 每个测试前的清理工作 + // 清理可能的环境变量污染 +}); + +afterEach(() => { + // 每个测试后的清理工作 + // 重置模块状态等 +}); + +// 全局错误处理 +process.on('unhandledRejection', (reason, promise) => { + console.error('Unhandled Rejection at:', promise, 'reason:', reason); +}); + +process.on('uncaughtException', error => { + console.error('Uncaught Exception:', error); +}); diff --git a/tests/unit/config.test.js b/tests/unit/config.test.js new file mode 100644 index 0000000..84721b2 --- /dev/null +++ b/tests/unit/config.test.js @@ -0,0 +1,592 @@ +/** + * config.js 模块单元测试 + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + MESSAGE_TYPES, + logConfig, + parseConfig, + validateMessageType, + validateWebhookUrl, +} from '../../src/config.js'; +import { envConfigs } from '../fixtures/configs/valid-configs.js'; +import { assertConfigStructure } from '../helpers/assertions.js'; +import { mockEnvVars } from '../helpers/env-mock.js'; + +describe('测试 config.js 文件', () => { + let restoreEnv; + let consoleSpy; + + beforeEach(() => { + // Mock console methods + consoleSpy = { + log: vi.spyOn(console, 'log').mockImplementation(() => {}), + }; + }); + + afterEach(() => { + // Restore console methods + Object.values(consoleSpy).forEach(spy => spy.mockRestore()); + + // Restore environment variables + if (restoreEnv) { + restoreEnv(); + restoreEnv = null; + } + }); + + describe('测试 MESSAGE_TYPES 常量', () => { + it('应该导出正确的消息类型', () => { + expect(MESSAGE_TYPES).toEqual({ + TEXT: 'text', + MARKDOWN: 'markdown', + LINK: 'link', + ACTION_CARD: 'actionCard', + FEED_CARD: 'feedCard', + }); + }); + + it('应该是只读枚举', () => { + expect(() => { + MESSAGE_TYPES.NEW_TYPE = 'newType'; + }).not.toThrow(); // JavaScript doesn't prevent this, but it's documented as readonly + }); + }); + + describe('测试 validateWebhookUrl 函数', () => { + it('应该验证正确的钉钉 Webhook URL', () => { + const validUrls = [ + 'https://oapi.dingtalk.com/robot/send?access_token=abc123', + 'https://oapi.dingtalk.com/robot/send?access_token=test-token-123', + 'https://oapi.dingtalk.com/robot/send?access_token=1234567890abcdef', + ]; + + validUrls.forEach(url => { + expect(validateWebhookUrl(url)).toBe(true); + }); + }); + + it('应该拒绝无效的 Webhook URL', () => { + const invalidUrls = [ + '', + null, + undefined, + 'not-a-url', + 'http://oapi.dingtalk.com/robot/send?access_token=abc123', // http instead of https + 'https://wrong-domain.com/robot/send?access_token=abc123', + 'https://oapi.dingtalk.com/wrong/path?access_token=abc123', + 'https://oapi.dingtalk.com/robot/send', // missing access_token + 'https://oapi.dingtalk.com/robot/send?access_token=', // empty token + 'https://oapi.dingtalk.com/robot/send?wrong_param=abc123', + ]; + + invalidUrls.forEach(url => { + expect(validateWebhookUrl(url)).toBe(false); + }); + }); + + it('应该处理非字符串输入', () => { + expect(validateWebhookUrl(123)).toBe(false); + expect(validateWebhookUrl({})).toBe(false); + expect(validateWebhookUrl([])).toBe(false); + }); + }); + + describe('测试 validateMessageType 函数', () => { + it('应该验证支持的消息类型', () => { + Object.values(MESSAGE_TYPES).forEach(type => { + expect(validateMessageType(type)).toBe(true); + }); + }); + + it('应该拒绝不支持的消息类型', () => { + const invalidTypes = [ + 'unsupported', + 'TEXT', // wrong case + 'MARKDOWN', // wrong case + '', + null, + undefined, + 123, + ]; + + invalidTypes.forEach(type => { + expect(validateMessageType(type)).toBe(false); + }); + }); + }); + + describe('测试 parseConfig 函数', () => { + describe('有效配置测试', () => { + it('应该解析基础文本消息配置', async () => { + restoreEnv = mockEnvVars(envConfigs.basicText); + + const config = await parseConfig(); + + assertConfigStructure(config, ['webhookUrl', 'messageType', 'content']); + expect(config.messageType).toBe('text'); + expect(config.content).toBe('Hello, World!'); + expect(config.atAll).toBe(false); + expect(config.atMobiles).toEqual([]); + }); + + it('应该解析 Markdown 消息配置', async () => { + restoreEnv = mockEnvVars(envConfigs.markdown); + + const config = await parseConfig(); + + expect(config.messageType).toBe('markdown'); + expect(config.title).toBe('Markdown 标题'); + expect(config.content).toContain('Markdown'); + }); + + it('应该解析带 @all 的文本消息配置', async () => { + restoreEnv = mockEnvVars(envConfigs.textWithAtAll); + + const config = await parseConfig(); + + expect(config.atAll).toBe(true); + expect(config.messageType).toBe('text'); + }); + + it('应该正确解析手机号码', async () => { + restoreEnv = mockEnvVars({ + ...envConfigs.basicText, + INPUT_AT_MOBILES: '13800138000,13900139000,invalid-mobile,15012345678', + }); + + const config = await parseConfig(); + + expect(config.atMobiles).toEqual(['13800138000', '13900139000', '15012345678']); + }); + + it('应该解析链接消息配置', async () => { + restoreEnv = mockEnvVars({ + INPUT_WEBHOOK_URL: 'https://oapi.dingtalk.com/robot/send?access_token=test123', + INPUT_MESSAGE_TYPE: 'link', + INPUT_CONTENT: '链接描述', + INPUT_TITLE: '链接标题', + INPUT_LINK_URL: 'https://example.com', + INPUT_PIC_URL: 'https://example.com/image.jpg', + }); + + const config = await parseConfig(); + + expect(config.messageType).toBe('link'); + expect(config.title).toBe('链接标题'); + expect(config.linkUrl).toBe('https://example.com'); + expect(config.picUrl).toBe('https://example.com/image.jpg'); + }); + + it('应该解析单按钮 ActionCard 配置', async () => { + restoreEnv = mockEnvVars({ + INPUT_WEBHOOK_URL: 'https://oapi.dingtalk.com/robot/send?access_token=test123', + INPUT_MESSAGE_TYPE: 'actionCard', + INPUT_CONTENT: 'ActionCard 内容', + INPUT_TITLE: 'ActionCard 标题', + INPUT_BTN_ORIENTATION: '0', + INPUT_SINGLE_TITLE: '查看详情', + INPUT_SINGLE_URL: 'https://example.com/detail', + }); + + const config = await parseConfig(); + + expect(config.messageType).toBe('actionCard'); + expect(config.btnOrientation).toBe('0'); + expect(config.singleTitle).toBe('查看详情'); + expect(config.singleURL).toBe('https://example.com/detail'); + expect(config.buttons).toEqual([]); + }); + + it('应该解析多按钮 ActionCard 配置', async () => { + const buttons = JSON.stringify([ + { title: '按钮1', actionURL: 'https://example.com/action1' }, + { title: '按钮2', actionURL: 'https://example.com/action2' }, + ]); + + restoreEnv = mockEnvVars({ + INPUT_WEBHOOK_URL: 'https://oapi.dingtalk.com/robot/send?access_token=test123', + INPUT_MESSAGE_TYPE: 'actionCard', + INPUT_CONTENT: 'ActionCard 内容', + INPUT_TITLE: 'ActionCard 标题', + INPUT_BTN_ORIENTATION: '1', + INPUT_BUTTONS: buttons, + }); + + const config = await parseConfig(); + + expect(config.messageType).toBe('actionCard'); + expect(config.btnOrientation).toBe('1'); + expect(config.buttons).toHaveLength(2); + expect(config.buttons[0]).toEqual({ + title: '按钮1', + actionURL: 'https://example.com/action1', + }); + }); + + it('应该解析 FeedCard 配置', async () => { + const feedLinks = JSON.stringify([ + { + title: '新闻1', + messageURL: 'https://example.com/news1', + picURL: 'https://example.com/pic1.jpg', + }, + { + title: '新闻2', + messageURL: 'https://example.com/news2', + picURL: 'https://example.com/pic2.jpg', + }, + ]); + + restoreEnv = mockEnvVars({ + INPUT_WEBHOOK_URL: 'https://oapi.dingtalk.com/robot/send?access_token=test123', + INPUT_MESSAGE_TYPE: 'feedCard', + INPUT_CONTENT: 'FeedCard content', + INPUT_FEED_LINKS: feedLinks, + }); + + const config = await parseConfig(); + + expect(config.messageType).toBe('feedCard'); + expect(config.feedLinks).toHaveLength(2); + expect(config.feedLinks[0]).toEqual({ + title: '新闻1', + messageURL: 'https://example.com/news1', + picURL: 'https://example.com/pic1.jpg', + }); + }); + }); + + describe('无效配置测试', () => { + it('应该抛出缺少 Webhook URL 的错误', async () => { + restoreEnv = mockEnvVars({ + INPUT_WEBHOOK_URL: '', + INPUT_MESSAGE_TYPE: 'text', + INPUT_CONTENT: 'Hello, World!', + }); + + await expect(parseConfig()).rejects.toThrow('webhook_url is required'); + }); + + it('应该抛出无效 Webhook URL 格式的错误', async () => { + restoreEnv = mockEnvVars({ + INPUT_WEBHOOK_URL: 'not-a-valid-url', + INPUT_MESSAGE_TYPE: 'text', + INPUT_CONTENT: 'Hello, World!', + }); + + await expect(parseConfig()).rejects.toThrow('webhook_url format is invalid'); + }); + + it('应该抛出缺少内容的错误', async () => { + restoreEnv = mockEnvVars({ + INPUT_WEBHOOK_URL: 'https://oapi.dingtalk.com/robot/send?access_token=test123', + INPUT_MESSAGE_TYPE: 'text', + INPUT_CONTENT: '', + }); + + await expect(parseConfig()).rejects.toThrow('content is required'); + }); + + it('应该抛出不支持的消息类型错误', async () => { + restoreEnv = mockEnvVars({ + INPUT_WEBHOOK_URL: 'https://oapi.dingtalk.com/robot/send?access_token=test123', + INPUT_MESSAGE_TYPE: 'unsupported', + INPUT_CONTENT: 'Hello, World!', + }); + + await expect(parseConfig()).rejects.toThrow('message_type must be one of'); + }); + + it('应该抛出 Markdown 消息缺少标题的错误', async () => { + restoreEnv = mockEnvVars({ + INPUT_WEBHOOK_URL: 'https://oapi.dingtalk.com/robot/send?access_token=test123', + INPUT_MESSAGE_TYPE: 'markdown', + INPUT_CONTENT: '## Content', + INPUT_TITLE: '', + }); + + await expect(parseConfig()).rejects.toThrow('title is required for markdown message type'); + }); + + it('应该抛出 Link 消息缺少标题的错误', async () => { + restoreEnv = mockEnvVars({ + INPUT_WEBHOOK_URL: 'https://oapi.dingtalk.com/robot/send?access_token=test123', + INPUT_MESSAGE_TYPE: 'link', + INPUT_CONTENT: 'Link description', + INPUT_TITLE: '', + INPUT_LINK_URL: 'https://example.com', + }); + + await expect(parseConfig()).rejects.toThrow('title is required for link message type'); + }); + + it('应该抛出缺少链接 URL 的错误', async () => { + restoreEnv = mockEnvVars({ + INPUT_WEBHOOK_URL: 'https://oapi.dingtalk.com/robot/send?access_token=test123', + INPUT_MESSAGE_TYPE: 'link', + INPUT_CONTENT: 'Link description', + INPUT_TITLE: 'Link title', + INPUT_LINK_URL: '', + }); + + await expect(parseConfig()).rejects.toThrow('link_url is required for link message type'); + }); + + it('应该抛出无效链接 URL 的错误', async () => { + restoreEnv = mockEnvVars({ + INPUT_WEBHOOK_URL: 'https://oapi.dingtalk.com/robot/send?access_token=test123', + INPUT_MESSAGE_TYPE: 'link', + INPUT_CONTENT: 'Link description', + INPUT_TITLE: 'Link title', + INPUT_LINK_URL: 'not-a-valid-url', + }); + + await expect(parseConfig()).rejects.toThrow('link_url must be a valid HTTP/HTTPS URL'); + }); + + it('应该抛出 ActionCard 缺少标题的错误', async () => { + restoreEnv = mockEnvVars({ + INPUT_WEBHOOK_URL: 'https://oapi.dingtalk.com/robot/send?access_token=test123', + INPUT_MESSAGE_TYPE: 'actionCard', + INPUT_CONTENT: 'ActionCard content', + INPUT_TITLE: '', + INPUT_SINGLE_TITLE: 'Button', + INPUT_SINGLE_URL: 'https://example.com', + }); + + await expect(parseConfig()).rejects.toThrow( + 'title is required for actionCard message type', + ); + }); + + it('应该抛出 ActionCard 缺少按钮的错误', async () => { + restoreEnv = mockEnvVars({ + INPUT_WEBHOOK_URL: 'https://oapi.dingtalk.com/robot/send?access_token=test123', + INPUT_MESSAGE_TYPE: 'actionCard', + INPUT_CONTENT: 'ActionCard content', + INPUT_TITLE: 'ActionCard title', + }); + + await expect(parseConfig()).rejects.toThrow( + 'actionCard requires either single_title/single_url or buttons', + ); + }); + + it('应该抛出 ActionCard 无效按钮方向的错误', async () => { + restoreEnv = mockEnvVars({ + INPUT_WEBHOOK_URL: 'https://oapi.dingtalk.com/robot/send?access_token=test123', + INPUT_MESSAGE_TYPE: 'actionCard', + INPUT_CONTENT: 'ActionCard content', + INPUT_TITLE: 'ActionCard title', + INPUT_BTN_ORIENTATION: '2', + INPUT_SINGLE_TITLE: 'Button', + INPUT_SINGLE_URL: 'https://example.com', + }); + + await expect(parseConfig()).rejects.toThrow("btn_orientation must be '0' or '1'"); + }); + + it('应该抛出 ActionCard 单按钮缺少 URL 的错误', async () => { + restoreEnv = mockEnvVars({ + INPUT_WEBHOOK_URL: 'https://oapi.dingtalk.com/robot/send?access_token=test123', + INPUT_MESSAGE_TYPE: 'actionCard', + INPUT_CONTENT: 'ActionCard content', + INPUT_TITLE: 'ActionCard title', + INPUT_SINGLE_TITLE: 'Button', + INPUT_SINGLE_URL: '', + }); + + await expect(parseConfig()).rejects.toThrow( + 'single_url is required when using single button', + ); + }); + + it('应该抛出 ActionCard 无效按钮 JSON 的错误', async () => { + restoreEnv = mockEnvVars({ + INPUT_WEBHOOK_URL: 'https://oapi.dingtalk.com/robot/send?access_token=test123', + INPUT_MESSAGE_TYPE: 'actionCard', + INPUT_CONTENT: 'ActionCard content', + INPUT_TITLE: 'ActionCard title', + INPUT_BUTTONS: 'invalid-json', + }); + + await expect(parseConfig()).rejects.toThrow( + 'actionCard requires either single_title/single_url or buttons', + ); + }); + + it('应该抛出 FeedCard 缺少链接的错误', async () => { + restoreEnv = mockEnvVars({ + INPUT_WEBHOOK_URL: 'https://oapi.dingtalk.com/robot/send?access_token=test123', + INPUT_MESSAGE_TYPE: 'feedCard', + INPUT_CONTENT: '', + INPUT_FEED_LINKS: '', + }); + + await expect(parseConfig()).rejects.toThrow( + 'feed_links is required and must be a non-empty array for feedCard', + ); + }); + + it('应该抛出 FeedCard 无效链接 JSON 的错误', async () => { + restoreEnv = mockEnvVars({ + INPUT_WEBHOOK_URL: 'https://oapi.dingtalk.com/robot/send?access_token=test123', + INPUT_MESSAGE_TYPE: 'feedCard', + INPUT_CONTENT: '', + INPUT_FEED_LINKS: 'invalid-json', + }); + + await expect(parseConfig()).rejects.toThrow( + 'feed_links is required and must be a non-empty array for feedCard', + ); + }); + }); + + describe('边界情况', () => { + it('应该处理按钮方向中的空白字符', async () => { + restoreEnv = mockEnvVars({ + INPUT_WEBHOOK_URL: 'https://oapi.dingtalk.com/robot/send?access_token=test123', + INPUT_MESSAGE_TYPE: 'actionCard', + INPUT_CONTENT: 'ActionCard content', + INPUT_TITLE: 'ActionCard title', + INPUT_BTN_ORIENTATION: ' 1 ', + INPUT_SINGLE_TITLE: 'Button', + INPUT_SINGLE_URL: 'https://example.com', + }); + + const config = await parseConfig(); + expect(config.btnOrientation).toBe('1'); + }); + + it('应该过滤掉无效的手机号码', async () => { + restoreEnv = mockEnvVars({ + INPUT_WEBHOOK_URL: 'https://oapi.dingtalk.com/robot/send?access_token=test123', + INPUT_MESSAGE_TYPE: 'text', + INPUT_CONTENT: 'Hello, World!', + INPUT_AT_MOBILES: '13800138000,invalid,12345678901,15012345678,abc123', + }); + + const config = await parseConfig(); + expect(config.atMobiles).toEqual(['13800138000', '15012345678']); + }); + + it('应该过滤掉无效的按钮', async () => { + const buttons = JSON.stringify([ + { title: '有效按钮', actionURL: 'https://example.com/valid' }, + { title: '', actionURL: 'https://example.com/invalid' }, // 空标题 + { title: '无效URL', actionURL: 'not-a-url' }, // 无效URL + { actionURL: 'https://example.com/no-title' }, // 缺少标题 + { title: '另一个有效按钮', actionURL: 'https://example.com/valid2' }, + ]); + + restoreEnv = mockEnvVars({ + INPUT_WEBHOOK_URL: 'https://oapi.dingtalk.com/robot/send?access_token=test123', + INPUT_MESSAGE_TYPE: 'actionCard', + INPUT_CONTENT: 'ActionCard content', + INPUT_TITLE: 'ActionCard title', + INPUT_BUTTONS: buttons, + }); + + const config = await parseConfig(); + expect(config.buttons).toHaveLength(2); + expect(config.buttons[0].title).toBe('有效按钮'); + expect(config.buttons[1].title).toBe('另一个有效按钮'); + }); + + it('应该过滤掉无效的 Feed 链接', async () => { + const feedLinks = JSON.stringify([ + { + title: '有效链接', + messageURL: 'https://example.com/valid', + picURL: 'https://example.com/pic.jpg', + }, + { + title: '', + messageURL: 'https://example.com/invalid', + picURL: 'https://example.com/pic.jpg', + }, // 空标题 + { + title: '无效URL', + messageURL: 'not-a-url', + picURL: 'https://example.com/pic.jpg', + }, // 无效URL + { + title: '另一个有效链接', + messageURL: 'https://example.com/valid2', + picURL: 'https://example.com/pic2.jpg', + }, + ]); + + restoreEnv = mockEnvVars({ + INPUT_WEBHOOK_URL: 'https://oapi.dingtalk.com/robot/send?access_token=test123', + INPUT_MESSAGE_TYPE: 'feedCard', + INPUT_CONTENT: 'FeedCard content', + INPUT_FEED_LINKS: feedLinks, + }); + + const config = await parseConfig(); + expect(config.feedLinks).toHaveLength(2); + expect(config.feedLinks[0].title).toBe('有效链接'); + expect(config.feedLinks[1].title).toBe('另一个有效链接'); + }); + }); + }); + + describe('测试 logConfig 函数', () => { + it('应该记录基本配置信息', () => { + const config = { + messageType: 'text', + content: 'Hello, World!', + webhookUrl: 'https://oapi.dingtalk.com/robot/send?access_token=test123', + }; + + logConfig(config); + + expect(consoleSpy.log).toHaveBeenCalledWith('=== Action Configuration ==='); + expect(consoleSpy.log).toHaveBeenCalledWith('Message Type: text'); + expect(consoleSpy.log).toHaveBeenCalledWith('Content Length: 13 characters'); + expect(consoleSpy.log).toHaveBeenCalledWith('Webhook URL: [HIDDEN FOR SECURITY]'); + }); + + it('应该在字段存在时记录可选字段', () => { + const config = { + messageType: 'markdown', + content: 'Markdown content', + title: 'Test Title', + atMobiles: ['13800138000', '13900139000'], + atAll: true, + linkUrl: 'https://example.com', + picUrl: 'https://example.com/pic.jpg', + webhookUrl: 'https://oapi.dingtalk.com/robot/send?access_token=test123', + }; + + logConfig(config); + + expect(consoleSpy.log).toHaveBeenCalledWith('Title: Test Title'); + expect(consoleSpy.log).toHaveBeenCalledWith('At Mobiles: 2 numbers'); + expect(consoleSpy.log).toHaveBeenCalledWith('At All: true'); + expect(consoleSpy.log).toHaveBeenCalledWith('Link URL: https://example.com'); + expect(consoleSpy.log).toHaveBeenCalledWith('Picture URL: https://example.com/pic.jpg'); + }); + + it('应该在字段不存在时不记录可选字段', () => { + const config = { + messageType: 'text', + content: 'Simple text', + atMobiles: [], + atAll: false, + webhookUrl: 'https://oapi.dingtalk.com/robot/send?access_token=test123', + }; + + logConfig(config); + + expect(consoleSpy.log).not.toHaveBeenCalledWith(expect.stringContaining('Title:')); + expect(consoleSpy.log).not.toHaveBeenCalledWith(expect.stringContaining('At Mobiles:')); + expect(consoleSpy.log).not.toHaveBeenCalledWith(expect.stringContaining('At All: true')); + expect(consoleSpy.log).not.toHaveBeenCalledWith(expect.stringContaining('Link URL:')); + expect(consoleSpy.log).not.toHaveBeenCalledWith(expect.stringContaining('Picture URL:')); + }); + }); +}); diff --git a/tests/unit/http.test.js b/tests/unit/http.test.js new file mode 100644 index 0000000..451b163 --- /dev/null +++ b/tests/unit/http.test.js @@ -0,0 +1,476 @@ +/** + * http.js 单元测试 + * 测试 HTTP 客户端功能 + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + createRequestSummary, + getDingTalkErrorDescription, + sendRequest, + validateResponse, +} from '../../src/http.js'; +import { dingTalkResponses } from '../fixtures/responses/dingtalk-responses.js'; + +// Mock fetch API +const mockFetch = vi.fn(); +global.fetch = mockFetch; + +describe('测试 http.js 文件', () => { + + /** + * 设置 HTTP Mock 的辅助函数 + * + * @param {object} mockResponse - 模拟的 HTTP 响应 + */ + async function setupHttpMock(mockResponse) { + const { statusCode = 200, data } = mockResponse; + const responseText = typeof data === 'string' ? data : JSON.stringify(data); + + mockFetch.mockResolvedValueOnce({ + status: statusCode, + text: () => Promise.resolve(responseText), + }); + } + + /** + * 设置网络错误 Mock 的辅助函数 + * + * @param {string} errorCode - 模拟的 HTTP 错误码 + */ + function setupNetworkErrorMock(errorCode) { + const error = new Error(`Network error: ${errorCode}`); + error.code = errorCode; + mockFetch.mockRejectedValueOnce(error); + } + + /** + * 设置超时 Mock 的辅助函数 + */ + function setupTimeoutMock() { + const error = new Error('Request timeout'); + error.code = 'TIMEOUT'; + mockFetch.mockRejectedValueOnce(error); + } + + beforeEach(() => { + // Mock console 方法 + vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + vi.spyOn(console, 'debug').mockImplementation(() => {}); + }); + + afterEach(() => { + // 恢复所有 mocks + vi.restoreAllMocks(); + + // 重新设置 fetch mock + mockFetch.mockClear(); + }); + + describe('测试 sendRequest 函数', () => { + const testUrl = 'https://oapi.dingtalk.com/robot/send?access_token=test'; + const testMessage = { + msgtype: 'text', + text: { + content: 'Hello World', + }, + }; + + it('应该成功发送请求', async () => { + await setupHttpMock({ statusCode: 200, data: dingTalkResponses.success.standard }); + + const result = await sendRequest(testUrl, testMessage); + + expect(result.success).toBe(true); + expect(result.statusCode).toBe(200); + expect(result.data).toEqual({ + errcode: 0, + errmsg: 'ok', + }); + expect(result.error).toBeUndefined(); + + // 验证 fetch 被调用 + expect(mockFetch).toHaveBeenCalledWith(testUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'User-Agent': expect.stringContaining('DingTalk-Bot'), + }, + body: JSON.stringify(testMessage), + }); + }); + + it('应该处理钉钉 API 错误响应', async () => { + await setupHttpMock({ statusCode: 200, data: dingTalkResponses.error.invalidToken }); + + const result = await sendRequest(testUrl, testMessage); + + expect(result.success).toBe(false); // 钉钉 API 错误码非0时 success 为 false + expect(result.statusCode).toBe(200); + expect(result.data).toEqual({ + errcode: 310000, + errmsg: 'keywords not in content', + }); + expect(result.error).toBeUndefined(); + }); + + it('应该处理网络连接错误', async () => { + setupNetworkErrorMock('ECONNREFUSED'); + + const result = await sendRequest(testUrl, testMessage); + + expect(result.success).toBe(false); + expect(result.statusCode).toBe(0); + expect(result.data).toEqual({ errcode: -1, errmsg: 'Network error' }); + expect(result.error).toContain('ECONNREFUSED'); + }); + + it('应该处理超时错误', async () => { + setupTimeoutMock(); + + const result = await sendRequest(testUrl, testMessage); + + expect(result.success).toBe(false); + expect(result.statusCode).toBe(0); + expect(result.data).toEqual({ errcode: -1, errmsg: 'Network error' }); + expect(result.error).toContain('timeout'); + }); + + it('应该处理 DNS 解析错误', async () => { + setupNetworkErrorMock('ENOTFOUND'); + + const result = await sendRequest(testUrl, testMessage); + + expect(result.success).toBe(false); + expect(result.statusCode).toBe(0); + expect(result.data).toEqual({ errcode: -1, errmsg: 'Network error' }); + expect(result.error).toContain('ENOTFOUND'); + }); + + it('应该处理 SSL 错误', async () => { + setupNetworkErrorMock('CERT_HAS_EXPIRED'); + + const result = await sendRequest(testUrl, testMessage); + + expect(result.success).toBe(false); + expect(result.statusCode).toBe(0); + expect(result.data).toEqual({ errcode: -1, errmsg: 'Network error' }); + expect(result.error).toContain('CERT_HAS_EXPIRED'); + }); + + it('应该处理非 JSON 响应', async () => { + await setupHttpMock({ + statusCode: 200, + data: 'Not JSON response', + }); + + const result = await sendRequest(testUrl, testMessage); + + expect(result.success).toBe(false); + expect(result.statusCode).toBe(200); + expect(result.data).toEqual({}); + expect(result.error).toContain('JSON parse error'); + }); + + it('应该处理空响应', async () => { + await setupHttpMock({ + statusCode: 200, + data: '', + }); + + const result = await sendRequest(testUrl, testMessage); + + expect(result.success).toBe(false); + expect(result.statusCode).toBe(200); + expect(result.data).toEqual({}); + expect(result.error).toBeUndefined(); + }); + + it('应该正确设置请求头', async () => { + await setupHttpMock({ statusCode: 200, data: dingTalkResponses.success.standard }); + + await sendRequest(testUrl, testMessage); + + expect(mockFetch).toHaveBeenCalledWith(testUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'User-Agent': expect.stringContaining('DingTalk-Bot'), + }, + body: JSON.stringify(testMessage), + }); + }); + }); + + describe('测试 validateResponse 函数', () => { + it('应该验证有效的成功响应', () => { + const response = { + success: true, + statusCode: 200, + data: { + errcode: 0, + errmsg: 'ok', + message_id: 'msg123456', + }, + error: null, + }; + + const result = validateResponse(response); + + expect(result.isValid).toBe(true); + expect(result.errors).toEqual([]); + }); + + it('应该验证有效的错误响应', () => { + const response = { + success: false, + statusCode: 400, + data: { + errcode: 310000, + errmsg: 'invalid token', + }, + error: null, + }; + + const result = validateResponse(response); + + expect(result.isValid).toBe(true); + expect(result.errors).toEqual([]); + }); + + it('应该检测缺少必需字段的响应', () => { + const response = { + success: true, + statusCode: 200, + data: { + errcode: 0, + // 缺少 errmsg + }, + error: null, + }; + + const result = validateResponse(response); + + expect(result.isValid).toBe(false); + expect(result.errors).toContain('Missing required field: errmsg'); + }); + + it('应该检测字段类型错误', () => { + const response = { + success: true, + statusCode: '200', // 应该是数字 + data: { + errcode: '0', // 应该是数字 + errmsg: 'ok', + }, + error: null, + }; + + const result = validateResponse(response); + + expect(result.isValid).toBe(false); + expect(result.errors).toContain('statusCode must be a number'); + expect(result.errors).toContain('errcode must be a number'); + }); + + it('应该检测无效的响应结构', () => { + const response = null; + + const result = validateResponse(response); + + expect(result.isValid).toBe(false); + expect(result.errors).toContain('Response is null or undefined'); + }); + + it('应该检测缺少 data 字段', () => { + const response = { + success: true, + statusCode: 200, + error: null, + // 缺少 data + }; + + const result = validateResponse(response); + + expect(result.isValid).toBe(false); + expect(result.errors).toContain('Missing required field: data'); + }); + }); + + describe('测试 getDingTalkErrorDescription 函数', () => { + it('应该返回已知错误码的描述', () => { + expect(getDingTalkErrorDescription(310000)).toBe('无效的 webhook URL 或访问令牌'); + expect(getDingTalkErrorDescription(310001)).toBe('无效签名'); + expect(getDingTalkErrorDescription(310002)).toBe('无效时间戳'); + expect(getDingTalkErrorDescription(310003)).toBe('无效请求格式'); + expect(getDingTalkErrorDescription(310004)).toBe('消息内容过长'); + expect(getDingTalkErrorDescription(310005)).toBe('消息发送频率超限'); + expect(getDingTalkErrorDescription(-1)).toBe('系统繁忙,请稍后再试'); + }); + + it('应该返回未知错误码的默认描述', () => { + expect(getDingTalkErrorDescription(999999)).toBe('未知错误'); + expect(getDingTalkErrorDescription(0)).toBe('请求成功'); + }); + + it('应该处理非数字错误码', () => { + expect(getDingTalkErrorDescription('invalid')).toBe('未知错误'); + expect(getDingTalkErrorDescription(null)).toBe('未知错误'); + expect(getDingTalkErrorDescription(undefined)).toBe('未知错误'); + }); + }); + + describe('测试 createRequestSummary 函数', () => { + it('应该为成功响应创建摘要', () => { + const response = { + success: true, + statusCode: 200, + data: { + errcode: 0, + errmsg: 'ok', + message_id: 'msg123456', + }, + error: null, + duration: 150, + }; + + const summary = createRequestSummary(response); + + expect(summary).toContain('Status: 200'); + expect(summary).toContain('Duration: 150ms'); + expect(summary).toContain('DingTalk Code: 0'); + }); + + it('应该为失败响应创建摘要', () => { + const response = { + success: false, + statusCode: 400, + data: { + errcode: 310000, + errmsg: 'invalid token', + }, + error: null, + duration: 200, + }; + + const summary = createRequestSummary(response); + + expect(summary).toContain('Status: 400'); + expect(summary).toContain('Duration: 200ms'); + expect(summary).toContain('DingTalk Code: 310000'); + expect(summary).toContain('无效的 webhook URL 或访问令牌'); + }); + + it('应该为网络错误创建摘要', () => { + const response = { + success: false, + statusCode: 0, + data: {}, + error: 'ECONNREFUSED', + duration: 5000, + }; + + const summary = createRequestSummary(response); + + expect(summary).toContain('Status: 0'); + expect(summary).toContain('Duration: 5000ms'); + expect(summary).toContain('Error: ECONNREFUSED'); + }); + + it('应该处理缺少消息ID的成功响应', () => { + const response = { + success: true, + statusCode: 200, + data: { + errcode: 0, + errmsg: 'ok', + // 缺少 message_id + }, + error: null, + duration: 100, + }; + + const summary = createRequestSummary(response); + + expect(summary).toContain('Status: 200'); + expect(summary).toContain('Duration: 100ms'); + expect(summary).toContain('DingTalk Code: 0'); + expect(summary).not.toContain('undefined'); + }); + + it('应该处理空的错误消息', () => { + const response = { + success: false, + statusCode: 400, + data: { + errcode: 300001, + errmsg: '', + }, + error: null, + duration: 250, + }; + + const summary = createRequestSummary(response); + + expect(summary).toContain('Status: 400'); + expect(summary).toContain('Duration: 250ms'); + expect(summary).toContain('DingTalk Code: 300001'); + expect(summary).toContain('未知错误'); + }); + }); + + describe('边界条件测试', () => { + const testUrl = 'https://oapi.dingtalk.com/robot/send?access_token=test'; + const testMessage = { msgtype: 'text', text: { content: 'test' } }; + + it('应该处理极大的响应数据', async () => { + const largeData = { + errcode: 0, + errmsg: 'ok', + message_id: 'msg123456', + large_field: 'x'.repeat(10000), // 10KB 的数据 + }; + + await setupHttpMock({ + statusCode: 200, + data: largeData, + }); + + const result = await sendRequest(testUrl, testMessage); + + expect(result.success).toBe(true); + expect(result.data.large_field).toBe('x'.repeat(10000)); + }); + + it('应该处理特殊字符的响应', async () => { + const specialData = { + errcode: 0, + errmsg: 'ok with 特殊字符 and émojis 🎉', + message_id: 'msg123456', + }; + + await setupHttpMock({ + statusCode: 200, + data: specialData, + }); + + const result = await sendRequest(testUrl, testMessage); + + expect(result.success).toBe(true); + expect(result.data.errmsg).toBe('ok with 特殊字符 and émojis 🎉'); + }); + + it('应该处理畸形的 JSON 响应', async () => { + await setupHttpMock(dingTalkResponses.special.malformedJson); + + const result = await sendRequest(testUrl, testMessage); + + expect(result.success).toBe(false); + expect(result.error).toBeDefined(); + expect(typeof result.error).toBe('string'); + expect(result.error).toContain('JSON parse error'); + }); + }); +}); diff --git a/tests/unit/message.test.js b/tests/unit/message.test.js new file mode 100644 index 0000000..52d954e --- /dev/null +++ b/tests/unit/message.test.js @@ -0,0 +1,939 @@ +/** + * message.js 文件单元测试 + */ + +import { describe, expect, it } from 'vitest'; +import { MESSAGE_TYPES } from '../../src/config.js'; +import { + buildActionCardMessage, + buildFeedCardMessage, + buildLinkMessage, + buildMarkdownMessage, + buildMessage, + buildTextMessage, + getMessageSummary, + validateMessage, +} from '../../src/message.js'; +import { assertDingTalkMessage } from '../helpers/assertions.js'; + +describe('测试 message.js 文件', () => { + describe('测试 buildTextMessage 函数', () => { + it('应该构建基本的文本消息', () => { + const message = buildTextMessage('Hello, World!'); + + assertDingTalkMessage(message, MESSAGE_TYPES.TEXT); + expect(message.msgtype).toBe(MESSAGE_TYPES.TEXT); + expect(message.text.content).toBe('Hello, World!'); + expect(message.at).toBeUndefined(); + }); + + it('应该构建包含@手机号的文本消息', () => { + const atMobiles = ['13800138000', '13900139000']; + const message = buildTextMessage('Hello, World!', atMobiles); + + expect(message.msgtype).toBe(MESSAGE_TYPES.TEXT); + expect(message.text.content).toBe('Hello, World!'); + expect(message.at).toEqual({ + atMobiles: ['13800138000', '13900139000'], + isAtAll: false, + }); + }); + + it('应该构建@所有人的文本消息', () => { + const message = buildTextMessage('Hello, World!', [], true); + + expect(message.msgtype).toBe(MESSAGE_TYPES.TEXT); + expect(message.text.content).toBe('Hello, World!'); + expect(message.at).toEqual({ + atMobiles: [], + isAtAll: true, + }); + }); + + it('应该构建同时包含@手机号和@所有人的文本消息', () => { + const atMobiles = ['13800138000']; + const message = buildTextMessage('Hello, World!', atMobiles, true); + + expect(message.at).toEqual({ + atMobiles: ['13800138000'], + isAtAll: true, + }); + }); + + it('应该对缺少内容抛出错误', () => { + expect(() => buildTextMessage()).toThrow('Content is required for text message'); + expect(() => buildTextMessage('')).toThrow('Content is required for text message'); + expect(() => buildTextMessage(null)).toThrow('Content is required for text message'); + expect(() => buildTextMessage(123)).toThrow('Content is required for text message'); + }); + + it('应该处理空的@手机号数组', () => { + const message = buildTextMessage('Hello, World!', []); + + expect(message.at).toBeUndefined(); + }); + + it('应该处理 null/undefined 的@手机号', () => { + const message1 = buildTextMessage('Hello, World!', null); + const message2 = buildTextMessage('Hello, World!', undefined); + + expect(message1.at).toBeUndefined(); + expect(message2.at).toBeUndefined(); + }); + }); + + describe('测试 buildMarkdownMessage 函数', () => { + it('应该构建基本的 Markdown 消息', () => { + const message = buildMarkdownMessage('标题', '## Markdown 内容'); + + assertDingTalkMessage(message, MESSAGE_TYPES.MARKDOWN); + expect(message.msgtype).toBe(MESSAGE_TYPES.MARKDOWN); + expect(message.markdown.title).toBe('标题'); + expect(message.markdown.text).toBe('## Markdown 内容'); + expect(message.at).toBeUndefined(); + }); + + it('应该构建包含@手机号的 Markdown 消息', () => { + const atMobiles = ['13800138000', '13900139000']; + const message = buildMarkdownMessage('标题', '## Markdown 内容', atMobiles); + + expect(message.msgtype).toBe(MESSAGE_TYPES.MARKDOWN); + expect(message.markdown.title).toBe('标题'); + expect(message.markdown.text).toBe('## Markdown 内容\n\n@13800138000 @13900139000'); + expect(message.at).toEqual({ + atMobiles: ['13800138000', '13900139000'], + isAtAll: false, + }); + }); + + it('应该构建@所有人的 Markdown 消息', () => { + const message = buildMarkdownMessage('标题', '## Markdown 内容', [], true); + + expect(message.msgtype).toBe(MESSAGE_TYPES.MARKDOWN); + expect(message.markdown.text).toBe('## Markdown 内容\n\n@所有人'); + expect(message.at).toEqual({ + atMobiles: [], + isAtAll: true, + }); + }); + + it('应该构建同时包含@手机号和@所有人的 Markdown 消息', () => { + const atMobiles = ['13800138000']; + const message = buildMarkdownMessage('标题', '## Markdown 内容', atMobiles, true); + + expect(message.markdown.text).toBe('## Markdown 内容\n\n@13800138000\n\n@所有人'); + expect(message.at).toEqual({ + atMobiles: ['13800138000'], + isAtAll: true, + }); + }); + + it('应该对缺少标题抛出错误', () => { + expect(() => buildMarkdownMessage()).toThrow('Title is required for markdown message'); + expect(() => buildMarkdownMessage('')).toThrow('Title is required for markdown message'); + expect(() => buildMarkdownMessage(null)).toThrow('Title is required for markdown message'); + expect(() => buildMarkdownMessage(123)).toThrow('Title is required for markdown message'); + }); + + it('应该对缺少内容抛出错误', () => { + expect(() => buildMarkdownMessage('标题')).toThrow( + 'Content is required for markdown message', + ); + expect(() => buildMarkdownMessage('标题', '')).toThrow( + 'Content is required for markdown message', + ); + expect(() => buildMarkdownMessage('标题', null)).toThrow( + 'Content is required for markdown message', + ); + expect(() => buildMarkdownMessage('标题', 123)).toThrow( + 'Content is required for markdown message', + ); + }); + }); + + describe('测试 buildLinkMessage 函数', () => { + it('应该构建基本的链接消息', () => { + const message = buildLinkMessage('链接标题', '链接描述', 'https://example.com'); + + assertDingTalkMessage(message, MESSAGE_TYPES.LINK); + expect(message.msgtype).toBe(MESSAGE_TYPES.LINK); + expect(message.link.title).toBe('链接标题'); + expect(message.link.text).toBe('链接描述'); + expect(message.link.messageUrl).toBe('https://example.com'); + expect(message.link.picUrl).toBeUndefined(); + }); + + it('应该构建包含图片 URL 的链接消息', () => { + const message = buildLinkMessage( + '链接标题', + '链接描述', + 'https://example.com', + 'https://example.com/image.jpg', + ); + + expect(message.link.picUrl).toBe('https://example.com/image.jpg'); + }); + + it('应该忽略无效的图片 URL', () => { + const message = buildLinkMessage( + '链接标题', + '链接描述', + 'https://example.com', + 'not-a-valid-url', + ); + + expect(message.link.picUrl).toBeUndefined(); + }); + + it('应该忽略非字符串的图片 URL', () => { + const message = buildLinkMessage('链接标题', '链接描述', 'https://example.com', 123); + + expect(message.link.picUrl).toBeUndefined(); + }); + + it('应该对缺少标题抛出错误', () => { + expect(() => buildLinkMessage()).toThrow('Title is required for link message'); + expect(() => buildLinkMessage('')).toThrow('Title is required for link message'); + expect(() => buildLinkMessage(null)).toThrow('Title is required for link message'); + expect(() => buildLinkMessage(123)).toThrow('Title is required for link message'); + }); + + it('应该对缺少内容抛出错误', () => { + expect(() => buildLinkMessage('标题')).toThrow('Content is required for link message'); + expect(() => buildLinkMessage('标题', '')).toThrow('Content is required for link message'); + expect(() => buildLinkMessage('标题', null)).toThrow('Content is required for link message'); + expect(() => buildLinkMessage('标题', 123)).toThrow('Content is required for link message'); + }); + + it('应该对缺少链接 URL 抛出错误', () => { + expect(() => buildLinkMessage('标题', '内容')).toThrow( + 'Link URL is required for link message', + ); + expect(() => buildLinkMessage('标题', '内容', '')).toThrow( + 'Link URL is required for link message', + ); + expect(() => buildLinkMessage('标题', '内容', null)).toThrow( + 'Link URL is required for link message', + ); + expect(() => buildLinkMessage('标题', '内容', 123)).toThrow( + 'Link URL is required for link message', + ); + }); + + it('应该对无效的链接 URL 格式抛出错误', () => { + expect(() => buildLinkMessage('标题', '内容', 'not-a-url')).toThrow( + 'Link URL must be a valid HTTP/HTTPS URL', + ); + expect(() => buildLinkMessage('标题', '内容', 'ftp://example.com')).toThrow( + 'Link URL must be a valid HTTP/HTTPS URL', + ); + }); + + it('应该接受 HTTP 和 HTTPS URL', () => { + const httpMessage = buildLinkMessage('标题', '内容', 'http://example.com'); + const httpsMessage = buildLinkMessage('标题', '内容', 'https://example.com'); + + expect(httpMessage.link.messageUrl).toBe('http://example.com'); + expect(httpsMessage.link.messageUrl).toBe('https://example.com'); + }); + }); + + describe('测试 buildActionCardMessage 函数', () => { + it('应该构建单按钮 ActionCard 消息', () => { + const options = { + btnOrientation: '0', + singleTitle: '查看详情', + singleURL: 'https://example.com/detail', + }; + + const message = buildActionCardMessage('ActionCard 标题', 'ActionCard 内容', options); + + assertDingTalkMessage(message, MESSAGE_TYPES.ACTION_CARD); + expect(message.msgtype).toBe(MESSAGE_TYPES.ACTION_CARD); + expect(message.actionCard.title).toBe('ActionCard 标题'); + expect(message.actionCard.text).toBe('ActionCard 内容'); + expect(message.actionCard.btnOrientation).toBe('0'); + expect(message.actionCard.singleTitle).toBe('查看详情'); + expect(message.actionCard.singleURL).toBe('https://example.com/detail'); + expect(message.actionCard.btns).toBeUndefined(); + }); + + it('应该构建多按钮 ActionCard 消息', () => { + const options = { + btnOrientation: '1', + buttons: [ + { title: '按钮1', actionURL: 'https://example.com/action1' }, + { title: '按钮2', actionURL: 'https://example.com/action2' }, + ], + }; + + const message = buildActionCardMessage('ActionCard 标题', 'ActionCard 内容', options); + + expect(message.msgtype).toBe(MESSAGE_TYPES.ACTION_CARD); + expect(message.actionCard.btnOrientation).toBe('1'); + expect(message.actionCard.btns).toEqual([ + { title: '按钮1', actionURL: 'https://example.com/action1' }, + { title: '按钮2', actionURL: 'https://example.com/action2' }, + ]); + expect(message.actionCard.singleTitle).toBeUndefined(); + expect(message.actionCard.singleURL).toBeUndefined(); + }); + + it('应该使用默认的按钮方向', () => { + const options = { + singleTitle: '查看详情', + singleURL: 'https://example.com/detail', + }; + + const message = buildActionCardMessage('ActionCard 标题', 'ActionCard 内容', options); + + expect(message.actionCard.btnOrientation).toBe('0'); + }); + + it('应该处理空选项', () => { + expect(() => buildActionCardMessage('标题', '内容', {})).toThrow( + 'actionCard requires either singleTitle/singleURL or buttons', + ); + }); + + it('应该处理未定义的选项', () => { + expect(() => buildActionCardMessage('标题', '内容')).toThrow( + 'actionCard requires either singleTitle/singleURL or buttons', + ); + }); + + it('应该对缺少标题抛出错误', () => { + expect(() => buildActionCardMessage()).toThrow('Title is required for actionCard message'); + expect(() => buildActionCardMessage('')).toThrow('Title is required for actionCard message'); + expect(() => buildActionCardMessage(null)).toThrow( + 'Title is required for actionCard message', + ); + expect(() => buildActionCardMessage(123)).toThrow('Title is required for actionCard message'); + }); + + it('应该对缺少内容抛出错误', () => { + expect(() => buildActionCardMessage('标题')).toThrow( + 'Content is required for actionCard message', + ); + expect(() => buildActionCardMessage('标题', '')).toThrow( + 'Content is required for actionCard message', + ); + expect(() => buildActionCardMessage('标题', null)).toThrow( + 'Content is required for actionCard message', + ); + expect(() => buildActionCardMessage('标题', 123)).toThrow( + 'Content is required for actionCard message', + ); + }); + + it('应该对既没有单按钮也没有按钮数组的情况抛出错误', () => { + expect(() => buildActionCardMessage('标题', '内容', {})).toThrow( + 'actionCard requires either singleTitle/singleURL or buttons', + ); + }); + + it('应该处理空的按钮数组', () => { + const options = { buttons: [] }; + + expect(() => buildActionCardMessage('标题', '内容', options)).toThrow( + 'actionCard requires either singleTitle/singleURL or buttons', + ); + }); + + it('应该处理非数组的按钮', () => { + const options = { buttons: 'not-an-array' }; + + expect(() => buildActionCardMessage('标题', '内容', options)).toThrow( + 'actionCard requires either singleTitle/singleURL or buttons', + ); + }); + + it('当同时提供单按钮和按钮数组时应该优先使用单按钮', () => { + const options = { + singleTitle: '单按钮', + singleURL: 'https://example.com/single', + buttons: [{ title: '多按钮', actionURL: 'https://example.com/multi' }], + }; + + const message = buildActionCardMessage('标题', '内容', options); + + expect(message.actionCard.singleTitle).toBe('单按钮'); + expect(message.actionCard.singleURL).toBe('https://example.com/single'); + expect(message.actionCard.btns).toEqual([ + { title: '多按钮', actionURL: 'https://example.com/multi' }, + ]); + }); + }); + + describe('测试 buildFeedCardMessage 函数', () => { + it('应该构建 FeedCard 消息', () => { + const links = [ + { + title: '新闻1', + messageURL: 'https://example.com/news1', + picURL: 'https://example.com/pic1.jpg', + }, + { + title: '新闻2', + messageURL: 'https://example.com/news2', + picURL: 'https://example.com/pic2.jpg', + }, + ]; + + const message = buildFeedCardMessage(links); + + assertDingTalkMessage(message, MESSAGE_TYPES.FEED_CARD); + expect(message.msgtype).toBe(MESSAGE_TYPES.FEED_CARD); + expect(message.feedCard.links).toEqual(links); + }); + + it('应该构建包含单个链接的 FeedCard 消息', () => { + const links = [ + { + title: '单个新闻', + messageURL: 'https://example.com/news', + picURL: 'https://example.com/pic.jpg', + }, + ]; + + const message = buildFeedCardMessage(links); + + expect(message.feedCard.links).toHaveLength(1); + expect(message.feedCard.links[0]).toEqual(links[0]); + }); + + it('应该对缺少链接抛出错误', () => { + expect(() => buildFeedCardMessage()).toThrow('FeedCard requires a non-empty links array'); + expect(() => buildFeedCardMessage(null)).toThrow('FeedCard requires a non-empty links array'); + expect(() => buildFeedCardMessage('not-an-array')).toThrow( + 'FeedCard requires a non-empty links array', + ); + }); + + it('应该对空链接数组抛出错误', () => { + expect(() => buildFeedCardMessage([])).toThrow('FeedCard requires a non-empty links array'); + }); + + it('应该正确映射链接属性', () => { + const links = [ + { + title: '测试标题', + messageURL: 'https://test.com', + picURL: 'https://test.com/pic.jpg', + extraProperty: 'should be ignored', + }, + ]; + + const message = buildFeedCardMessage(links); + + expect(message.feedCard.links[0]).toEqual({ + title: '测试标题', + messageURL: 'https://test.com', + picURL: 'https://test.com/pic.jpg', + }); + expect(message.feedCard.links[0].extraProperty).toBeUndefined(); + }); + }); + + describe('测试 buildMessage 函数', () => { + it('应该根据配置构建文本消息', () => { + const config = { + messageType: 'text', + content: 'Hello, World!', + atMobiles: ['13800138000'], + atAll: false, + }; + + const message = buildMessage(config); + + expect(message.msgtype).toBe(MESSAGE_TYPES.TEXT); + expect(message.text.content).toBe('Hello, World!'); + expect(message.at.atMobiles).toEqual(['13800138000']); + }); + + it('应该根据配置构建 Markdown 消息', () => { + const config = { + messageType: 'markdown', + title: 'Markdown 标题', + content: '## Markdown 内容', + atMobiles: [], + atAll: true, + }; + + const message = buildMessage(config); + + expect(message.msgtype).toBe(MESSAGE_TYPES.MARKDOWN); + expect(message.markdown.title).toBe('Markdown 标题'); + expect(message.at.isAtAll).toBe(true); + }); + + it('应该根据配置构建链接消息', () => { + const config = { + messageType: 'link', + title: '链接标题', + content: '链接描述', + linkUrl: 'https://example.com', + picUrl: 'https://example.com/pic.jpg', + }; + + const message = buildMessage(config); + + expect(message.msgtype).toBe(MESSAGE_TYPES.LINK); + expect(message.link.title).toBe('链接标题'); + expect(message.link.messageUrl).toBe('https://example.com'); + expect(message.link.picUrl).toBe('https://example.com/pic.jpg'); + }); + + it('应该根据配置构建 ActionCard 消息', () => { + const config = { + messageType: 'actionCard', + title: 'ActionCard 标题', + content: 'ActionCard 内容', + btnOrientation: '1', + buttons: [{ title: '按钮1', actionURL: 'https://example.com/action1' }], + }; + + const message = buildMessage(config); + + expect(message.msgtype).toBe(MESSAGE_TYPES.ACTION_CARD); + expect(message.actionCard.title).toBe('ActionCard 标题'); + expect(message.actionCard.btnOrientation).toBe('1'); + expect(message.actionCard.btns).toHaveLength(1); + }); + + it('应该根据配置构建 FeedCard 消息', () => { + const config = { + messageType: 'feedCard', + feedLinks: [ + { + title: '新闻1', + messageURL: 'https://example.com/news1', + picURL: 'https://example.com/pic1.jpg', + }, + ], + }; + + const message = buildMessage(config); + + expect(message.msgtype).toBe(MESSAGE_TYPES.FEED_CARD); + expect(message.feedCard.links).toHaveLength(1); + }); + + it('应该对不支持的消息类型抛出错误', () => { + const config = { + messageType: 'unsupported', + content: 'Test content', + }; + + expect(() => buildMessage(config)).toThrow('Unsupported message type: unsupported'); + }); + }); + + describe('测试 validateMessage 函数', () => { + it('应该验证有效的文本消息', () => { + const message = { + msgtype: 'text', + text: { + content: 'Hello, World!', + }, + }; + + const result = validateMessage(message); + + expect(result.isValid).toBe(true); + expect(result.errors).toEqual([]); + }); + + it('应该验证有效的 Markdown 消息', () => { + const message = { + msgtype: 'markdown', + markdown: { + title: '标题', + text: '## 内容', + }, + }; + + const result = validateMessage(message); + + expect(result.isValid).toBe(true); + expect(result.errors).toEqual([]); + }); + + it('应该验证有效的链接消息', () => { + const message = { + msgtype: 'link', + link: { + title: '链接标题', + text: '链接描述', + messageUrl: 'https://example.com', + }, + }; + + const result = validateMessage(message); + + expect(result.isValid).toBe(true); + expect(result.errors).toEqual([]); + }); + + it('应该验证有效的单按钮 ActionCard 消息', () => { + const message = { + msgtype: 'actionCard', + actionCard: { + title: 'ActionCard 标题', + text: 'ActionCard 内容', + singleTitle: '查看详情', + singleURL: 'https://example.com', + }, + }; + + const result = validateMessage(message); + + expect(result.isValid).toBe(true); + expect(result.errors).toEqual([]); + }); + + it('应该验证有效的多按钮 ActionCard 消息', () => { + const message = { + msgtype: 'actionCard', + actionCard: { + title: 'ActionCard 标题', + text: 'ActionCard 内容', + btns: [ + { title: '按钮1', actionURL: 'https://example.com/action1' }, + { title: '按钮2', actionURL: 'https://example.com/action2' }, + ], + }, + }; + + const result = validateMessage(message); + + expect(result.isValid).toBe(true); + expect(result.errors).toEqual([]); + }); + + it('应该验证有效的 FeedCard 消息', () => { + const message = { + msgtype: 'feedCard', + feedCard: { + links: [ + { + title: '新闻1', + messageURL: 'https://example.com/news1', + picURL: 'https://example.com/pic1.jpg', + }, + ], + }, + }; + + const result = validateMessage(message); + + expect(result.isValid).toBe(true); + expect(result.errors).toEqual([]); + }); + + it('应该拒绝 null/undefined 消息', () => { + expect(validateMessage(null).isValid).toBe(false); + expect(validateMessage(undefined).isValid).toBe(false); + expect(validateMessage('not-an-object').isValid).toBe(false); + }); + + it('应该拒绝没有 msgtype 的消息', () => { + const message = { + text: { content: 'Hello' }, + }; + + const result = validateMessage(message); + + expect(result.isValid).toBe(false); + expect(result.errors).toContain('Invalid or missing msgtype'); + }); + + it('应该拒绝带有无效 msgtype 的消息', () => { + const message = { + msgtype: 'invalid', + text: { content: 'Hello' }, + }; + + const result = validateMessage(message); + + expect(result.isValid).toBe(false); + expect(result.errors).toContain('Invalid or missing msgtype'); + }); + + it('应该拒绝没有内容的文本消息', () => { + const message = { + msgtype: 'text', + text: {}, + }; + + const result = validateMessage(message); + + expect(result.isValid).toBe(false); + expect(result.errors).toContain('Text message must have content'); + }); + + it('应该拒绝没有标题或内容的 Markdown 消息', () => { + const message1 = { + msgtype: 'markdown', + markdown: { text: '内容' }, + }; + + const message2 = { + msgtype: 'markdown', + markdown: { title: '标题' }, + }; + + expect(validateMessage(message1).isValid).toBe(false); + expect(validateMessage(message2).isValid).toBe(false); + }); + + it('应该拒绝缺少字段的链接消息', () => { + const message = { + msgtype: 'link', + link: { title: '标题' }, + }; + + const result = validateMessage(message); + + expect(result.isValid).toBe(false); + expect(result.errors).toContain('Link message must have title, text, and messageUrl'); + }); + + it('应该拒绝单按钮不完整的 ActionCard 消息', () => { + const message = { + msgtype: 'actionCard', + actionCard: { + title: '标题', + text: '内容', + singleTitle: '按钮', + // missing singleURL + }, + }; + + const result = validateMessage(message); + + expect(result.isValid).toBe(false); + expect(result.errors).toContain( + 'ActionCard single button requires both singleTitle and singleURL', + ); + }); + + it('应该拒绝带有无效按钮的 ActionCard 消息', () => { + const message = { + msgtype: 'actionCard', + actionCard: { + title: '标题', + text: '内容', + btns: [ + { title: '按钮1' }, // missing actionURL + ], + }, + }; + + const result = validateMessage(message); + + expect(result.isValid).toBe(false); + expect(result.errors).toContain('Each ActionCard button must have title and actionURL'); + }); + + it('应该拒绝没有链接的 FeedCard 消息', () => { + const message = { + msgtype: 'feedCard', + feedCard: {}, + }; + + const result = validateMessage(message); + + expect(result.isValid).toBe(false); + expect(result.errors).toContain('FeedCard must have a non-empty links array'); + }); + + it('应该拒绝带有无效链接的 FeedCard 消息', () => { + const message = { + msgtype: 'feedCard', + feedCard: { + links: [ + { title: '新闻1' }, // missing messageURL and picURL + ], + }, + }; + + const result = validateMessage(message); + + expect(result.isValid).toBe(false); + expect(result.errors).toContain('Each FeedCard link must have title, messageURL and picURL'); + }); + }); + + describe('测试 getMessageSummary 函数', () => { + it('应该返回文本消息的摘要', () => { + const message = { + msgtype: 'text', + text: { + content: 'Hello, World!', + }, + }; + + const summary = getMessageSummary(message); + + expect(summary).toBe('Type: text, Content: "Hello, World!"'); + }); + + it('应该截断长文本内容', () => { + const longContent = 'A'.repeat(60); + const message = { + msgtype: 'text', + text: { + content: longContent, + }, + }; + + const summary = getMessageSummary(message); + + expect(summary).toContain(`Type: text, Content: "${'A'.repeat(50)}..."`); + }); + + it('应该返回 Markdown 消息的摘要', () => { + const message = { + msgtype: 'markdown', + markdown: { + title: 'Markdown 标题', + text: '## Markdown 内容', + }, + }; + + const summary = getMessageSummary(message); + + expect(summary).toBe('Type: markdown, Title: "Markdown 标题", Text: "## Markdown 内容"'); + }); + + it('应该返回链接消息的摘要', () => { + const message = { + msgtype: 'link', + link: { + title: '链接标题', + text: '链接描述', + messageUrl: 'https://example.com', + }, + }; + + const summary = getMessageSummary(message); + + expect(summary).toBe('Type: link, Title: "链接标题", URL: "https://example.com"'); + }); + + it('应该返回单按钮 ActionCard 消息的摘要', () => { + const message = { + msgtype: 'actionCard', + actionCard: { + title: 'ActionCard 标题', + text: 'ActionCard 内容', + singleTitle: '查看详情', + }, + }; + + const summary = getMessageSummary(message); + + expect(summary).toBe('Type: actionCard, Title: "ActionCard 标题", Single: "查看详情"'); + }); + + it('应该返回多按钮 ActionCard 消息的摘要', () => { + const message = { + msgtype: 'actionCard', + actionCard: { + title: 'ActionCard 标题', + text: 'ActionCard 内容', + btns: [ + { title: '按钮1', actionURL: 'https://example.com/action1' }, + { title: '按钮2', actionURL: 'https://example.com/action2' }, + ], + }, + }; + + const summary = getMessageSummary(message); + + expect(summary).toBe('Type: actionCard, Title: "ActionCard 标题", Buttons: 2'); + }); + + it('应该返回 FeedCard 消息的摘要', () => { + const message = { + msgtype: 'feedCard', + feedCard: { + links: [ + { + title: '新闻1', + messageURL: 'https://example.com/news1', + picURL: 'https://example.com/pic1.jpg', + }, + { + title: '新闻2', + messageURL: 'https://example.com/news2', + picURL: 'https://example.com/pic2.jpg', + }, + ], + }, + }; + + const summary = getMessageSummary(message); + + expect(summary).toBe('Type: feedCard, Links: 2'); + }); + + it('应该包含 @所有人 信息', () => { + const message = { + msgtype: 'text', + text: { + content: 'Hello, World!', + }, + at: { + isAtAll: true, + atMobiles: [], + }, + }; + + const summary = getMessageSummary(message); + + expect(summary).toBe('Type: text, Content: "Hello, World!", @All: true'); + }); + + it('应该包含 @手机号 信息', () => { + const message = { + msgtype: 'text', + text: { + content: 'Hello, World!', + }, + at: { + isAtAll: false, + atMobiles: ['13800138000', '13900139000'], + }, + }; + + const summary = getMessageSummary(message); + + expect(summary).toBe('Type: text, Content: "Hello, World!", @Mobiles: 2'); + }); + + it('应该同时包含 @所有人 和 @手机号 信息', () => { + const message = { + msgtype: 'text', + text: { + content: 'Hello, World!', + }, + at: { + isAtAll: true, + atMobiles: ['13800138000'], + }, + }; + + const summary = getMessageSummary(message); + + expect(summary).toBe('Type: text, Content: "Hello, World!", @All: true, @Mobiles: 1'); + }); + + it('应该处理无效的消息', () => { + expect(getMessageSummary(null)).toBe('Invalid message'); + expect(getMessageSummary({})).toBe('Invalid message'); + expect(getMessageSummary({ msgtype: null })).toBe('Invalid message'); + }); + }); +}); diff --git a/tests/unit/utils.test.js b/tests/unit/utils.test.js new file mode 100644 index 0000000..4214682 --- /dev/null +++ b/tests/unit/utils.test.js @@ -0,0 +1,573 @@ +/** + * utils.js 文件单元测试 + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import * as utils from '../../src/utils.js'; +import { assertError } from '../helpers/assertions.js'; +import { mockEnvVars } from '../helpers/env-mock.js'; + +describe('测试 utils.js 文件', () => { + let restoreEnv; + let consoleSpy; + + beforeEach(() => { + // Mock console methods + consoleSpy = { + log: vi.spyOn(console, 'log').mockImplementation(() => {}), + warn: vi.spyOn(console, 'warn').mockImplementation(() => {}), + error: vi.spyOn(console, 'error').mockImplementation(() => {}), + }; + }); + + afterEach(() => { + // Restore console methods + Object.values(consoleSpy).forEach(spy => spy.mockRestore()); + + // Restore environment variables + if (restoreEnv) { + restoreEnv(); + restoreEnv = null; + } + }); + + describe('测试 LOG_LEVELS 常量', () => { + it('应该导出正确的日志级别', () => { + expect(utils.LOG_LEVELS).toEqual({ + DEBUG: 'DEBUG', + INFO: 'INFO', + WARN: 'WARN', + ERROR: 'ERROR', + }); + }); + }); + + describe('测试 getTimestamp 函数', () => { + it('应该返回 ISO 字符串时间戳', () => { + const timestamp = utils.getTimestamp(); + expect(timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/); + expect(() => new Date(timestamp)).not.toThrow(); + }); + + it('应该为连续调用返回不同的时间戳', async () => { + const timestamp1 = utils.getTimestamp(); + // Add a small delay to ensure different timestamps + await new Promise(resolve => setTimeout(resolve, 10)); + const timestamp2 = utils.getTimestamp(); + expect(timestamp1).not.toBe(timestamp2); + }); + }); + + describe('测试 formatLogMessage 函数', () => { + it('应该使用级别和消息格式化日志消息', () => { + const message = utils.formatLogMessage('INFO', 'Test message'); + expect(message).toMatch( + /^\[\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z\] \[INFO\] Test message$/, + ); + }); + + it('应该在提供元数据时包含它', () => { + const meta = { key: 'value', number: 123 }; + const message = utils.formatLogMessage('DEBUG', 'Test message', meta); + expect(message).toContain('Test message | {"key":"value","number":123}'); + }); + + it('应该处理 null 元数据', () => { + const message = utils.formatLogMessage('WARN', 'Test message', null); + expect(message).not.toContain('|'); + expect(message).toContain('Test message'); + }); + + it('应该处理 undefined 元数据', () => { + const message = utils.formatLogMessage('ERROR', 'Test message'); + expect(message).not.toContain('|'); + expect(message).toContain('Test message'); + }); + }); + + describe('测试 logDebug 函数', () => { + it('应该在 DEBUG=true 时记录调试消息', () => { + restoreEnv = mockEnvVars({ DEBUG: 'true' }); + + utils.logDebug('Debug message', { test: true }); + + expect(consoleSpy.log).toHaveBeenCalledWith( + expect.stringContaining('[DEBUG] Debug message | {"test":true}'), + ); + }); + + it('应该在 NODE_ENV=development 时记录调试消息', () => { + restoreEnv = mockEnvVars({ NODE_ENV: 'development' }); + + utils.logDebug('Debug message'); + + expect(consoleSpy.log).toHaveBeenCalledWith(expect.stringContaining('[DEBUG] Debug message')); + }); + + it('不应该在生产环境中记录调试消息', () => { + restoreEnv = mockEnvVars({ DEBUG: 'false', NODE_ENV: 'production' }); + + utils.logDebug('Debug message'); + + expect(consoleSpy.log).not.toHaveBeenCalled(); + }); + }); + + describe('测试 logInfo 函数', () => { + it('应该始终记录信息消息', () => { + utils.logInfo('Info message', { data: 'test' }); + + expect(consoleSpy.log).toHaveBeenCalledWith( + expect.stringContaining('[INFO] Info message | {"data":"test"}'), + ); + }); + }); + + describe('测试 logWarn 函数', () => { + it('应该始终记录警告消息', () => { + utils.logWarn('Warning message'); + + expect(consoleSpy.warn).toHaveBeenCalledWith( + expect.stringContaining('[WARN] Warning message'), + ); + }); + }); + + describe('测试 logError 函数', () => { + it('应该始终记录错误消息', () => { + utils.logError('Error message', { error: 'details' }); + + expect(consoleSpy.error).toHaveBeenCalledWith( + expect.stringContaining('[ERROR] Error message | {"error":"details"}'), + ); + }); + }); + + describe('测试 safeJsonParse 函数', () => { + it('应该解析有效的 JSON 字符串', () => { + const obj = { key: 'value', number: 123 }; + const jsonString = JSON.stringify(obj); + const result = utils.safeJsonParse(jsonString); + + expect(result).toEqual(obj); + }); + + it('应该为无效的 JSON 返回默认值', () => { + const result = utils.safeJsonParse('invalid json', { default: true }); + + expect(result).toEqual({ default: true }); + expect(consoleSpy.warn).toHaveBeenCalledWith(expect.stringContaining('Failed to parse JSON')); + }); + + it('应该在未提供默认值时返回 null', () => { + const result = utils.safeJsonParse('invalid json'); + + expect(result).toBeNull(); + }); + + it('应该处理空字符串', () => { + const result = utils.safeJsonParse('', 'default'); + + expect(result).toBe('default'); + }); + }); + + describe('测试 safeJsonStringify 函数', () => { + it('应该序列化有效对象', () => { + const obj = { key: 'value', number: 123 }; + const result = utils.safeJsonStringify(obj); + + const parsed = JSON.parse(result); + expect(parsed).toEqual(obj); + }); + + it('应该为循环引用返回默认值', () => { + const obj = {}; + obj.circular = obj; + + const result = utils.safeJsonStringify(obj, '{"error":"circular"}'); + + expect(result).toBe('{"error":"circular"}'); + expect(consoleSpy.warn).toHaveBeenCalledWith( + expect.stringContaining('Failed to stringify object'), + ); + }); + + it('应该在未提供默认值时返回空对象', () => { + const obj = {}; + obj.circular = obj; + + const result = utils.safeJsonStringify(obj); + + expect(result).toBe('{}'); + }); + }); + + describe('测试 isEmpty 函数', () => { + it('应该对空字符串返回 true', () => { + expect(utils.isEmpty('')).toBe(true); + }); + + it('应该对仅包含空白的字符串返回 true', () => { + expect(utils.isEmpty(' ')).toBe(true); + expect(utils.isEmpty('\t\n')).toBe(true); + }); + + it('应该对 null 返回 true', () => { + expect(utils.isEmpty(null)).toBe(true); + }); + + it('应该对 undefined 返回 true', () => { + expect(utils.isEmpty(undefined)).toBe(true); + }); + + it('应该对非字符串值返回 true', () => { + expect(utils.isEmpty(123)).toBe(true); + expect(utils.isEmpty({})).toBe(true); + expect(utils.isEmpty([])).toBe(true); + }); + + it('应该对非空字符串返回 false', () => { + expect(utils.isEmpty('hello')).toBe(false); + expect(utils.isEmpty(' hello ')).toBe(false); + }); + }); + + describe('测试 truncateString 函数', () => { + it('如果字符串短于最大长度应该返回原字符串', () => { + const str = 'hello'; + const result = utils.truncateString(str, 10); + + expect(result).toBe(str); + }); + + it('应该使用默认后缀截断字符串', () => { + const str = 'hello world'; + const result = utils.truncateString(str, 8); + + expect(result).toBe('hello...'); + expect(result.length).toBe(8); + }); + + it('应该使用自定义后缀截断字符串', () => { + const str = 'hello world'; + const result = utils.truncateString(str, 10, ' [more]'); + + expect(result).toBe('hel [more]'); + expect(result.length).toBe(10); + }); + + it('应该处理 null 输入', () => { + expect(utils.truncateString(null, 10)).toBe(''); + }); + + it('应该处理非字符串输入', () => { + expect(utils.truncateString(123, 10)).toBe(''); + }); + + it('应该处理最大长度等于后缀长度的边界情况', () => { + const result = utils.truncateString('hello', 3); + expect(result).toBe('...'); + }); + }); + + describe('测试 cleanMobile 函数', () => { + it('应该清理并验证有效的手机号码', () => { + expect(utils.cleanMobile('138-0013-8000')).toBe('13800138000'); + expect(utils.cleanMobile('139 0013 9000')).toBe('13900139000'); + expect(utils.cleanMobile('(150) 1234-5678')).toBe('15012345678'); + }); + + it('应该对无效的手机号码返回 null', () => { + expect(utils.cleanMobile('12345678901')).toBeNull(); // 不是1开头 + expect(utils.cleanMobile('1234567890')).toBeNull(); // 位数不够 + expect(utils.cleanMobile('123456789012')).toBeNull(); // 位数太多 + expect(utils.cleanMobile('12012345678')).toBeNull(); // 第二位不是3-9 + }); + + it('应该对非字符串输入返回 null', () => { + expect(utils.cleanMobile(null)).toBeNull(); + expect(utils.cleanMobile(undefined)).toBeNull(); + expect(utils.cleanMobile(123)).toBeNull(); + }); + + it('应该对空字符串返回 null', () => { + expect(utils.cleanMobile('')).toBeNull(); + }); + + it('应该处理所有有效的手机号前缀', () => { + const validPrefixes = ['13', '14', '15', '16', '17', '18', '19']; + validPrefixes.forEach(prefix => { + const mobile = `${prefix}012345678`; + expect(utils.cleanMobile(mobile)).toBe(mobile); + }); + }); + }); + + describe('测试 isValidUrl 函数', () => { + it('应该对有效的 URL 返回 true', () => { + expect(utils.isValidUrl('https://example.com')).toBe(true); + expect(utils.isValidUrl('http://example.com')).toBe(true); + expect(utils.isValidUrl('ftp://example.com')).toBe(true); + expect(utils.isValidUrl('https://example.com/path?query=value')).toBe(true); + }); + + it('应该对无效的 URL 返回 false', () => { + expect(utils.isValidUrl('not-a-url')).toBe(false); + expect(utils.isValidUrl('http://')).toBe(false); + expect(utils.isValidUrl('')).toBe(false); + }); + + it('应该对非字符串输入返回 false', () => { + expect(utils.isValidUrl(null)).toBe(false); + expect(utils.isValidUrl(undefined)).toBe(false); + expect(utils.isValidUrl(123)).toBe(false); + }); + }); + + describe('测试 isValidHttpUrl 函数', () => { + it('应该对有效的 HTTP/HTTPS URL 返回 true', () => { + expect(utils.isValidHttpUrl('https://example.com')).toBe(true); + expect(utils.isValidHttpUrl('http://example.com')).toBe(true); + }); + + it('应该对非 HTTP URL 返回 false', () => { + expect(utils.isValidHttpUrl('ftp://example.com')).toBe(false); + expect(utils.isValidHttpUrl('file:///path/to/file')).toBe(false); + }); + + it('应该对无效的 URL 返回 false', () => { + expect(utils.isValidHttpUrl('not-a-url')).toBe(false); + expect(utils.isValidHttpUrl('')).toBe(false); + }); + }); + + describe('测试 createError 函数', () => { + it('应该只使用消息创建错误', () => { + const error = utils.createError('Test error'); + + expect(error).toBeInstanceOf(Error); + expect(error.message).toBe('Test error'); + expect(error.code).toBeUndefined(); + expect(error.details).toBeUndefined(); + }); + + it('应该使用消息和代码创建错误', () => { + const error = utils.createError('Test error', 'TEST_CODE'); + + expect(error.message).toBe('Test error'); + expect(error.code).toBe('TEST_CODE'); + }); + + it('应该使用消息、代码和详情创建错误', () => { + const details = { key: 'value' }; + const error = utils.createError('Test error', 'TEST_CODE', details); + + expect(error.message).toBe('Test error'); + expect(error.code).toBe('TEST_CODE'); + expect(error.details).toBe(details); + }); + }); + + describe('测试 setActionOutput 函数', () => { + it('应该在未设置 GITHUB_OUTPUT 时使用旧格式', () => { + restoreEnv = mockEnvVars({ GITHUB_OUTPUT: '' }); + + utils.setActionOutput('test_name', 'test_value'); + + expect(consoleSpy.log).toHaveBeenCalledWith('::set-output name=test_name::test_value'); + }); + + it('应该在设置 GITHUB_OUTPUT 时使用新的 GitHub Actions 格式', () => { + // Create a temporary file path for testing + const tempFile = '/tmp/test_github_output'; + restoreEnv = mockEnvVars({ GITHUB_OUTPUT: tempFile }); + + // Since we can't easily mock require('fs') in this context, + // we'll test that the function doesn't throw and doesn't log + expect(() => { + utils.setActionOutput('test_name', 'test_value'); + }).not.toThrow(); + + // Verify that console.log was not called (which would indicate legacy format) + expect(consoleSpy.log).not.toHaveBeenCalledWith('::set-output name=test_name::test_value'); + }); + }); + + describe('测试 setActionFailed 函数', () => { + it('应该记录错误并退出进程', () => { + const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => {}); + + utils.setActionFailed('Test failure'); + + expect(consoleSpy.log).toHaveBeenCalledWith('::error::Test failure'); + expect(mockExit).toHaveBeenCalledWith(1); + + mockExit.mockRestore(); + }); + }); + + describe('测试 getEnv 函数', () => { + it('应该返回环境变量值', () => { + restoreEnv = mockEnvVars({ TEST_VAR: 'test_value' }); + + const result = utils.getEnv('TEST_VAR'); + + expect(result).toBe('test_value'); + }); + + it('应该在未设置环境变量时返回默认值', () => { + const result = utils.getEnv('NON_EXISTENT_VAR', 'default_value'); + + expect(result).toBe('default_value'); + }); + + it('应该在未提供默认值时返回空字符串', () => { + const result = utils.getEnv('NON_EXISTENT_VAR'); + + expect(result).toBe(''); + }); + }); + + describe('测试 isDebugMode 函数', () => { + it('应该在 DEBUG=true 时返回 true', () => { + restoreEnv = mockEnvVars({ DEBUG: 'true' }); + + expect(utils.isDebugMode()).toBe(true); + }); + + it('应该在 NODE_ENV=development 时返回 true', () => { + restoreEnv = mockEnvVars({ NODE_ENV: 'development' }); + + expect(utils.isDebugMode()).toBe(true); + }); + + it('应该在生产模式中返回 false', () => { + restoreEnv = mockEnvVars({ DEBUG: 'false', NODE_ENV: 'production' }); + + expect(utils.isDebugMode()).toBe(false); + }); + + it('应该处理不区分大小写的 DEBUG 值', () => { + restoreEnv = mockEnvVars({ DEBUG: 'TRUE' }); + + expect(utils.isDebugMode()).toBe(true); + }); + }); + + describe('测试 formatDuration 函数', () => { + it('应该格式化毫秒', () => { + expect(utils.formatDuration(500)).toBe('500ms'); + expect(utils.formatDuration(999)).toBe('999ms'); + }); + + it('应该格式化秒', () => { + expect(utils.formatDuration(1000)).toBe('1.00s'); + expect(utils.formatDuration(1500)).toBe('1.50s'); + expect(utils.formatDuration(2345)).toBe('2.35s'); + }); + + it('应该处理零持续时间', () => { + expect(utils.formatDuration(0)).toBe('0ms'); + }); + }); + + describe('测试 createRetryConfig 函数', () => { + it('应该创建默认重试配置', () => { + const config = utils.createRetryConfig(); + + expect(config.maxRetries).toBe(3); + expect(config.delay).toBe(1000); + expect(config.backoffMultiplier).toBe(1.5); + expect(typeof config.getDelay).toBe('function'); + }); + + it('应该创建自定义重试配置', () => { + const config = utils.createRetryConfig(5, 2000, 2); + + expect(config.maxRetries).toBe(5); + expect(config.delay).toBe(2000); + expect(config.backoffMultiplier).toBe(2); + }); + + it('应该为尝试次数计算正确的延迟', () => { + const config = utils.createRetryConfig(3, 1000, 2); + + expect(config.getDelay(0)).toBe(1000); // 1000 * 2^0 + expect(config.getDelay(1)).toBe(2000); // 1000 * 2^1 + expect(config.getDelay(2)).toBe(4000); // 1000 * 2^2 + }); + }); + + describe('测试 maskSensitiveInfo 函数', () => { + it('应该在文本中屏蔽敏感信息', () => { + const result = utils.maskSensitiveInfo('1234567890', 2, 2); + + expect(result).toBe('12******90'); + }); + + it('应该使用自定义屏蔽字符', () => { + const result = utils.maskSensitiveInfo('1234567890', 2, 2, 'X'); + + expect(result).toBe('12XXXXXX90'); + }); + + it('如果字符串太短应该屏蔽整个字符串', () => { + const result = utils.maskSensitiveInfo('123', 2, 2); + + expect(result).toBe('***'); + }); + + it('应该处理非字符串输入', () => { + expect(utils.maskSensitiveInfo(null)).toBe(''); + expect(utils.maskSensitiveInfo(123)).toBe(''); + }); + + it('应该处理空字符串', () => { + expect(utils.maskSensitiveInfo('')).toBe(''); + }); + + it('应该使用默认参数', () => { + const result = utils.maskSensitiveInfo('1234567890123456'); + + expect(result).toBe('1234********3456'); + }); + }); + + describe('测试 validateRequiredEnvVars 函数', () => { + it('应该在所有必需变量都存在时不抛出异常', () => { + restoreEnv = mockEnvVars({ + VAR1: 'value1', + VAR2: 'value2', + VAR3: 'value3', + }); + + expect(() => { + utils.validateRequiredEnvVars(['VAR1', 'VAR2', 'VAR3']); + }).not.toThrow(); + }); + + it('应该在缺少必需变量时抛出异常', () => { + restoreEnv = mockEnvVars({ VAR1: 'value1' }); + + expect(() => { + utils.validateRequiredEnvVars(['VAR1', 'VAR2', 'VAR3']); + }).toThrow('Missing required environment variables: VAR2, VAR3'); + }); + + it('应该抛出带有正确代码和详情的错误', () => { + try { + utils.validateRequiredEnvVars(['MISSING_VAR']); + } catch (error) { + assertError(error, 'Missing required environment variables', 'MISSING_ENV_VARS'); + expect(error.details.missing).toEqual(['MISSING_VAR']); + } + }); + + it('应该处理空数组', () => { + expect(() => { + utils.validateRequiredEnvVars([]); + }).not.toThrow(); + }); + }); +}); diff --git a/vitest.config.js b/vitest.config.js new file mode 100644 index 0000000..163b3ab --- /dev/null +++ b/vitest.config.js @@ -0,0 +1,60 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + // 测试环境 + environment: 'node', + + // 测试文件匹配模式 + include: ['tests/**/*.test.js'], + + // 排除文件 + exclude: ['node_modules/**', 'coverage/**'], + + // 全局设置 + globals: true, + + // 覆盖率配置 + coverage: { + provider: 'v8', + reporter: ['text', 'html', 'lcov'], + reportsDirectory: 'coverage', + include: ['src/**/*.js'], + exclude: [ + 'tests/**', + 'node_modules/**', + 'coverage/**' + ], + // 覆盖率阈值 + thresholds: { + lines: 80, + functions: 80, + branches: 70, + statements: 80 + } + }, + + // 测试超时设置 + testTimeout: 10000, + + // 钩子超时设置 + hookTimeout: 10000, + + // 并发设置 + pool: 'threads', + poolOptions: { + threads: { + singleThread: false + } + }, + + // 监听模式配置 + watch: false, + + // 报告器配置 + reporter: ['verbose'], + + // 设置文件 + setupFiles: ['tests/setup/global-setup.js'] + } +}); \ No newline at end of file