提交qiming-dev-inject

This commit is contained in:
Codex
2026-06-01 12:53:19 +08:00
parent 96af3b324b
commit 9e9486b7c2
76 changed files with 6397 additions and 0 deletions

1
.gitignore vendored
View File

@@ -7,6 +7,7 @@
!/qiming-file-server/
!/qiming-mobile/
!/qiming-claude-code-acp-ts/
!/qiming-dev-inject/
!/qimingclaw/
!/qimingcode/

76
qiming-dev-inject/.gitignore vendored Normal file
View File

@@ -0,0 +1,76 @@
# 依赖
node_modules/
.npm/
.pnp
.pnp.js
# 日志
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
pnpm-debug.log*
# 缓存
.cache/
.parcel-cache/
.npm/
.eslintcache
.next/
out/
dist/
build/
# 操作系统
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# 编辑器
.vscode/
.idea/
*.swp
*.swo
*~
.cursor/
# 环境变量
.env
.env.local
.env*.local
.env.development
.env.production
.env.test
# 测试项目依赖和构建
test-projects/*/node_modules/
test-projects/*/.next/
test-projects/*/dist/
test-projects/*/build/
test-projects/*/.env*
# 临时文件
*.tmp
*.temp
server.log
# 打包文件
*.tgz
*.tar.gz
# 覆盖率报告
coverage/
.nyc_output/
# IDE 临时文件
*.code-workspace
# Lock files
pnpm-lock.yaml
yarn.lock
package-lock.json

View File

@@ -0,0 +1,73 @@
# 测试文件和目录
test/
test-*.js
test.html
test-simplified.js
test-projects/
*.test.js
# 示例和演示文件
# EXAMPLES.md (保留在发布包中,因为用户可能需要)
react-iframe-test.html
simple-react-app.html
simplified-test.html
server.js
start-test.js
test-cli.js
test-framework.js
test-framework-simple.js
test-full-system.js
# 日志文件
*.log
server.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# 依赖
node_modules/
.pnp
.pnp.js
# 构建输出
dist/
build/
.next/
out/
# 缓存
.cache/
.npm/
.parcel-cache/
.eslintcache
# 环境配置
.env
.env.local
.env*.local
# IDE 配置
.vscode/
.idea/
*.swp
*.swo
*~
# 系统文件
.DS_Store
Thumbs.db
*.tmp
# Git
.git/
.gitignore
.gitattributes
# CI/CD
.github/
.gitlab-ci.yml
.travis.yml
# 文档(除了 package.json 中 files 指定的)
# 注意README.md 等会自动包含

View File

@@ -0,0 +1,149 @@
# 使用示例
## 安装方式
```bash
# 静默安装(推荐,自动确认,无需交互)
npx -y @xagi/dev-inject install --framework
# 使用默认脚本地址 (/sdk/dev-monitor.js)
npx @xagi/dev-inject install --framework
# 使用自定义地址
npx @xagi/dev-inject install --remote=http://localhost:9000/dev-monitor.js --framework
# 使用 pnpm dlx
pnpm dlx @xagi/dev-inject install --framework
```
## 1. 本地开发环境监控
将监控脚本复制到你的项目静态资源目录:
```bash
# 复制监控脚本到你的项目中
cp dev-inject/scripts/dev-monitor.js public/scripts/
```
然后注入到 HTML
```bash
# 注入本地监控脚本
npx @xagi/dev-inject install --remote=/scripts/dev-monitor.js
# 或者指定特定 HTML 文件
npx @xagi/dev-inject install --remote=/scripts/dev-monitor.js --file=./public/index.html
```
## 2. 远程开发服务器监控
如果你的监控脚本运行在开发服务器上:
```bash
# 注入远程脚本
npx @xagi/dev-inject install --remote=http://localhost:9000/dev-monitor.js
# 或使用 pnpm
pnpm dlx @xagi/dev-inject install --remote=http://localhost:9000/dev-monitor.js
```
## 3. 在 React/Vite/Next.js 项目中使用(框架感知模式)
```bash
# Vite 项目 - 使用框架感知注入(推荐)
npx @xagi/dev-inject install --remote=http://localhost:9000/dev-monitor.js --framework
# Next.js App Router 项目
npx @xagi/dev-inject install --remote=http://localhost:9000/dev-monitor.js --framework
# Next.js Pages Router 项目
npx @xagi/dev-inject install --remote=/scripts/dev-monitor.js --framework
# Create React App 项目
npx @xagi/dev-inject install --remote=/scripts/dev-monitor.js --framework
```
## 4. 开发完成后清理
```bash
# 移除框架注入
npx @xagi/dev-inject uninstall --framework
# 移除传统注入
npx @xagi/dev-inject uninstall
# 只移除特定文件的注入脚本
pnpm dlx @xagi/dev-inject uninstall --file=./index.html
```
## 5. 预览模式
在实际注入前,可以先预览将要执行的操作:
```bash
npx @xagi/dev-inject install --remote=/scripts/dev-monitor.js --dry-run --verbose
```
## 监控面板使用
在浏览器控制台中:
```javascript
// 显示监控面板
DevMonitor.showPanel()
// 获取监控数据
DevMonitor.getData()
// 导出数据到文件
DevMonitor.exportData()
// 清除监控数据
DevMonitor.clearData()
// 设置日志级别
DevMonitor.setLogLevel('debug') // debug, info, warn, error
```
## 完整工作流示例
```bash
# 1. 进入项目目录
cd my-project
# 2. 预览注入操作(框架感知模式)
npx @xagi/dev-inject install --remote=http://localhost:9000/dev-monitor.js --framework --dry-run
# 3. 执行注入(框架感知模式)
# 方式一:静默安装(推荐,一键完成)
npx -y @xagi/dev-inject install --framework
# 方式二:标准安装
npx @xagi/dev-inject install --remote=http://localhost:9000/dev-monitor.js --framework
# 4. 启动开发服务器
npm run dev
# 5. 在浏览器中测试功能,打开控制台使用 DevMonitor API
# 6. 开发完成后清理注入
npx @xagi/dev-inject uninstall --framework
```
## 快速启动示例
使用远程监控脚本(推荐):
```bash
# 静默安装(最推荐,一键完成)
npx -y @xagi/dev-inject install --framework
# 框架感知注入(自动检测最佳注入方式)
npx @xagi/dev-inject install --remote=http://localhost:9000/dev-monitor.js --framework
# 或使用 pnpm
pnpm dlx @xagi/dev-inject install --remote=http://localhost:9000/dev-monitor.js --framework
# 卸载
npx @xagi/dev-inject uninstall --framework
```

22
qiming-dev-inject/LICENSE Normal file
View File

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

257
qiming-dev-inject/README.md Normal file
View File

@@ -0,0 +1,257 @@
# 网页应用智能脚本注入
🚀 **框架感知的开发环境脚本注入工具**
支持 Vite、Next.js、React 等现代框架的智能脚本注入,提供零侵入式的开发期监控方案。
## 🌟 特性
### 框架感知注入
- 🟢 **Vite**: 自动注入 Vite 插件到 `vite.config.js`
- 🟢 **Next.js App Router**: 注入到 `app/layout.tsx`
- 🟢 **Next.js Pages Router**: 注入到 `pages/_document.tsx`
- 🟢 **Create React App**: 注入到 `public/index.html`
- 🟢 **传统 HTML**: 直接修改 HTML 文件
### 核心功能
- ✅ 支持远程 URL 和绝对路径注入
- ✅ 自动检测项目类型和最佳注入方式
- ✅ 智能去重,避免重复注入
- ✅ 支持卸载功能
- ✅ 预览模式dry-run
- ✅ 详细日志输出
- ✅ 零侵入式修改
## 安装
使用 npx推荐
```bash
# 查看帮助信息
npx @xagi/dev-inject --help
# 查看版本信息
npx @xagi/dev-inject --version
# 静默安装(自动确认,无需交互)-y 选项
npx -y @xagi/dev-inject install --framework
```
或使用 pnpm dlx
```bash
pnpm dlx @xagi/dev-inject --help
```
## 🚀 快速开始
### 框架感知注入(推荐)
```bash
# 静默安装(推荐,无需确认)
npx -y @xagi/dev-inject install --framework
# 使用默认脚本地址 (/sdk/dev-monitor.js)
npx @xagi/dev-inject install --framework
# 或指定自定义地址
npx @xagi/dev-inject install --remote=http://localhost:9000/dev-monitor.js --framework
# 使用 pnpm
pnpm dlx @xagi/dev-inject install --framework
# 查看检测到的项目类型
npx @xagi/dev-inject install --framework --verbose
```
### 传统 HTML 注入
```bash
# 注入远程脚本
npx @xagi/dev-inject install --remote=http://localhost:9000/dev-monitor.js
# 注入本地脚本
npx @xagi/dev-inject install --remote=/scripts/dev-monitor.js
# 指定特定文件
npx @xagi/dev-inject install --remote=/scripts/monitor.js --file=./public/index.html
```
### 预览和卸载
```bash
# 预览将要执行的操作
npx @xagi/dev-inject install --remote=/scripts/monitor.js --framework --dry-run
# 移除框架注入
npx @xagi/dev-inject uninstall --framework
# 移除传统注入
npx @xagi/dev-inject uninstall
```
## 📋 支持的脚本格式
### 远程 URL
```bash
--remote=http://localhost:9000/dev-monitor.js
--remote=https://cdn.example.com/monitor.js
```
### 绝对路径
```bash
--remote=/scripts/dev-monitor.js
--remote=/assets/monitor.js
```
## 🎯 配置选项
| 选项 | 描述 | 示例 |
|------|------|------|
| `--remote` | 脚本地址(可选,默认值: /sdk/dev-monitor.js | `--remote=/scripts/dev-monitor.js` |
| `--framework, -f` | 使用框架感知注入 | `--framework` |
| `--file` | 指定 HTML 文件 | `--file=./public/index.html` |
| `--dry-run` | 预览模式 | `--dry-run` |
| `--verbose` | 详细输出 | `--verbose` |
| `--help` | 显示帮助 | `--help` |
| `--version` | 显示版本 | `--version` |
## 监控脚本功能
`dev-inject/scripts/dev-monitor.js` 提供完整的开发监控功能:
### 错误监控
- 全局 JavaScript 错误捕获
- Promise Rejection 监控
- 资源加载错误监控
- iframe 通信错误
### 性能监控
- 页面加载时间分析
- 网络请求时间统计
- 长任务检测
- DOM Ready 性能
### 开发工具
- 实时监控面板
- 数据导出功能
- 控制台日志拦截
- iframe 通信支持
### 使用监控面板
```javascript
// 在浏览器控制台执行
DevMonitor.showPanel(); // 显示监控面板
DevMonitor.getData(); // 获取监控数据
DevMonitor.exportData(); // 导出监控数据
DevMonitor.clearData(); // 清除监控数据
```
## 配置选项
| 选项 | 描述 | 示例 |
|------|------|------|
| `--remote` | 脚本地址(必需) | `--remote=/scripts/dev-monitor.js` |
| `--file` | 指定 HTML 文件 | `--file=./public/index.html` |
| `--dry-run` | 预览模式 | `--dry-run` |
| `--verbose` | 详细输出 | `--verbose` |
| `--help` | 显示帮助 | `--help` |
| `--version` | 显示版本 | `--version` |
## 工作原理
1. **查找 HTML 文件**:自动查找项目中的 HTML 文件(优先 index.html
2. **脚本注入**:在 `</head>` 标签前注入脚本
3. **去重处理**:移除之前注入的相同脚本,避免重复
4. **智能检测**支持多种项目结构React、Vite、Next.js 等)
## 开发和测试
```bash
# 进入项目目录
cd dev-inject
# 安装依赖
npm install
# 链接到全局(用于本地测试)
npm link
# 测试帮助信息
dev-inject --help
# 测试注入功能(预览模式)
dev-inject install --remote=/scripts/dev-monitor.js --dry-run
# 取消链接
npm unlink
```
## 📦 发布到 npm
```bash
# 确保你已经登录 npm
npm login
# 发布到 npm
npm publish
# 发布后验证
npx @xagi/dev-inject --version
```
## 项目结构
```
dev-inject/
├── bin/
│ └── index.js # CLI 入口文件
├── lib/
│ ├── args.js # 参数解析
│ ├── inject.js # 核心注入功能
│ ├── utils.js # 工具函数
│ └── help.js # 帮助信息
├── scripts/
│ └── dev-monitor.js # 监控脚本
├── package.json # 项目配置
└── README.md # 说明文档
```
## 兼容性
- ✅ Node.js >= 14.0.0
- ✅ 所有现代浏览器
- ✅ React、Vue、Angular、Next.js 等
- ✅ Vite、Webpack、Create React App 等构建工具
## 📦 发布配置
本项目已配置 npm publish 时仅包含必要的文件:
**包含的文件:**
-`bin/` - CLI 可执行文件
-`lib/` - 核心库文件
-`scripts/` - 监控脚本
-`*.md` - 文档文件
-`LICENSE` - 许可证
**排除的文件:**
-`test/` - 测试文件
-`test-projects/` - 测试项目
- ❌ 所有 `*.test.js` 文件
- ❌ 测试和演示 HTML 文件
- ❌ 日志和缓存文件
- ❌ 开发工具配置
完整配置请查看 `package.json``files` 字段和 `.npmignore` 文件。
## 注意事项
1. **绝对路径**:确保静态文件服务器可以访问该路径
2. **重复注入**:工具会自动处理重复注入问题
3. **权限**:确保有 HTML 文件的写入权限
4. **备份**:建议在操作前备份重要文件
## 许可证
MIT

View File

@@ -0,0 +1,54 @@
#!/usr/bin/env node
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
import { parseArgs } from '../lib/args.js';
import { installScript } from '../lib/inject.js';
import { uninstallScript } from '../lib/inject.js';
import { showHelp, showVersion } from '../lib/help.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
async function main() {
try {
const args = process.argv.slice(2);
// 显示帮助信息
if (args.includes('--help') || args.includes('-h')) {
showHelp();
return;
}
// 显示版本信息
if (args.includes('--version') || args.includes('-v')) {
showVersion();
return;
}
const parsed = parseArgs(args);
if (!parsed.command) {
console.error('❌ 错误:请指定命令 (install 或 uninstall)');
console.log('使用 --help 查看帮助信息');
process.exit(1);
}
switch (parsed.command) {
case 'install':
await installScript(parsed.options);
break;
case 'uninstall':
await uninstallScript(parsed.options);
break;
default:
console.error(`❌ 未知命令: ${parsed.command}`);
process.exit(1);
}
} catch (error) {
console.error('❌ 执行失败:', error.message);
process.exit(1);
}
}
main();

View File

@@ -0,0 +1,48 @@
export function parseArgs(args) {
const result = {
command: null,
options: {},
};
// 解析命令
if (args[0] === 'install' || args[0] === 'uninstall') {
result.command = args[0];
args = args.slice(1);
} else if (args.length === 0) {
return result;
}
// 解析选项
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg.startsWith('--remote=')) {
result.options.remote = arg.split('=')[1];
} else if (arg === '--remote' && i + 1 < args.length) {
result.options.remote = args[i + 1];
i++; // 跳过下一个参数
} else if (arg.startsWith('--file=')) {
result.options.file = arg.split('=')[1];
} else if (arg === '--file' && i + 1 < args.length) {
result.options.file = args[i + 1];
i++; // 跳过下一个参数
} else if (arg === '--dry-run') {
result.options.dryRun = true;
} else if (arg === '--verbose') {
result.options.verbose = true;
} else if (arg === '--framework' || arg === '-f') {
result.options.framework = true;
} else if (!arg.startsWith('--')) {
// 未知参数,可能是错误输入
console.warn(`⚠️ 忽略未知参数: ${arg}`);
}
}
// 为 install 命令设置 remote 默认值
if (result.command === 'install' && !result.options.remote) {
result.options.remote = '/sdk/dev-monitor.js';
}
return result;
}

View File

@@ -0,0 +1,112 @@
/**
* 代码清理工具类 - 清理多余的空白字符和逗号
*/
export class CodeCleaner {
/**
* 清理多余的空白行
* @param {string} content - 要清理的内容
* @param {boolean} preserveDoubleNewline - 是否保留双换行(默认 true
* @returns {string} 清理后的内容
*/
static cleanBlankLines(content, preserveDoubleNewline = true) {
if (preserveDoubleNewline) {
// 保留单行空白,清理多余的连续空白行
return content.replace(/\n\s*\n\s*\n+/g, '\n\n');
} else {
// 清理所有多余空白行,只保留单个换行
return content.replace(/\n\s*\n+/g, '\n');
}
}
/**
* 清理行尾的空白字符
* @param {string} content - 要清理的内容
* @returns {string} 清理后的内容
*/
static cleanTrailingSpaces(content) {
return content.replace(/[ \t]+$/gm, '');
}
/**
* 清理多余的逗号
* @param {string} content - 要清理的内容
* @returns {string} 清理后的内容
*/
static cleanCommas(content) {
// 1. 清理单独的逗号行(整行都是逗号和空白)
content = content.replace(/^\s*,\s*\n/gm, '');
// 2. 清理行首的逗号和空白
content = content.replace(/^\s*,\s+/gm, '');
// 3. 清理连续的多个逗号
content = content.replace(/,\s*,\s*/g, ',');
return content;
}
/**
* 清理空的标签对
* @param {string} content - 要清理的内容
* @param {string} tagName - 标签名
* @returns {string} 清理后的内容
*/
static cleanEmptyTags(content, tagName) {
const regex = new RegExp(`<${tagName}[^>]*>\\s*</${tagName}>`, 'g');
return content.replace(regex, '');
}
/**
* 清理标签中间的空白行
* @param {string} content - 要清理的内容
* @param {string} tagName - 标签名(可以是正则表达式)
* @returns {string} 清理后的内容
*/
static cleanTagWhitespace(content, tagName) {
// 清理如 <Head>\n\n\n</Head> -> <Head>\n</Head>
content = content.replace(
new RegExp(`(<${tagName}[^>]*>)\\s*\\n\\s*\\n(\\s*</${tagName}>)`, 'g'),
'$1\n$2'
);
return content;
}
/**
* 综合清理:清理所有常见的代码格式问题
* @param {string} content - 要清理的内容
* @param {object} options - 清理选项
* @returns {string} 清理后的内容
*/
static cleanAll(content, options = {}) {
const {
blankLines = true,
trailingSpaces = true,
commas = false,
emptyTags = true,
tagWhitespace = true
} = options;
if (blankLines) {
content = this.cleanBlankLines(content);
}
if (trailingSpaces) {
content = this.cleanTrailingSpaces(content);
}
if (commas) {
content = this.cleanCommas(content);
}
if (emptyTags) {
['head', 'Head'].forEach(tag => {
content = this.cleanEmptyTags(content, tag);
});
}
if (tagWhitespace) {
['Head', 'head'].forEach(tag => {
content = this.cleanTagWhitespace(content, tag);
});
}
return content;
}
}

View File

@@ -0,0 +1,133 @@
import fs from 'fs';
import path from 'path';
import { lookupFiles } from './utils.js';
/**
* 文件查找工具类 - 支持多种后缀名尝试机制
*/
export class FileFinder {
/**
* 查找具有多种后缀名的文件
* @param {string} basePath - 基础路径(不包含后缀)
* @param {string[]} extensions - 后缀名数组,按优先级排序
* @param {string} startDir - 起始目录
* @returns {string|null} 找到的文件路径或 null
*/
static findWithExtensions(basePath, extensions = ['.tsx', '.ts', '.jsx', '.js'], startDir = process.cwd()) {
for (const ext of extensions) {
const fullPath = path.join(startDir, basePath + ext);
if (fs.existsSync(fullPath)) {
return basePath + ext;
}
}
return null;
}
/**
* 查找多个候选文件
* @param {string[]} candidates - 候选文件路径数组
* @param {string} startDir - 起始目录
* @returns {string[]} 找到的文件路径数组
*/
static findMultiple(candidates, startDir = process.cwd()) {
const foundFiles = [];
for (const candidate of candidates) {
if (candidate === '*.html') {
// 特殊处理:查找所有 HTML 文件
const htmlFiles = lookupFiles(startDir);
foundFiles.push(...htmlFiles);
} else if (fs.existsSync(path.join(startDir, candidate))) {
foundFiles.push(candidate);
}
}
return foundFiles;
}
/**
* 查找框架特定的入口文件
* @param {string} projectType - 项目类型
* @param {string} startDir - 起始目录
* @returns {string[]} 找到的文件路径数组
*/
static findFrameworkEntry(projectType, startDir = process.cwd()) {
const foundFiles = [];
switch (projectType) {
case 'vite':
// 尝试查找 vite.config 文件
const viteConfig = this.findWithExtensions('vite.config', ['.js', '.ts', '.mjs'], startDir);
if (viteConfig) {
foundFiles.push(viteConfig);
}
break;
case 'next-pages':
// 尝试查找 _document 文件
const documentFile = this.findWithExtensions('pages/_document', ['.tsx', '.jsx', '.ts', '.js'], startDir);
if (documentFile) {
foundFiles.push(documentFile);
}
// 尝试查找 _app 文件
const appFile = this.findWithExtensions('pages/_app', ['.tsx', '.jsx', '.ts', '.js'], startDir);
if (appFile) {
foundFiles.push(appFile);
}
break;
case 'next-app':
// 尝试查找 layout 文件
const layoutFile = this.findWithExtensions('app/layout', ['.tsx', '.jsx', '.ts', '.js'], startDir);
if (layoutFile) {
foundFiles.push(layoutFile);
}
// 尝试查找 globals.css
const globalsCss = 'app/globals.css';
if (fs.existsSync(path.join(startDir, globalsCss))) {
foundFiles.push(globalsCss);
}
break;
case 'next-hybrid':
// 尝试查找 app/layout 文件
const appLayoutFile = this.findWithExtensions('app/layout', ['.tsx', '.jsx', '.ts', '.js'], startDir);
if (appLayoutFile) {
foundFiles.push(appLayoutFile);
}
// 尝试查找 pages/_document 文件
const pagesDocumentFile = this.findWithExtensions('pages/_document', ['.tsx', '.jsx', '.ts', '.js'], startDir);
if (pagesDocumentFile) {
foundFiles.push(pagesDocumentFile);
}
break;
case 'create-react-app':
// 尝试查找 HTML 文件
const htmlFiles = ['public/index.html', 'index.html'];
foundFiles.push(...this.findMultiple(htmlFiles, startDir));
break;
case 'html-static':
// 尝试查找 HTML 文件
const staticHtmlFiles = ['index.html', '*.html'];
foundFiles.push(...this.findMultiple(staticHtmlFiles, startDir));
break;
default:
// 默认查找 HTML 文件
const defaultHtmlFiles = ['index.html', '*.html'];
foundFiles.push(...this.findMultiple(defaultHtmlFiles, startDir));
break;
}
return foundFiles;
}
}
// 导出便捷方法
export function findFrameworkEntry(projectType, startDir = process.cwd()) {
return FileFinder.findFrameworkEntry(projectType, startDir);
}

View File

@@ -0,0 +1,850 @@
import {
readFile,
writeFile,
parseRemoteType,
generateScriptTag,
injectScriptToHtml,
removeInjectedScripts,
log,
logInfo,
logSuccess,
logError
} from './utils.js';
import { FileFinder } from './file-finder.js';
import { CodeCleaner } from './code-cleaner.js';
import fs from 'fs';
import path from 'path';
// 检测项目类型
export function detectProjectType(startDir = process.cwd()) {
const indicators = {
'vite': ['vite.config.js', 'vite.config.ts'],
'next-pages': ['next.config.js', 'pages/'],
'next-app': ['next.config.js', 'app/'],
'create-react-app': ['public/index.html', 'src/'],
'webpack': ['webpack.config.js'],
'html-static': ['index.html', '.html']
};
try {
// 按优先级顺序检测
const priorityOrder = ['vite', 'next-app', 'next-pages', 'create-react-app', 'webpack', 'html-static'];
for (const type of priorityOrder) {
const files = indicators[type];
for (const file of files) {
if (fs.existsSync(path.join(startDir, file))) {
// 特殊处理 Next.js
if (type.startsWith('next-')) {
const hasPages = fs.existsSync(path.join(startDir, 'pages'));
const hasApp = fs.existsSync(path.join(startDir, 'app'));
if (hasApp && hasPages) {
return 'next-hybrid'; // 两个目录都存在
} else if (hasApp) {
return 'next-app';
} else if (hasPages) {
return 'next-pages';
}
}
return type;
}
}
}
} catch (error) {
logError(`项目类型检测失败: ${error.message}`);
}
return 'unknown';
}
// 查找框架特定的入口文件 - 支持多次尝试机制
export function findFrameworkEntry(projectType, startDir = process.cwd()) {
return FileFinder.findFrameworkEntry(projectType, startDir);
}
// 为 Vite 项目注入脚本插件
export function injectToViteConfig(filePath, remote, scriptTag) {
try {
// 检查文件是否存在
if (!fs.existsSync(path.join(process.cwd(), filePath))) {
log(`Vite 配置文件 ${filePath} 不存在,跳过`, true);
return false;
}
let content = readFile(filePath);
// 先移除之前的注入(如果存在)- 支持新旧两种格式
// 使用字符串查找方式,确保能移除所有注入块(包括多个重复的情况)
const startMarker = '// <!-- DEV-INJECT-START -->';
const endMarker = '// <!-- DEV-INJECT-END -->';
let startIndex = content.indexOf(startMarker);
while (startIndex !== -1) {
const endIndex = content.indexOf(endMarker, startIndex);
if (endIndex !== -1) {
// 找到结束标记,移除从开始标记到结束标记后的所有内容
// 注意:逗号在 })() 和注释结束之间,所以会被包含在移除范围内
let removeEnd = endIndex + endMarker.length;
// 移除结束标记后的空白和换行
while (removeEnd < content.length && (content[removeEnd] === ' ' || content[removeEnd] === '\t' || content[removeEnd] === '\n')) {
removeEnd++;
}
// 移除整个块:从开始标记(包括前面的换行和空白)到结束标记后
// 向前查找,移除开始标记前的换行和空白
let removeStart = startIndex;
while (removeStart > 0 && (content[removeStart - 1] === ' ' || content[removeStart - 1] === '\t' || content[removeStart - 1] === '\n')) {
removeStart--;
if (content[removeStart] === '\n') {
removeStart++;
break;
}
}
content = content.slice(0, removeStart) + content.slice(removeEnd);
} else {
// 没有找到结束标记,只移除开始标记
content = content.slice(0, startIndex) + content.slice(startIndex + startMarker.length);
}
// 继续查找下一个注入块
startIndex = content.indexOf(startMarker);
}
// 移除旧版格式(向后兼容)
content = content.replace(
/\/\/\s*dev-inject-plugin[\s\S]*?}\s*}\s*,?\s*/g,
''
);
// 清理多余的逗号和空行
// 移除连续的逗号
content = content.replace(/,\s*,/g, ',');
// 移除数组开头的逗号
content = content.replace(/\[\s*,/g, '[');
// 移除数组元素之间多余的逗号
content = content.replace(/,\s*\)/g, ')');
content = content.replace(/,\s*\n\s*\]/g, '\n ]');
content = content.replace(/,\s*\n\s*\[/g, ' [');
// 清理多余的空行(保留最多一个空行)
content = content.replace(/\n\s*\n\s*\n+/g, '\n\n');
// 生成插件代码,传入原始 remote URL 而不是 scriptTag
const pluginCode = generateVitePlugin(remote, scriptTag);
// 查找 plugins 数组,并在其中插入插件
const pluginsMatch = content.match(/(plugins:\s*\[)([\s\S]*?)(\])/);
if (pluginsMatch) {
const beforePlugins = pluginsMatch[1];
const pluginsContent = pluginsMatch[2].trim();
const afterPlugins = pluginsMatch[3];
// 插入插件到 plugins 数组开头
const newPlugins = beforePlugins + '\n ' + pluginCode + '\n ' + pluginsContent + '\n ' + afterPlugins;
content = content.replace(pluginsMatch[0], newPlugins);
} else {
// 如果没有 plugins 数组,需要创建
log(`未找到 plugins 配置Vite 配置可能不标准: ${filePath}`, true);
return false;
}
writeFile(filePath, content);
logSuccess(`已向 ${filePath} 注入 Vite 插件`);
return true;
} catch (error) {
log(`Vite 插件注入失败: ${error.message}`, true);
return false;
}
}
// 更规范与健壮的版本
function generateVitePlugin(remote, { insertPosition = '</head>' } = {}) {
const safeRemote = JSON.stringify(remote); // 避免注入问题
const pluginName = 'dev-inject';
const scriptId = 'dev-inject-monitor';
const scriptInjection = `
<script data-id="${scriptId}">
(function() {
const remote = ${safeRemote};
const separator = remote.includes('?') ? '&' : '?';
const script = document.createElement('script');
script.src = remote + separator + 't=' + Date.now();
script.dataset.id = '${scriptId}-script';
script.defer = true;
// 防止重复注入
if (!document.querySelector('[data-id="${scriptId}-script"]')) {
document.head.appendChild(script);
}
})();
</script>
`;
return `
// <!-- DEV-INJECT-START -->
{
name: '${pluginName}',
enforce: 'post', // 确保在 HTML 注入阶段最后执行
transformIndexHtml(html) {
if (!html.includes('data-id="${scriptId}"')) {
return html.replace(${JSON.stringify(insertPosition)}, \`${scriptInjection}\\n${insertPosition}\`);
}
return html;
}
},
// <!-- DEV-INJECT-END -->
`;
}
// 为 Next.js 注入脚本到 _document
export function injectToNextDocument(filePath, scriptTag, remote = null) {
try {
// 检查文件是否存在
if (!fs.existsSync(path.join(process.cwd(), filePath))) {
log(`Next.js 文件 ${filePath} 不存在,跳过`, true);
return false;
}
let content = readFile(filePath);
// 检查是否是 _document.tsx/_document.js
if (filePath.includes('_document')) {
return injectToDocumentComponent(content, scriptTag, filePath, remote);
}
// 如果是 _app.tsx/_app.js
if (filePath.includes('_app')) {
return injectToAppComponent(content, scriptTag, filePath, remote);
}
log(`无法识别的 Next.js 文件类型: ${filePath}`, true);
return false;
} catch (error) {
log(`Next.js 注入失败: ${error.message}`, true);
return false;
}
}
// 注入到 Next.js _document 组件
function injectToDocumentComponent(content, scriptTag, filePath, remote = null) {
const isTypeScript = filePath.endsWith('.tsx');
// 先移除之前的注入
content = removeNextDocumentInjection(content);
// 生成脚本加载代码(优先使用动态方式)
let scriptInjection;
if (remote) {
// 使用动态脚本加载器(推荐方式,防止缓存)
const scriptLoader = generateDynamicScriptLoader(remote);
scriptInjection = `{/* DEV-INJECT-START */}
{typeof window !== 'undefined' && (
<script dangerouslySetInnerHTML={{
__html: ${JSON.stringify(scriptLoader.trim())}
}} />
)}
{/* DEV-INJECT-END */}`;
} else {
// 向后兼容:使用旧的 scriptTag 方式
scriptInjection = `{/* DEV-INJECT-START */}
{typeof window !== 'undefined' && (
${scriptTag}
)}
{/* DEV-INJECT-END */}`;
}
// 查找 <Head> 组件
const headRegex = /<Head[^>]*>([\s\S]*?)<\/Head>/;
const headMatch = content.match(headRegex);
if (headMatch) {
// 在 <Head> 组件内注入 - 使用 DEV-INJECT-START/END 标识
const beforeHead = headMatch[1];
const newHead = beforeHead + '\n ' + scriptInjection;
// 修复多余的 < 符号问题
const headAttributes = headMatch[0].match(/<Head([^>]*)>/);
const attributes = headAttributes ? headAttributes[1] : '';
content = content.replace(headRegex, `<Head${attributes}>\n ${newHead}\n </Head>`);
} else {
// 如果没有 <Head> 组件,需要添加
const importReactMatch = content.match(/import.*React.*from.*react/);
if (importReactMatch) {
const afterImport = content.indexOf(importReactMatch[0]) + importReactMatch[0].length;
const headComponent = isTypeScript ? `
import Head from 'next/head';` : `
import Head from 'next/head';`;
content = content.slice(0, afterImport) + headComponent + content.slice(afterImport);
}
// 在 Document 组件的 render 方法中添加 Head
const renderMatch = content.match(/render\(\)\s*{[\s\S]*?return\s*\(/);
if (renderMatch) {
const insertPoint = content.indexOf(renderMatch[0]) + renderMatch[0].length;
let headInsert;
if (remote) {
// 使用动态脚本加载器
const scriptLoader = generateDynamicScriptLoader(remote);
headInsert = `
<Head>
{/* DEV-INJECT-START */}
{typeof window !== 'undefined' && (
<script dangerouslySetInnerHTML={{
__html: ${JSON.stringify(scriptLoader.trim())}
}} />
)}
{/* DEV-INJECT-END */}
</Head>`;
} else {
// 向后兼容:使用旧的 scriptTag 方式
headInsert = `
<Head>
{/* DEV-INJECT-START */}
{typeof window !== 'undefined' && ${scriptTag.replace(/<script[^>]*>|<\/script>/g, '')}}
{/* DEV-INJECT-END */}
</Head>`;
}
content = content.slice(0, insertPoint) + headInsert + content.slice(insertPoint);
}
}
writeFile(filePath, content);
return true;
}
// 注入到 Next.js _app 组件
function injectToAppComponent(content, scriptTag, filePath, remote = null) {
// 移除之前的注入
content = removeNextAppInjection(content);
// 在 _app 组件中注入,使用 useEffect
const scriptLoader = generateScriptLoader(scriptTag, false, remote);
const useEffectInsert = `
{/* DEV-INJECT-START */}
${scriptLoader}
{/* DEV-INJECT-END */}`;
// 查找现有的 useEffect 或组件末尾
const existingUseEffect = content.match(/useEffect\([^)]*\)\s*=>\s*{[\s\S]*?},\s*\[?\s*\]?\s*\);?/);
if (existingUseEffect) {
// 在现有 useEffect 中添加
content = content.replace(
existingUseEffect[0],
existingUseEffect[0].replace('}, []);', ' \n' + useEffectInsert.replace(/useEffect\([^)]*\)\s*=>\s*{\n/, '').replace(/}, \[\];/, '') + '\n }, [];')
);
} else {
// 添加新的 useEffect
const componentEndMatch = content.match(/export default.*|}\s*$/);
if (componentEndMatch) {
const insertPoint = content.lastIndexOf(componentEndMatch[0]);
content = content.slice(0, insertPoint) + useEffectInsert + '\n\n' + content.slice(insertPoint);
}
}
writeFile(filePath, content);
return true;
}
// 为 Next.js App Router 注入
export function injectToNextAppLayout(filePath, scriptTag, remote = null) {
try {
// 检查文件是否存在
if (!fs.existsSync(path.join(process.cwd(), filePath))) {
log(`Next.js App Router 文件 ${filePath} 不存在,跳过`, true);
return false;
}
let content = readFile(filePath);
// 移除之前的注入
content = removeNextAppLayoutInjection(content);
// 在 layout.tsx/js 中注入到 <head> 内
const scriptLoader = generateScriptLoader(scriptTag, true, remote);
// 查找 <head> 标签
const headRegex = /<head[^>]*>([\s\S]*?)<\/head>/i;
const headMatch = content.match(headRegex);
if (headMatch) {
// 在 <head> 内注入
const beforeHead = headMatch[1];
const newHead = beforeHead + '\n ' + scriptLoader;
content = content.replace(headRegex, `<head${headMatch[0].slice(5, -6)}${newHead}</head>`);
} else {
// 如果没有 <head> 标签,在 <html> 标签后添加
const htmlRegex = /<html[^>]*>/;
const htmlMatch = content.match(htmlRegex);
if (htmlMatch) {
const insertPoint = content.indexOf(htmlMatch[0]) + htmlMatch[0].length;
const headTag = '\n <head>\n ' + scriptLoader + '\n </head>';
content = content.slice(0, insertPoint) + headTag + content.slice(insertPoint);
}
}
writeFile(filePath, content);
return true;
} catch (error) {
log(`Next.js App Router 注入失败: ${error.message}`, true);
return false;
}
}
// 生成动态脚本加载器 - 使用运行时时间戳防止缓存
function generateDynamicScriptLoader(remote, scriptId = 'dev-inject-monitor') {
const safeRemote = JSON.stringify(remote); // 避免注入问题
return `
(function() {
const remote = ${safeRemote};
const separator = remote.includes('?') ? '&' : '?';
const script = document.createElement('script');
script.src = remote + separator + 't=' + Date.now();
script.dataset.id = '${scriptId}-script';
script.defer = true;
// 防止重复注入
if (!document.querySelector('[data-id="${scriptId}-script"]')) {
document.head.appendChild(script);
}
})();
`;
}
// 生成脚本加载器 - 使用 DEV-INJECT 标识和动态时间戳
function generateScriptLoader(scriptTag, isHead = false, remote = null) {
// 如果提供了 remote URL使用动态脚本加载器推荐方式
if (remote) {
const scriptLoader = generateDynamicScriptLoader(remote);
if (isHead) {
// 对于 App Router layout注入到 <head> 内
return `{/* DEV-INJECT-START */}
{process.env.NODE_ENV === 'development' && (
<script dangerouslySetInnerHTML={{
__html: ${JSON.stringify(scriptLoader.trim())}
}} />
)}
{/* DEV-INJECT-END */}`;
}
// useEffect 方式(用于 _app 组件)
return `useEffect(() => {
if (typeof window !== 'undefined') {
${scriptLoader.trim()}
}
}, []);`;
}
// 向后兼容:如果没有提供 remote使用旧的 scriptTag 方式
if (isHead) {
// 对于 App Router layout注入到 <head> 内
return `{/* DEV-INJECT-START */}
{process.env.NODE_ENV === 'development' && (
${scriptTag}
)}
{/* DEV-INJECT-END */}`;
}
// 旧版 useEffect 方式(保留向后兼容)
return `useEffect(() => {
if (typeof window !== 'undefined') {
${scriptTag.replace(/<script[^>]*>|<\/script>/g, '').trim()}
}
}, []);`;
}
// 通用的移除注入内容函数
function removeInjectionContent(content, startMarker = '/* DEV-INJECT-START */', endMarker = '/* DEV-INJECT-END */') {
let startIndex = content.indexOf(startMarker);
while (startIndex !== -1) {
const endIndex = content.indexOf(endMarker, startIndex);
if (endIndex !== -1) {
// 找到这两行前后的换行符
const startLineIndex = content.lastIndexOf('\n', startIndex - 1);
const endLineEnd = content.indexOf('\n', endIndex + endMarker.length);
if (startLineIndex !== -1 && endLineEnd !== -1) {
// 移除整块内容
content = content.slice(0, startLineIndex + 1) + content.slice(endLineEnd + 1);
} else if (startIndex >= 0 && endIndex >= startIndex) {
// 简单的字符串切片
const endPos = endIndex + endMarker.length;
const endLine = content.indexOf('\n', endPos);
if (endLine !== -1) {
content = content.slice(0, startIndex) + content.slice(endLine + 1);
} else {
content = content.slice(0, startIndex) + content.slice(endIndex + endMarker.length);
}
}
}
// 继续查找下一个
startIndex = content.indexOf(startMarker);
}
// 清理多余的空白行
content = content.replace(/\n\s*\n\s*\n+/g, '\n\n');
return content;
}
// 移除 Next.js Document 注入
function removeNextDocumentInjection(content) {
return removeInjectionContent(content);
}
// 移除 Next.js App 注入
function removeNextAppInjection(content) {
// 移除 DEV-INJECT 相关的 useEffect只保留新版标准标识
const patterns = [
/\{\/\*\s*DEV-INJECT-START\s*\/\*\/[\s\S]*?\{\/\*\s*DEV-INJECT-END\s*\/\*\/\}/g,
/useEffect\(\(\) => \{\s*if \(typeof window !== 'undefined'\) \{[\s\S]*?\}\s*\}, \[\]\);?/g
];
patterns.forEach(pattern => {
content = content.replace(pattern, '');
});
return content;
}
// 移除 Next.js App Layout 注入
function removeNextAppLayoutInjection(content) {
// 使用字符串匹配移除 DEV-INJECT-START/END 之间的内容
const startMarker = '/* DEV-INJECT-START */';
const endMarker = '/* DEV-INJECT-END */';
let startIndex = content.indexOf(startMarker);
while (startIndex !== -1) {
const endIndex = content.indexOf(endMarker, startIndex);
if (endIndex !== -1) {
// 找到这两行前后的换行符
const startLineIndex = content.lastIndexOf('\n', startIndex - 1);
const endLineEnd = content.indexOf('\n', endIndex + endMarker.length);
if (startLineIndex !== -1 && endLineEnd !== -1) {
// 移除整块内容
content = content.slice(0, startLineIndex + 1) + content.slice(endLineEnd + 1);
} else {
const endPos = endIndex + endMarker.length;
const endLine = content.indexOf('\n', endPos);
if (endLine !== -1) {
content = content.slice(0, startIndex) + content.slice(endLine + 1);
} else {
content = content.slice(0, startIndex) + content.slice(endIndex + endMarker.length);
}
}
}
startIndex = content.indexOf(startMarker);
}
// 使用 CodeCleaner 清理代码格式
content = CodeCleaner.cleanAll(content, {
blankLines: true,
trailingSpaces: true,
emptyTags: true,
tagWhitespace: true
});
return content;
}
// 移除 Vite 插件注入
function removeViteInjection(content) {
// 移除新版标准标识的注入(优先),包括后面的逗号
content = content.replace(
/\/\/ <!-- DEV-INJECT-START -->[\s\S]*?\/\/ <!-- DEV-INJECT-END -->\s*,?\s*/g,
''
);
// 移除旧版格式的注入(向后兼容)
content = content.replace(
/\/\/ dev-inject-plugin[\s\S]*?}\s*}\s*,?\s*/g,
''
);
// 使用 CodeCleaner 清理代码格式
content = CodeCleaner.cleanAll(content, {
blankLines: true,
commas: true
});
return content;
}
// 智能卸载 - 智能移除不同框架的注入
export function smartUninstall(options = {}) {
const { file, dryRun = false, verbose = false } = options;
logInfo('开始移除注入的脚本...');
// 检测项目类型
const projectType = detectProjectType();
// 只在 verbose 模式下输出检测到的项目类型(避免重复)
log(`检测到项目类型: ${projectType}`, verbose);
// 查找入口文件
const entryFiles = file ? [file] : findFrameworkEntry(projectType);
if (entryFiles.length === 0) {
logInfo('未找到需要处理的文件');
return;
}
log(`找到 ${entryFiles.length} 个文件:`, verbose);
entryFiles.forEach(f => log(` - ${f}`, verbose));
let successCount = 0;
for (const entryFile of entryFiles) {
try {
if (projectType === 'next-pages' || projectType === 'next-hybrid') {
if (entryFile.includes('_document')) {
successCount += uninstallFromNextDocument(entryFile, dryRun, verbose);
} else if (entryFile.includes('_app')) {
successCount += uninstallFromNextApp(entryFile, dryRun, verbose);
}
} else if (projectType === 'next-app') {
successCount += uninstallFromNextAppLayout(entryFile, dryRun, verbose);
} else if (projectType === 'vite') {
successCount += uninstallFromViteConfig(entryFile, dryRun, verbose);
} else if (projectType === 'html-static' || projectType === 'create-react-app') {
successCount += uninstallFromHTML(entryFile, dryRun, verbose);
}
} catch (error) {
logError(`处理文件 ${entryFile} 失败: ${error.message}`);
}
}
if (successCount > 0) {
logSuccess(`成功清理 ${successCount} 个文件`);
} else {
logInfo('没有找到需要清理的注入脚本');
}
}
// 从 HTML 文件卸载
function uninstallFromHTML(filePath, dryRun, verbose) {
const content = readFile(filePath);
const originalContent = content;
const newContent = removeInjectedScripts(content);
if (newContent === originalContent) {
log(`文件 ${filePath} 无需清理`, verbose);
return 0;
}
if (!dryRun) {
writeFile(filePath, newContent);
logSuccess(`已从 ${filePath} 移除注入的脚本`);
} else {
logInfo(`[DRY RUN] 将从 ${filePath} 移除注入的脚本`);
}
return 1;
}
// 从 Vite 配置卸载
function uninstallFromViteConfig(filePath, dryRun, verbose) {
const content = readFile(filePath);
const originalContent = content;
const newContent = removeViteInjection(content);
if (newContent === originalContent) {
log(`文件 ${filePath} 无需清理`, verbose);
return 0;
}
if (!dryRun) {
writeFile(filePath, newContent);
logSuccess(`已从 ${filePath} 移除注入的 Vite 插件`);
} else {
logInfo(`[DRY RUN] 将从 ${filePath} 移除注入的插件`);
}
return 1;
}
// 从 Next.js Document 卸载
function uninstallFromNextDocument(filePath, dryRun, verbose) {
const content = readFile(filePath);
const originalContent = content;
const newContent = removeNextDocumentInjection(content);
if (newContent === originalContent) {
log(`文件 ${filePath} 无需清理`, verbose);
return 0;
}
if (!dryRun) {
writeFile(filePath, newContent);
logSuccess(`已从 ${filePath} 移除注入的脚本`);
} else {
logInfo(`[DRY RUN] 将从 ${filePath} 移除注入的脚本`);
}
return 1;
}
// 从 Next.js App 卸载
function uninstallFromNextApp(filePath, dryRun, verbose) {
const content = readFile(filePath);
const originalContent = content;
const newContent = removeNextAppInjection(content);
if (newContent === originalContent) {
log(`文件 ${filePath} 无需清理`, verbose);
return 0;
}
if (!dryRun) {
writeFile(filePath, newContent);
logSuccess(`已从 ${filePath} 移除注入的脚本`);
} else {
logInfo(`[DRY RUN] 将从 ${filePath} 移除注入的脚本`);
}
return 1;
}
// 从 Next.js App Layout 卸载
function uninstallFromNextAppLayout(filePath, dryRun, verbose) {
const content = readFile(filePath);
const originalContent = content;
const newContent = removeNextAppLayoutInjection(content);
if (newContent === originalContent) {
log(`文件 ${filePath} 无需清理`, verbose);
return 0;
}
if (!dryRun) {
writeFile(filePath, newContent);
logSuccess(`已从 ${filePath} 移除注入的脚本`);
} else {
logInfo(`[DRY RUN] 将从 ${filePath} 移除注入的脚本`);
}
return 1;
}
// 智能注入 - 根据项目类型选择最佳注入方式
export function smartInject(options = {}) {
const { remote, file, dryRun = false, verbose = false } = options;
// 检测项目类型
const projectType = detectProjectType();
// 只在 verbose 模式下输出检测到的项目类型(避免重复)
log(`检测到项目类型: ${projectType}`, verbose);
// 生成脚本标签
const remoteType = parseRemoteType(remote);
const scriptTag = generateScriptTag(remote, remoteType);
log(`脚本类型: ${remoteType}`, verbose);
log(`脚本标签: ${scriptTag}`, verbose);
// 查找入口文件
const entryFiles = file ? [file] : findFrameworkEntry(projectType);
if (entryFiles.length === 0) {
logError('未找到需要处理的文件');
return false;
}
log(`找到 ${entryFiles.length} 个文件:`, verbose);
entryFiles.forEach(f => log(` - ${f}`, verbose));
let successCount = 0;
const errors = [];
for (const entryFile of entryFiles) {
try {
// 检查文件是否存在
if (!fs.existsSync(path.join(process.cwd(), entryFile))) {
log(`文件 ${entryFile} 不存在,跳过`, verbose);
continue;
}
// 处理 Next.js Pages Router
if (projectType === 'next-pages' || projectType === 'next-hybrid') {
if (entryFile.includes('_document') || entryFile.includes('_app')) {
if (injectToNextDocument(entryFile, scriptTag, remote)) {
successCount++;
}
}
}
// 处理 Next.js App Router
else if (projectType === 'next-app') {
if (injectToNextAppLayout(entryFile, scriptTag, remote)) {
successCount++;
}
}
// 处理 Vite 项目
else if (projectType === 'vite') {
if (injectToViteConfig(entryFile, remote, scriptTag)) {
successCount++;
}
}
// 处理 HTML 和 create-react-app
else if (projectType === 'html-static' || projectType === 'create-react-app') {
let htmlContent = readFile(entryFile);
const originalContent = htmlContent;
// 传入 remote URL 以使用动态时间戳防止缓存
const newContent = injectScriptToHtml(htmlContent, scriptTag, 'dev-inject', remote);
if (newContent !== originalContent) {
if (!dryRun) {
writeFile(entryFile, newContent);
logSuccess(`已注入脚本到 ${entryFile}`);
} else {
logInfo(`[DRY RUN] 将注入脚本到 ${entryFile}`);
}
successCount++;
} else {
log(`文件 ${entryFile} 已包含注入内容`, verbose);
}
}
} catch (error) {
// 收集错误,但不立即报错
errors.push(`处理文件 ${entryFile} 失败: ${error.message}`);
log(`处理文件 ${entryFile} 失败: ${error.message}`, verbose);
}
}
// 只有在所有尝试都失败后才报错
if (successCount > 0) {
logSuccess(`成功处理 ${successCount} 个文件`);
// 只在 verbose 模式下输出详细信息
log(`项目类型: ${projectType}`, verbose);
log(`脚本地址: ${remote}`, verbose);
// 统一输出重启提示(不重复)
if (projectType.startsWith('next-')) {
logInfo('💡 Next.js 项目: 脚本已注入到组件中,需要重启开发服务器');
} else if (projectType === 'vite') {
logInfo('💡 Vite 项目: 脚本已注入到配置文件,需要重启开发服务器');
}
// 如果有部分错误,显示警告
if (errors.length > 0) {
logInfo(`⚠️ 部分文件处理失败: ${errors.length} 个错误`);
errors.forEach(error => log(` - ${error}`, verbose));
}
} else {
// 所有尝试都失败了
logError('没有成功处理任何文件');
if (errors.length > 0) {
logError('错误详情:');
errors.forEach(error => logError(` - ${error}`));
}
}
return successCount > 0;
}

View File

@@ -0,0 +1,115 @@
import { readFileSync } from 'fs';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
export function showHelp() {
console.log(`
@xagi/dev-inject - 开发环境脚本注入工具
用法:
npx @xagi/dev-inject <命令> [选项]
或使用 pnpm:
pnpm dlx @xagi/dev-inject <命令> [选项]
命令:
install 注入脚本到 HTML 文件
uninstall 从 HTML 文件中移除注入的脚本
选项:
--remote <url|path> 脚本地址(可选,默认值: /sdk/dev-monitor.js
- 远程URL: http://localhost:9000/monitor.js
- 绝对路径: /scripts/monitor.js
--file <path> 指定要处理的 HTML 文件(可选)
- 如果不指定,会自动查找项目中的 HTML 文件
--dry-run 预览模式,显示将要执行的操作但不实际修改文件
--verbose 显示详细输出信息
--framework, -f 使用框架感知注入模式
- 自动检测 Vite、Next.js 等框架
- 使用最佳注入方式,无需手动修改 HTML
--help, -h 显示帮助信息
--version, -v 显示版本信息
示例:
# 静默安装(推荐,自动确认,无需交互)
npx -y @xagi/dev-inject install --framework
# 使用默认脚本地址 (/sdk/dev-monitor.js)
npx @xagi/dev-inject install
# 使用默认地址 + 框架感知注入
npx @xagi/dev-inject install --framework
# 传统 HTML 注入
npx @xagi/dev-inject install --remote=http://localhost:9000/dev-monitor.js
# 框架感知注入(推荐)
npx @xagi/dev-inject install --remote=http://localhost:9000/dev-monitor.js --framework
# Vite 项目自动注入
npx @xagi/dev-inject install --remote=/scripts/dev-monitor.js -f --verbose
# Next.js App Router 注入
npx @xagi/dev-inject install --remote=http://localhost:9000/dev-monitor.js --framework
# Next.js Pages Router 注入
npx @xagi/dev-inject install --remote=/scripts/dev-monitor.js -f
# 指定特定 HTML 文件(传统模式)
npx @xagi/dev-inject install --remote=/scripts/monitor.js --file=./public/index.html
# 使用 pnpm dlx
pnpm dlx @xagi/dev-inject install --remote=http://localhost:9000/dev-monitor.js --framework
# 预览将要执行的操作
npx @xagi/dev-inject install --remote=/scripts/monitor.js --framework --dry-run
# 移除框架注入
npx @xagi/dev-inject uninstall --framework
# 移除传统注入
npx @xagi/dev-inject uninstall
支持的脚本格式:
- 远程 HTTP/HTTPS URL
- 以 / 开头的绝对路径(相对于网站根目录)
框架支持:
🟢 Vite: 自动注入 Vite 插件到 vite.config.js
🟢 Next.js App Router: 注入到 app/layout.tsx
🟢 Next.js Pages Router: 注入到 pages/_document.tsx
🟢 Create React App: 注入到 public/index.html
🟢 传统 HTML: 直接修改 HTML 文件
框架注入优势:
✅ 自动检测项目类型
✅ 使用最佳注入方式
✅ 仅在开发环境生效
✅ 零侵入性修改
✅ 支持热重载
注意:
- 使用 --framework 会自动选择最佳注入方式
- 框架注入后需要重启开发服务器
- 传统模式脚本会被注入到 HTML 文件的 </head> 标签之前
- 重复执行 install 会自动替换之前的注入
- 使用 uninstall 可以完全移除所有注入的脚本
`);
}
export function showVersion() {
try {
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const packagePath = join(__dirname, '../package.json');
const packageJson = JSON.parse(readFileSync(packagePath, 'utf8'));
console.log(`@xagi/dev-inject v${packageJson.version}`);
} catch (error) {
console.log('@xagi/dev-inject v1.0.0');
}
}

View File

@@ -0,0 +1,206 @@
import {
lookupFiles,
readFile,
writeFile,
parseRemoteType,
generateScriptTag,
injectScriptToHtml,
removeInjectedScripts,
log,
logInfo,
logSuccess,
logError,
} from './utils.js';
import {
detectProjectType,
smartInject as smartInjectFramework,
} from './framework-inject.js';
import { readFileSync } from 'fs';
import { dirname, join } from 'path';
import { fileURLToPath } from 'url';
/**
* 获取当前包版本号
*/
function getVersion() {
try {
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const packagePath = join(__dirname, '..', 'package.json');
const packageJson = JSON.parse(readFileSync(packagePath, 'utf8'));
return packageJson.version;
} catch (error) {
return 'unknown';
}
}
/**
* 安装脚本 - 支持框架感知
*/
export async function installScript(options = {}) {
const {
remote,
file,
dryRun = false,
verbose = false,
framework = false,
} = options;
const version = getVersion();
logInfo(`开始执行脚本注入... (v${version})`);
// 检测是否使用框架注入模式
if (framework) {
logInfo('使用框架感知注入模式...');
// 检测项目类型(只在 verbose 模式下输出,避免重复)
const projectType = detectProjectType();
log(`检测到项目类型: ${projectType}`, verbose);
// 对于现代框架,使用智能注入
if (
['vite', 'next-app', 'next-pages', 'next-hybrid'].includes(projectType)
) {
try {
const success = smartInjectFramework({ remote, dryRun, verbose });
// smartInjectFramework 内部已经输出了所有必要的日志,这里不需要重复输出
return success;
} catch (error) {
logError(`框架注入失败: ${error.message}`);
logInfo('回退到传统 HTML 注入模式...');
}
}
}
// 传统 HTML 注入模式(兜底方案)
logInfo('使用传统 HTML 注入模式...');
// 查找 HTML 文件
const targetFiles = file ? [file] : lookupFiles();
if (targetFiles.length === 0) {
throw new Error('未找到 HTML 文件');
}
log(`找到 ${targetFiles.length} 个 HTML 文件:`, verbose);
targetFiles.forEach(f => log(` - ${f}`, verbose));
// 解析远程脚本类型和生成脚本标签
const remoteType = parseRemoteType(remote);
const scriptTag = generateScriptTag(remote, remoteType);
log(`脚本类型: ${remoteType}`, verbose);
log(`脚本标签: ${scriptTag}`, verbose);
// 处理每个 HTML 文件
let successCount = 0;
for (const htmlFile of targetFiles) {
try {
// 读取 HTML 内容
let htmlContent = readFile(htmlFile);
const originalContent = htmlContent;
// 移除之前注入的脚本(避免重复)
htmlContent = removeInjectedScripts(htmlContent);
// 注入新脚本 - 传入 remote URL 以使用动态时间戳
const newContent = injectScriptToHtml(htmlContent, scriptTag, 'dev-inject', remote);
// 检查是否有变化
if (newContent === originalContent) {
log(`文件 ${htmlFile} 无需更新`, verbose);
successCount++;
continue;
}
// 写入文件
if (!dryRun) {
writeFile(htmlFile, newContent);
logSuccess(`已注入脚本到 ${htmlFile}`);
} else {
logInfo(`[DRY RUN] 将注入脚本到 ${htmlFile}`);
}
successCount++;
} catch (error) {
logError(`处理文件 ${htmlFile} 失败: ${error.message}`);
}
}
if (successCount > 0) {
logSuccess(`成功处理 ${successCount} 个文件`);
logInfo(`脚本地址: ${remote}`);
if (remoteType === 'absolute-path') {
logInfo('提示:请确保静态文件服务器可以访问该路径');
}
} else {
logError('没有成功处理任何文件');
}
}
/**
* 卸载脚本从 HTML 文件
*/
export async function uninstallScript(options = {}) {
const { file, dryRun = false, verbose = false, framework } = options;
// 如果指定了 framework 标志,使用框架感知卸载
if (framework) {
const { smartUninstall } = await import('./framework-inject.js');
return smartUninstall(options);
}
logInfo('开始移除注入的脚本...');
// 查找 HTML 文件
const targetFiles = file ? [file] : lookupFiles();
if (targetFiles.length === 0) {
logInfo('未找到 HTML 文件');
return;
}
log(`找到 ${targetFiles.length} 个 HTML 文件:`, verbose);
targetFiles.forEach(f => log(` - ${f}`, verbose));
// 处理每个 HTML 文件
let successCount = 0;
for (const htmlFile of targetFiles) {
try {
// 读取 HTML 内容
let htmlContent = readFile(htmlFile);
const originalContent = htmlContent;
// 移除注入的脚本
const newContent = removeInjectedScripts(htmlContent);
// 检查是否有变化
if (newContent === originalContent) {
log(`文件 ${htmlFile} 无需清理`, verbose);
successCount++;
continue;
}
// 写入文件
if (!dryRun) {
writeFile(htmlFile, newContent);
logSuccess(`已从 ${htmlFile} 移除注入的脚本`);
} else {
logInfo(`[DRY RUN] 将从 ${htmlFile} 移除注入的脚本`);
}
successCount++;
} catch (error) {
logError(`处理文件 ${htmlFile} 失败: ${error.message}`);
}
}
if (successCount > 0) {
logSuccess(`成功清理 ${successCount} 个文件`);
} else {
logInfo('没有找到需要清理的注入脚本');
}
}

View File

@@ -0,0 +1,275 @@
import { readFileSync, writeFileSync, existsSync, readdirSync, statSync } from 'fs';
import { join } from 'path';
/**
* 查找项目中的 HTML 文件
*/
export function lookupFiles(startDir = process.cwd()) {
const htmlFiles = [];
function searchDirectory(dir) {
try {
const files = readdirSync(dir);
for (const file of files) {
const fullPath = join(dir, file);
const stat = statSync(fullPath);
if (stat.isDirectory()) {
// 跳过 node_modules 等目录
if (!['node_modules', '.git', 'dist', 'build'].includes(file)) {
searchDirectory(fullPath);
}
} else if (file.endsWith('.html')) {
htmlFiles.push(fullPath);
}
}
} catch (error) {
// 忽略无法访问的目录
}
}
searchDirectory(startDir);
// 优先返回 index.html否则返回第一个找到的 HTML 文件
const indexHtml = htmlFiles.find(file => file.endsWith('index.html'));
return indexHtml ? [indexHtml] : htmlFiles;
}
/**
* 读取文件内容
*/
export function readFile(filePath) {
try {
return readFileSync(filePath, 'utf8');
} catch (error) {
throw new Error(`无法读取文件 ${filePath}: ${error.message}`);
}
}
/**
* 写入文件内容
*/
export function writeFile(filePath, content) {
try {
writeFileSync(filePath, content, 'utf8');
} catch (error) {
throw new Error(`无法写入文件 ${filePath}: ${error.message}`);
}
}
/**
* 解析远程脚本类型
*/
export function parseRemoteType(remote) {
if (remote.startsWith('http://') || remote.startsWith('https://')) {
return 'url';
} else if (remote.startsWith('/')) {
return 'absolute-path';
} else {
throw new Error(
`不支持的远程路径格式: ${remote}。请使用完整 URL (http://...) 或绝对路径 (/path/to/script.js)`
);
}
}
/**
* 生成脚本标签
*/
export function generateScriptTag(remote, type) {
// 所有类型都添加时间戳参数避免缓存
const separator = remote.includes('?') ? '&' : '?';
const timestamp = Date.now();
if (type === 'url') {
return `<script src="${remote}${separator}t=${timestamp}"></script>`;
} else if (type === 'absolute-path') {
return `<script src="${remote}${separator}t=${timestamp}"></script>`;
} else {
throw new Error(`未知的远程类型: ${type}`);
}
}
/**
* 从 HTML 内容中移除注入的脚本 - 同时清理前后的空白行
*/
export function removeInjectedScripts(htmlContent, identifier = 'dev-inject') {
// 使用标准的开始/结束标识移除注入的代码
// 匹配包括前后换行符和空白在内的完整块
// 使用非贪婪匹配确保只匹配一个注入块
const injectBlockRegex = /(\r?\n)?\s*<!-- DEV-INJECT-START -->[\s\S]*?<!-- DEV-INJECT-END -->\s*(\r?\n)?/g;
let cleaned = htmlContent.replace(injectBlockRegex, (match, beforeNewline, afterNewline) => {
// 如果匹配块前后都有换行符,只保留一个换行符
if (beforeNewline && afterNewline) {
return '\n';
}
// 如果只有前面的换行符,保留它
if (beforeNewline) {
return beforeNewline;
}
// 如果只有后面的换行符,保留它
if (afterNewline) {
return afterNewline;
}
// 如果都没有,返回空字符串
return '';
});
// 清理移除后可能产生的多余空白行
// 清理 <head> 标签后的多余空白行(保留一个换行)
cleaned = cleaned.replace(/(<head[^>]*>)\s*\n\s*\n+(\s*)/g, '$1\n$2');
// 清理 </head> 前的多余空白行(保留一个换行)
cleaned = cleaned.replace(/(\s*)\n\s*\n+(\s*<\/head>)/g, '$1\n$2');
// 清理连续的多个空白行(保留最多一个空行)
cleaned = cleaned.replace(/\n\s*\n\s*\n+/g, '\n\n');
// 清理空行(只包含空白字符的行)
cleaned = cleaned.replace(/^\s+$/gm, '');
return cleaned;
}
/**
* 向 HTML 中注入脚本 - 使用动态时间戳防止缓存
*/
export function injectScriptToHtml(
htmlContent,
scriptTag,
identifier = 'dev-inject',
remote = null
) {
// 先清除之前的注入
htmlContent = removeInjectedScripts(htmlContent, identifier);
// 生成脚本内容 - 优先使用动态时间戳方式
// 注意:脚本内容前后不包含额外的换行符,由插入逻辑统一处理
let scriptContent;
if (remote) {
// 使用动态脚本加载器(推荐方式,防止缓存)
const safeRemote = JSON.stringify(remote);
const scriptId = `${identifier}-monitor`;
scriptContent = `<!-- DEV-INJECT-START -->
<script data-id="${scriptId}">
(function() {
const remote = ${safeRemote};
const separator = remote.includes('?') ? '&' : '?';
const script = document.createElement('script');
script.src = remote + separator + 't=' + Date.now();
script.dataset.id = '${scriptId}-script';
script.defer = true;
// 防止重复注入
if (!document.querySelector('[data-id="${scriptId}-script"]')) {
document.head.appendChild(script);
}
})();
</script>
<!-- DEV-INJECT-END -->`;
} else {
// 向后兼容:从 scriptTag 中提取 URL 并添加时间戳
const scriptSrc = scriptTag.match(/src="([^"]*)"/)?.[1] || '';
if (scriptSrc) {
// 提取基础 URL移除可能已存在的时间戳参数
const baseUrl = scriptSrc.split('?')[0];
const separator = scriptSrc.includes('?') ? '&' : '?';
scriptContent = `<!-- DEV-INJECT-START -->
<script>
(function() {
const remote = ${JSON.stringify(baseUrl)};
const separator = remote.includes('?') ? '&' : '?';
const script = document.createElement('script');
script.src = remote + separator + 't=' + Date.now();
script.defer = true;
// 防止重复注入
if (!document.querySelector('script[src*="' + remote.split('/').pop() + '"]')) {
document.head.appendChild(script);
}
})();
</script>
<!-- DEV-INJECT-END -->`;
} else {
// 如果无法提取 URL使用旧的静态方式
scriptContent = `<!-- DEV-INJECT-START -->
${scriptTag}
<!-- DEV-INJECT-END -->`;
}
}
// 查找 </head> 标签
const headEndIndex = htmlContent.lastIndexOf('</head>');
if (headEndIndex !== -1) {
// 在 </head> 之前注入
const beforeHead = htmlContent.substring(0, headEndIndex);
const afterHead = htmlContent.substring(headEndIndex);
// 检查 </head> 前是否有非空白内容
const beforeHeadTrimmed = beforeHead.trimEnd();
const needsNewlineBefore = beforeHeadTrimmed.length > 0 && !beforeHeadTrimmed.endsWith('\n');
// 构建注入内容,统一格式
let result;
if (needsNewlineBefore) {
// 如果前面有内容,添加换行符后插入脚本
result = `${beforeHeadTrimmed}\n${scriptContent}\n${afterHead}`;
} else {
// 如果前面是空白或换行,直接插入(脚本内容已有标识注释)
// 确保脚本前后有适当的换行
const trimmedBefore = beforeHead.trimEnd();
if (trimmedBefore.length === 0) {
// 如果前面完全是空白,直接插入
result = `${beforeHead}${scriptContent}\n${afterHead}`;
} else {
// 前面有内容但末尾是换行,直接插入
result = `${beforeHead}${scriptContent}\n${afterHead}`;
}
}
// 清理可能的多余空白行(保留最多一个空行)
// 清理 <head> 标签后的多余空白行
result = result.replace(/(<head[^>]*>)\s*\n\s*\n+(\s*)/g, '$1\n$2');
// 清理 </head> 前的多余空白行
result = result.replace(/(\s*)\n\s*\n+(\s*<\/head>)/g, '$1\n$2');
// 清理连续的多个空白行
result = result.replace(/\n\s*\n\s*\n+/g, '\n\n');
return result;
} else {
// 如果没有找到 </head>,查找 <body> 标签
const bodyStartIndex = htmlContent.indexOf('<body');
if (bodyStartIndex !== -1) {
const bodyEndIndex = htmlContent.indexOf('>', bodyStartIndex) + 1;
const beforeBody = htmlContent.substring(0, bodyEndIndex);
const afterBody = htmlContent.substring(bodyEndIndex);
return `${beforeBody}
${scriptContent}
${afterBody}`;
} else {
// 如果都没有找到,在文件末尾添加
return `${htmlContent}
${scriptContent}`;
}
}
}
/**
* 日志输出函数
*/
export function log(message, verbose = false) {
if (verbose) {
console.log(`[dev-inject] ${message}`);
}
}
export function logError(message) {
console.error(`❌ [dev-inject] ${message}`);
}
export function logSuccess(message) {
console.log(`✅ [dev-inject] ${message}`);
}
export function logInfo(message) {
console.log(` [dev-inject] ${message}`);
}

View File

@@ -0,0 +1,50 @@
{
"name": "@xagi/dev-inject",
"version": "1.0.4",
"description": "框架感知的开发环境脚本注入工具 - 支持 Vite、Next.js、React 等现代框架的智能脚本注入",
"type": "module",
"main": "lib/index.js",
"bin": {
"dev-inject": "bin/index.js"
},
"files": [
"bin/",
"lib/",
"scripts/",
"*.md",
"LICENSE"
],
"scripts": {
"build": "echo 'No build needed for Node.js CLI'",
"prepare": "chmod +x bin/index.js",
"test": "node scripts/test-projects.js all",
"test:all": "node scripts/test-projects.js all",
"test:install": "node scripts/test-projects.js install",
"test:uninstall": "node scripts/test-projects.js uninstall",
"test:html": "cd test-projects/html-project && node ../../bin/index.js install --remote=/sdk/dev-monitor.js --framework && node ../../bin/index.js uninstall --framework",
"test:vite": "cd test-projects/vite-project && node ../../bin/index.js install --remote=/sdk/dev-monitor.js --framework && node ../../bin/index.js uninstall --framework",
"test:next-pages": "cd test-projects/next-pages-project && node ../../bin/index.js install --remote=/sdk/dev-monitor.js --framework && node ../../bin/index.js uninstall --framework",
"test:next-app": "cd test-projects/next-app-project && node ../../bin/index.js install --remote=/sdk/dev-monitor.js --framework && node ../../bin/index.js uninstall --framework"
},
"keywords": [
"development",
"script-injection",
"html",
"cli",
"remote-script",
"local-script"
],
"author": "dongdada29",
"license": "MIT",
"engines": {
"node": ">=14.0.0"
},
"repository": {
"type": "git",
"url": "git+https://github.com/dongdada29/dev-inject.git"
},
"bugs": {
"url": "https://github.com/dongdada29/dev-inject/issues"
},
"homepage": "https://github.com/dongdada29/dev-inject#readme"
}

View File

@@ -0,0 +1,223 @@
/**
* 开发环境监控脚本
* 提供错误监控、性能监控、通信功能
*/
(function () {
'use strict';
// 配置
const config = {
version: '1.0.0',
enabled: true,
logLevel: 'error', // 只记录错误级别日志
maxErrors: 10, // 减少存储量
maxLogs: 20, // 减少存储量
};
// 简化的监控数据存储
const monitorData = {
errors: [],
basicInfo: {
url: window.location.href,
userAgent: navigator.userAgent.split(' ')[0], // 只保留浏览器名称
},
ready: false,
};
// 简化的日志函数 - 只记录错误
const logger = {
error: (message, details = null) => {
console.error('[Dev-Monitor ERROR]', message, details || '');
const errorData = {
message: typeof message === 'string' ? message : message.toString(),
details: details ? JSON.stringify(details).substring(0, 200) : null, // 限制详细信息长度
timestamp: Date.now(),
};
monitorData.errors.push(errorData);
// 限制错误数量
if (monitorData.errors.length > config.maxErrors) {
monitorData.errors.shift();
}
},
};
// 简化的错误监控
function setupErrorMonitoring() {
// 全局错误捕获
window.addEventListener('error', function (event) {
const errorMsg = `${event.message} at ${event.filename}:${event.lineno}:${event.colno}`;
logger.error(errorMsg);
});
// Promise 错误捕获
window.addEventListener('unhandledrejection', function (event) {
const errorMsg = `Promise rejection: ${event.reason}`;
logger.error(errorMsg);
});
// 资源加载错误 - 只记录关键信息
window.addEventListener(
'error',
function (event) {
if (event.target !== window) {
const source = event.target.src || event.target.href || 'unknown';
logger.error(`Resource failed: ${source}`);
}
},
true
);
}
// 移除复杂的性能监控和控制台拦截,专注于核心错误监控
// 简化的通信功能
function setupCommunication() {
// 向父窗口发送监控数据 - 只在有新错误时发送
function sendToParent() {
if (
window.parent &&
window.parent !== window &&
monitorData.errors.length > 0
) {
try {
const summary = {
type: 'dev-monitor-summary',
errorCount: monitorData.errors.length,
latestError: monitorData.errors[monitorData.errors.length - 1],
url: monitorData.basicInfo.url,
timestamp: Date.now(),
};
window.parent.postMessage(summary, '*');
} catch (e) {
// 静默处理错误
}
}
}
// 监听来自父窗口的请求
window.addEventListener('message', function (event) {
if (event.data && event.data.type === 'dev-monitor-request') {
const summary = {
type: 'dev-monitor-data',
errorCount: monitorData.errors.length,
errors: monitorData.errors.slice(-3), // 只发送最近3个错误
url: monitorData.basicInfo.url,
};
try {
window.parent.postMessage(summary, '*');
} catch (e) {
// 静默处理错误
}
}
});
// 降低发送频率 - 每10秒检查一次
setInterval(sendToParent, 10000);
}
// 简化的全局 API
window.DevMonitor = {
// 获取错误统计
getStats: function () {
return {
errorCount: monitorData.errors.length,
url: monitorData.basicInfo.url,
userAgent: monitorData.basicInfo.userAgent,
latestError: monitorData.errors[monitorData.errors.length - 1] || null,
};
},
// 获取所有错误(限制数量)
getErrors: function () {
return monitorData.errors.slice(-5); // 只返回最近5个错误
},
// 清除错误
clearErrors: function () {
monitorData.errors = [];
console.log('✅ DevMonitor errors cleared');
},
// 显示简化监控面板
showPanel: function () {
// 移除现有面板
const existingPanel = document.getElementById('dev-monitor-panel');
if (existingPanel) {
existingPanel.remove();
}
const panel = document.createElement('div');
panel.id = 'dev-monitor-panel';
panel.style.cssText = `
position: fixed;
top: 10px;
right: 10px;
width: 280px;
background: #1a1a1a;
color: white;
border: 1px solid #333;
border-radius: 6px;
padding: 12px;
font-family: monospace;
font-size: 12px;
z-index: 999999;
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
`;
const errors = monitorData.errors.slice(-3); // 只显示最近3个错误
panel.innerHTML = `
<div style="border-bottom: 1px solid #333; margin-bottom: 10px; padding-bottom: 8px; display: flex; justify-content: space-between; align-items: center;">
<strong style="color: #4CAF50;">DevMonitor</strong>
<button onclick="this.parentElement.parentElement.remove()" style="background: #ff4444; border: none; color: white; padding: 2px 6px; border-radius: 3px; cursor: pointer;">×</button>
</div>
<div style="margin-bottom: 10px;">
<strong>Errors (${monitorData.errors.length}):</strong>
</div>
${
errors.length
? errors
.map(
e => `
<div style="color: #ff6b6b; margin: 4px 0; padding: 4px; background: rgba(255,107,107,0.1); border-radius: 3px; font-size: 11px;">
${e.message}
${e.details ? `<br><small style="color: #999;">${e.details}</small>` : ''}
</div>
`
)
.join('')
: '<div style="color: #666; font-style: italic;">No errors</div>'
}
<div style="margin-top: 10px; padding-top: 10px; border-top: 1px solid #333;">
<button onclick="DevMonitor.clearErrors(); DevMonitor.showPanel();" style="background: #333; border: none; color: white; padding: 6px 12px; border-radius: 3px; cursor: pointer; margin-right: 8px;">Clear</button>
<button onclick="console.log('Stats:', DevMonitor.getStats())" style="background: #2196F3; border: none; color: white; padding: 6px 12px; border-radius: 3px; cursor: pointer;">Stats</button>
</div>
`;
document.body.appendChild(panel);
},
};
// 简化的初始化
function init() {
setupErrorMonitoring();
setupCommunication();
monitorData.ready = true;
// 简化的控制台提示
console.log(
'%cDevMonitor v' + config.version,
'color: #4CAF50; font-weight: bold;',
'- DevMonitor.showPanel()'
);
}
// 立即初始化
init();
})();

View File

@@ -0,0 +1,534 @@
#!/usr/bin/env node
/**
* 测试脚本 - 运行所有 test-projects 的注入和卸载测试
*/
import { execSync } from 'child_process';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
import { existsSync, readFileSync } from 'fs';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const rootDir = join(__dirname, '..');
const testProjectsDir = join(rootDir, 'test-projects');
// 测试项目列表
const testProjects = [
{ name: 'html-project', type: 'html-static' },
{ name: 'vite-project', type: 'vite' },
{ name: 'next-pages-project', type: 'next-pages' },
{ name: 'next-app-project', type: 'next-app' },
];
// 测试配置
const testConfig = {
remote: '/sdk/dev-monitor.js',
remoteUrl: 'https://testagent.xspaceagi.com/sdk/dev-monitor.js',
};
// 颜色输出
const colors = {
reset: '\x1b[0m',
bright: '\x1b[1m',
green: '\x1b[32m',
red: '\x1b[31m',
yellow: '\x1b[33m',
blue: '\x1b[34m',
cyan: '\x1b[36m',
};
function log(message, color = 'reset') {
console.log(`${colors[color]}${message}${colors.reset}`);
}
function logSection(title) {
console.log('\n' + '='.repeat(60));
log(title, 'bright');
console.log('='.repeat(60));
}
function logTest(name) {
log(`\n📦 测试项目: ${name}`, 'cyan');
}
function runCommand(command, cwd) {
try {
const output = execSync(command, {
cwd,
encoding: 'utf8',
stdio: 'pipe',
});
return { success: true, output };
} catch (error) {
return {
success: false,
output: error.stdout || error.stderr || error.message,
error: error.message,
};
}
}
/**
* 测试单个项目的安装(注入)
*/
function testInstall(project, remote, testName) {
const projectPath = join(testProjectsDir, project.name);
if (!existsSync(projectPath)) {
log(` ❌ 项目目录不存在: ${projectPath}`, 'red');
return false;
}
logTest(`${project.name} (${testName})`);
// 1. 先清理之前的注入(可选)
log(' 🔄 步骤 1: 清理之前的注入...', 'yellow');
const uninstallResult = runCommand(
`node ${join(rootDir, 'bin/index.js')} uninstall --framework`,
projectPath
);
if (!uninstallResult.success && !uninstallResult.output.includes('未找到需要清理的注入脚本')) {
log(` ⚠️ 清理警告: ${uninstallResult.output}`, 'yellow');
}
// 2. 执行注入
log(' 步骤 2: 注入脚本...', 'yellow');
const installResult = runCommand(
`node ${join(rootDir, 'bin/index.js')} install --remote=${remote} --framework`,
projectPath
);
if (!installResult.success) {
log(` ❌ 注入失败: ${installResult.error}`, 'red');
console.log(installResult.output);
return false;
}
log(' ✅ 注入成功', 'green');
// 3. 验证注入结果
log(' 🔍 步骤 3: 验证注入结果...', 'yellow');
const verifyResult = verifyInjection(project, projectPath);
if (!verifyResult.success) {
log(` ❌ 验证失败: ${verifyResult.message}`, 'red');
return false;
}
log(' ✅ 验证通过', 'green');
return true;
}
/**
* 测试单个项目的卸载
*/
function testUninstall(project) {
const projectPath = join(testProjectsDir, project.name);
if (!existsSync(projectPath)) {
log(` ❌ 项目目录不存在: ${projectPath}`, 'red');
return false;
}
logTest(`${project.name}`);
// 1. 先确保有注入内容(如果没有,先注入)
log(' 🔍 步骤 1: 检查是否有注入内容...', 'yellow');
const hasInjection = checkInjection(project, projectPath);
if (!hasInjection) {
log(' ⚠️ 未找到注入内容,先执行注入...', 'yellow');
const installResult = runCommand(
`node ${join(rootDir, 'bin/index.js')} install --remote=/sdk/dev-monitor.js --framework`,
projectPath
);
if (!installResult.success) {
log(` ❌ 预注入失败: ${installResult.error}`, 'red');
return false;
}
log(' ✅ 预注入成功', 'green');
}
// 2. 执行卸载
log(' 🗑️ 步骤 2: 卸载注入...', 'yellow');
const uninstallResult = runCommand(
`node ${join(rootDir, 'bin/index.js')} uninstall --framework`,
projectPath
);
if (!uninstallResult.success) {
log(` ❌ 卸载失败: ${uninstallResult.error}`, 'red');
console.log(uninstallResult.output);
return false;
}
log(' ✅ 卸载成功', 'green');
// 3. 验证卸载结果
log(' 🔍 步骤 3: 验证卸载结果...', 'yellow');
const verifyResult = verifyUninstall(project, projectPath);
if (!verifyResult.success) {
log(` ❌ 验证失败: ${verifyResult.message}`, 'red');
return false;
}
log(' ✅ 验证通过', 'green');
return true;
}
/**
* 检查是否有注入内容
*/
function checkInjection(project, projectPath) {
try {
switch (project.type) {
case 'html-static': {
const htmlFile = join(projectPath, 'index.html');
if (existsSync(htmlFile)) {
const content = readFileSync(htmlFile, 'utf8');
return content.includes('DEV-INJECT-START');
}
return false;
}
case 'vite': {
const configFile = join(projectPath, 'vite.config.js');
if (existsSync(configFile)) {
const content = readFileSync(configFile, 'utf8');
return content.includes('DEV-INJECT-START');
}
return false;
}
case 'next-pages': {
const docFile = join(projectPath, 'pages/_document.tsx');
if (existsSync(docFile)) {
const content = readFileSync(docFile, 'utf8');
return content.includes('DEV-INJECT-START');
}
return false;
}
case 'next-app': {
const layoutFile = join(projectPath, 'app/layout.tsx');
if (existsSync(layoutFile)) {
const content = readFileSync(layoutFile, 'utf8');
return content.includes('DEV-INJECT-START');
}
return false;
}
}
return false;
} catch (error) {
return false;
}
}
/**
* 验证注入结果
*/
function verifyInjection(project, projectPath) {
try {
switch (project.type) {
case 'html-static': {
const htmlFile = join(projectPath, 'index.html');
const content = readFileSync(htmlFile, 'utf8');
if (!content.includes('DEV-INJECT-START')) {
return { success: false, message: 'HTML 文件中未找到注入标记' };
}
break;
}
case 'vite': {
const configFile = join(projectPath, 'vite.config.js');
const content = readFileSync(configFile, 'utf8');
if (!content.includes('DEV-INJECT-START')) {
return { success: false, message: 'Vite 配置文件中未找到注入标记' };
}
// 验证逗号位置(允许多种格式)
if (!content.includes('// <!-- DEV-INJECT-END -->')) {
return { success: false, message: '未找到结束标记' };
}
// 验证 index.html 未被修改
const htmlFile = join(projectPath, 'index.html');
const htmlContent = readFileSync(htmlFile, 'utf8');
if (htmlContent.includes('DEV-INJECT-START')) {
return { success: false, message: 'index.html 不应包含注入标记' };
}
break;
}
case 'next-pages': {
const docFile = join(projectPath, 'pages/_document.tsx');
if (existsSync(docFile)) {
const content = readFileSync(docFile, 'utf8');
if (!content.includes('DEV-INJECT-START')) {
return { success: false, message: '_document.tsx 中未找到注入标记' };
}
}
break;
}
case 'next-app': {
const layoutFile = join(projectPath, 'app/layout.tsx');
if (existsSync(layoutFile)) {
const content = readFileSync(layoutFile, 'utf8');
if (!content.includes('DEV-INJECT-START')) {
return { success: false, message: 'layout.tsx 中未找到注入标记' };
}
}
break;
}
}
return { success: true };
} catch (error) {
return { success: false, message: error.message };
}
}
/**
* 验证卸载结果
*/
function verifyUninstall(project, projectPath) {
try {
switch (project.type) {
case 'html-static': {
const htmlFile = join(projectPath, 'index.html');
const content = readFileSync(htmlFile, 'utf8');
if (content.includes('DEV-INJECT-START')) {
return { success: false, message: 'HTML 文件中仍存在注入标记' };
}
break;
}
case 'vite': {
const configFile = join(projectPath, 'vite.config.js');
const content = readFileSync(configFile, 'utf8');
if (content.includes('DEV-INJECT-START')) {
return { success: false, message: 'Vite 配置文件中仍存在注入标记' };
}
break;
}
case 'next-pages': {
const docFile = join(projectPath, 'pages/_document.tsx');
if (existsSync(docFile)) {
const content = readFileSync(docFile, 'utf8');
if (content.includes('DEV-INJECT-START')) {
return { success: false, message: '_document.tsx 中仍存在注入标记' };
}
}
break;
}
case 'next-app': {
const layoutFile = join(projectPath, 'app/layout.tsx');
if (existsSync(layoutFile)) {
const content = readFileSync(layoutFile, 'utf8');
if (content.includes('DEV-INJECT-START')) {
return { success: false, message: 'layout.tsx 中仍存在注入标记' };
}
}
break;
}
}
return { success: true };
} catch (error) {
return { success: false, message: error.message };
}
}
/**
* 运行安装测试
*/
function runInstallTests() {
logSection('🚀 开始运行安装(注入)测试');
log(`测试目录: ${testProjectsDir}`, 'blue');
log(`测试项目数量: ${testProjects.length}`, 'blue');
const results = {
total: 0,
passed: 0,
failed: 0,
projects: [],
};
// 测试本地路径注入
logSection('📝 测试 1: 本地路径注入 (/sdk/dev-monitor.js)');
for (const project of testProjects) {
results.total++;
const success = testInstall(project, testConfig.remote, '本地路径');
results.projects.push({ project: project.name, test: '本地路径', success });
if (success) {
results.passed++;
} else {
results.failed++;
}
}
// 测试远程 URL 注入
logSection('🌐 测试 2: 远程 URL 注入 (https://testagent.xspaceagi.com/sdk/dev-monitor.js)');
for (const project of testProjects) {
results.total++;
const success = testInstall(project, testConfig.remoteUrl, '远程URL');
results.projects.push({ project: project.name, test: '远程URL', success });
if (success) {
results.passed++;
} else {
results.failed++;
}
}
// 输出测试结果
logSection('📊 安装测试结果汇总');
log(`总测试数: ${results.total}`, 'blue');
log(`通过: ${results.passed}`, 'green');
log(`失败: ${results.failed}`, results.failed > 0 ? 'red' : 'green');
console.log('\n详细结果:');
results.projects.forEach(({ project, test, success }) => {
const icon = success ? '✅' : '❌';
const color = success ? 'green' : 'red';
log(` ${icon} ${project} - ${test}`, color);
});
// 返回退出码
process.exit(results.failed > 0 ? 1 : 0);
}
/**
* 运行卸载测试
*/
function runUninstallTests() {
logSection('🚀 开始运行卸载测试');
log(`测试目录: ${testProjectsDir}`, 'blue');
log(`测试项目数量: ${testProjects.length}`, 'blue');
const results = {
total: 0,
passed: 0,
failed: 0,
projects: [],
};
for (const project of testProjects) {
results.total++;
const success = testUninstall(project);
results.projects.push({ project: project.name, success });
if (success) {
results.passed++;
} else {
results.failed++;
}
}
// 输出测试结果
logSection('📊 卸载测试结果汇总');
log(`总测试数: ${results.total}`, 'blue');
log(`通过: ${results.passed}`, 'green');
log(`失败: ${results.failed}`, results.failed > 0 ? 'red' : 'green');
console.log('\n详细结果:');
results.projects.forEach(({ project, success }) => {
const icon = success ? '✅' : '❌';
const color = success ? 'green' : 'red';
log(` ${icon} ${project}`, color);
});
// 返回退出码
process.exit(results.failed > 0 ? 1 : 0);
}
/**
* 运行所有测试(安装 + 卸载)
*/
function runAllTests() {
logSection('🚀 开始运行完整测试(安装 + 卸载)');
log(`测试目录: ${testProjectsDir}`, 'blue');
log(`测试项目数量: ${testProjects.length}`, 'blue');
const results = {
total: 0,
passed: 0,
failed: 0,
projects: [],
};
// 先运行安装测试
logSection('📝 第一部分: 安装测试');
for (const project of testProjects) {
results.total++;
const success = testInstall(project, testConfig.remote, '本地路径');
results.projects.push({ project: project.name, test: '安装', success });
if (success) {
results.passed++;
} else {
results.failed++;
}
}
// 再运行卸载测试
logSection('🗑️ 第二部分: 卸载测试');
for (const project of testProjects) {
results.total++;
const success = testUninstall(project);
results.projects.push({ project: project.name, test: '卸载', success });
if (success) {
results.passed++;
} else {
results.failed++;
}
}
// 输出测试结果
logSection('📊 完整测试结果汇总');
log(`总测试数: ${results.total}`, 'blue');
log(`通过: ${results.passed}`, 'green');
log(`失败: ${results.failed}`, results.failed > 0 ? 'red' : 'green');
console.log('\n详细结果:');
results.projects.forEach(({ project, test, success }) => {
const icon = success ? '✅' : '❌';
const color = success ? 'green' : 'red';
log(` ${icon} ${project} - ${test}`, color);
});
// 返回退出码
process.exit(results.failed > 0 ? 1 : 0);
}
// 解析命令行参数
const args = process.argv.slice(2);
const command = args[0] || 'all';
// 运行测试
try {
switch (command) {
case 'install':
case 'i':
runInstallTests();
break;
case 'uninstall':
case 'u':
runUninstallTests();
break;
case 'all':
default:
runAllTests();
break;
}
} catch (error) {
log(`\n❌ 测试执行失败: ${error.message}`, 'red');
console.error(error);
process.exit(1);
}

View File

@@ -0,0 +1,124 @@
# 测试项目集合
这个目录包含了用于测试 dev-inject 在不同框架和项目类型中的注入功能的示例项目。
## 项目列表
### 1. HTML 静态项目 (html-project)
- **类型**: 纯 HTML + JavaScript
- **端口**: 3002
- **启动**: `npm run serve``python3 -m http.server 3002`
- **特点**: 基础的 HTML 项目,用于测试最简单场景下的脚本注入
### 2. Vite 项目 (vite-project)
- **类型**: Vite + React
- **端口**: 3001
- **启动**: `npm run dev`
- **特点**: 现代化的 Vite 构建工具,单页应用
### 3. Next.js App Router (next-app-project)
- **类型**: Next.js 14 App Router
- **端口**: 3000
- **启动**: `npm run dev`
- **特点**: 最新的 Next.js 应用路由架构,支持 React Server Components
### 4. Next.js Pages Router (next-pages-project)
- **类型**: Next.js Pages Router
- **端口**: 3000
- **启动**: `npm run dev`
- **特点**: 传统的 Next.js Pages 路由架构
## 使用指南
### 快速开始
1. **选择一个测试项目**
```bash
cd test-projects/html-project # 或选择其他项目
```
2. **安装依赖**(如果有 package.json
```bash
npm install
```
3. **启动项目**
```bash
npm run dev
# 或
npm run serve
# 或
python3 -m http.server 3002
```
4. **使用 dev-inject 注入监控脚本**
```bash
# 在项目根目录运行
node ../bin/index.js install --remote http://localhost:9001/scripts/dev-monitor.js
```
5. **测试功能**
- 打开浏览器访问项目
- 点击页面上的按钮触发各种错误
- 在浏览器控制台运行 DevMonitor 命令查看监控信息
### 测试命令
所有测试项目都支持以下 DevMonitor 命令:
```javascript
// 显示监控面板
DevMonitor.showPanel()
// 获取统计信息
DevMonitor.getStats()
// 清除错误记录
DevMonitor.clearErrors()
// 检查注入状态
window.__DEV_INJECT__
```
## 功能特性
每个测试项目都包含以下功能:
- ✅ 触发同步错误
- ✅ 触发异步错误
- ✅ 错误记录和显示
- ✅ 清除错误功能
- ✅ DevMonitor 控制台命令
- ✅ 实时错误统计
- ✅ 美观的 UI 界面
## 项目对比
| 项目类型 | 构建工具 | 路由方式 | 启动速度 | 适用场景 |
|---------|---------|---------|---------|---------|
| HTML | 无 | 静态文件 | 最快 | 传统网站 |
| Vite | Vite | SPA | 快 | 现代 SPA 应用 |
| Next.js App | Next.js | App Router | 中 | 全栈应用(推荐) |
| Next.js Pages | Next.js | Pages Router | 中 | 传统 Next.js 应用 |
## 注意事项
1. **端口冲突**: 确保各个项目使用不同的端口
2. **依赖安装**: 每个项目需要单独安装依赖
3. **测试服务器**: 确保 dev-inject 的监控服务器 (localhost:9001) 正在运行
4. **框架检测**: dev-inject 会自动检测项目类型并应用相应的注入策略
## 问题排查
如果遇到问题:
1. 检查端口是否被占用
2. 确认所有依赖已正确安装
3. 查看浏览器控制台是否有错误
4. 确认监控服务器是否正在运行
5. 检查注入的脚本是否正确加载
## 贡献
欢迎添加更多测试项目或改进现有项目!

View File

@@ -0,0 +1,16 @@
# OS
.DS_Store
Thumbs.db
# Editor
.vscode/
.idea/
*.swp
*.swo
# Node
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*

View File

@@ -0,0 +1,41 @@
# HTML 静态项目测试
这是一个用于测试 dev-inject 在纯 HTML 静态项目中的注入功能的示例项目。
## 启动项目
### 方式 1: 使用 Python
```bash
python3 -m http.server 3002
```
### 方式 2: 使用 serve
```bash
npx serve -s . -p 3002
```
项目将在 http://localhost:3002 运行。
## 测试步骤
1. 启动项目(任选上述方式之一)
2. 使用 dev-inject 注入监控脚本
3. 点击页面上的按钮触发各种错误类型
4. 在浏览器控制台运行 DevMonitor 命令查看监控信息
## DevMonitor 命令
- `DevMonitor.showPanel()` - 显示监控面板
- `DevMonitor.getStats()` - 获取统计信息
- `DevMonitor.clearErrors()` - 清除错误记录
- `window.__DEV_INJECT__` - 检查注入状态
## 功能特性
- 触发同步错误
- 触发异步错误
- 触发引用错误
- 触发类型错误
- 实时统计错误数据
- 清除错误记录

View File

@@ -0,0 +1,31 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>HTML 静态项目 - dev-inject 测试</title>
<!-- DEV-INJECT-START -->
<script data-id="dev-inject-monitor">
(function() {
const remote = "https://testagent.xspaceagi.com/sdk/dev-monitor.js";
const separator = remote.includes('?') ? '&' : '?';
const script = document.createElement('script');
script.src = remote + separator + 't=' + Date.now();
script.dataset.id = 'dev-inject-monitor-script';
script.defer = true;
// 防止重复注入
if (!document.querySelector('[data-id="dev-inject-monitor-script"]')) {
document.head.appendChild(script);
}
})();
</script>
<!-- DEV-INJECT-END -->
</head>
<body>
<h1>HTML 静态项目测试</h1>
<p>测试 dev-inject 在纯 HTML 项目中的注入功能</p>
</body>
</html>

View File

@@ -0,0 +1,17 @@
{
"name": "html-static-test",
"version": "1.0.0",
"description": "纯 HTML 静态项目测试 - dev-inject",
"type": "module",
"scripts": {
"start": "python3 -m http.server 3002",
"serve": "npx serve -s . -p 3002"
},
"keywords": [
"html",
"test",
"dev-inject"
],
"author": "",
"license": "MIT"
}

View File

@@ -0,0 +1,36 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

View File

@@ -0,0 +1,32 @@
# Next.js App Router 测试项目
这是一个用于测试 dev-inject 框架感知注入功能的 Next.js App Router 项目。
## 安装依赖
```bash
npm install
```
## 启动开发服务器
```bash
npm run dev
```
项目将在 http://localhost:3000 运行。
## 测试步骤
1. 启动项目:`npm run dev`
2. 使用 dev-inject 注入监控脚本
3. 点击页面上的按钮触发各种错误
4. 在浏览器控制台运行 DevMonitor 命令查看监控信息
## DevMonitor 命令
- `DevMonitor.showPanel()` - 显示监控面板
- `DevMonitor.getStats()` - 获取统计信息
- `DevMonitor.clearErrors()` - 清除错误记录
- `window.__DEV_INJECT__` - 检查注入状态

View File

@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

View File

@@ -0,0 +1,32 @@
import type { Metadata } from 'next'
import { Inter } from 'next/font/google'
import './globals.css'
const inter = Inter({ subsets: ['latin'] })
export const metadata: Metadata = {
title: 'Next.js App Test - dev-inject',
description: '测试 dev-inject 框架感知注入功能',
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<head>
{/* DEV-INJECT-START */}
{process.env.NODE_ENV === 'development' && (
<script dangerouslySetInnerHTML={{
__html: "(function() {\n const remote = \"https://testagent.xspaceagi.com/sdk/dev-monitor.js\";\n const separator = remote.includes('?') ? '&' : '?';\n const script = document.createElement('script');\n script.src = remote + separator + 't=' + Date.now();\n script.dataset.id = 'dev-inject-monitor-script';\n script.defer = true;\n // 防止重复注入\n if (!document.querySelector('[data-id=\"dev-inject-monitor-script\"]')) {\n document.head.appendChild(script);\n }\n })();"
}} />
)}
{/* DEV-INJECT-END */}
</head>
<body className={inter.className}>{children}</body>
</html>
)
}

View File

@@ -0,0 +1,85 @@
'use client'
import { useState } from 'react'
export default function Home() {
const [errors, setErrors] = useState<string[]>([]);
const triggerError = () => {
try {
throw new Error('测试错误 - Next.js App Router');
} catch (error) {
setErrors(prev => [...prev, (error as Error).message]);
}
};
const triggerAsyncError = () => {
Promise.reject(new Error('异步错误测试 - Next.js App'));
};
const clearErrors = () => {
setErrors([]);
};
return (
<main className="min-h-screen bg-gradient-to-br from-blue-500 to-purple-600 text-white">
<div className="container mx-auto px-4 py-8">
<header className="text-center mb-12">
<h1 className="text-4xl font-bold mb-4">🟢 Next.js App Router Test</h1>
<p className="text-xl opacity-90"> dev-inject </p>
</header>
<div className="max-w-2xl mx-auto">
<div className="bg-white/10 backdrop-blur-md rounded-xl p-6 mb-6">
<h2 className="text-2xl font-semibold mb-4">🚀 </h2>
<div className="flex flex-wrap gap-3 justify-center">
<button
onClick={triggerError}
className="px-6 py-3 bg-red-500 hover:bg-red-600 rounded-lg font-medium transition-colors"
>
</button>
<button
onClick={triggerAsyncError}
className="px-6 py-3 bg-red-500 hover:bg-red-600 rounded-lg font-medium transition-colors"
>
</button>
<button
onClick={clearErrors}
className="px-6 py-3 bg-gray-600 hover:bg-gray-700 rounded-lg font-medium transition-colors"
>
</button>
</div>
</div>
<div className="bg-white/10 backdrop-blur-md rounded-xl p-6 mb-6">
<h2 className="text-2xl font-semibold mb-4">📊 </h2>
{errors.length === 0 ? (
<p className="text-gray-300 italic"></p>
) : (
<div className="space-y-2">
{errors.map((error, index) => (
<div key={index} className="bg-red-500/20 border-l-4 border-red-500 p-3 rounded font-mono text-sm">
{error}
</div>
))}
</div>
)}
</div>
<div className="bg-white/10 backdrop-blur-md rounded-xl p-6">
<h2 className="text-2xl font-semibold mb-4">🔧 DevMonitor </h2>
<p className="mb-3"></p>
<ul className="space-y-1 text-left max-w-md mx-auto">
<li><code className="bg-black/20 px-2 py-1 rounded">DevMonitor.showPanel()</code> - </li>
<li><code className="bg-black/20 px-2 py-1 rounded">DevMonitor.getStats()</code> - </li>
<li><code className="bg-black/20 px-2 py-1 rounded">window.__DEV_INJECT__</code> - </li>
</ul>
</div>
</div>
</div>
</main>
)
}

View File

@@ -0,0 +1,4 @@
/** @type {import('next').NextConfig} */
const nextConfig = {}
module.exports = nextConfig

View File

@@ -0,0 +1,25 @@
{
"name": "next-app-test",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"react": "^18",
"react-dom": "^18",
"next": "14.0.0"
},
"devDependencies": {
"typescript": "^5",
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"autoprefixer": "^10.0.1",
"postcss": "^8",
"tailwindcss": "^3.3.0"
}
}

View File

@@ -0,0 +1,7 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@@ -0,0 +1,12 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
'./pages/**/*.{js,ts,jsx,tsx,mdx}',
'./components/**/*.{js,ts,jsx,tsx,mdx}',
'./app/**/*.{js,ts,jsx,tsx,mdx}',
],
theme: {
extend: {},
},
plugins: [],
}

View File

@@ -0,0 +1,28 @@
{
"compilerOptions": {
"target": "es5",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}

View File

@@ -0,0 +1,36 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

View File

@@ -0,0 +1,32 @@
# Next.js Pages Router 测试项目
这是一个用于测试 dev-inject 框架感知注入功能的 Next.js Pages Router 项目。
## 安装依赖
```bash
npm install
```
## 启动开发服务器
```bash
npm run dev
```
项目将在 http://localhost:3000 运行。
## 测试步骤
1. 启动项目:`npm run dev`
2. 使用 dev-inject 注入监控脚本
3. 点击页面上的按钮触发各种错误
4. 在浏览器控制台运行 DevMonitor 命令查看监控信息
## DevMonitor 命令
- `DevMonitor.showPanel()` - 显示监控面板
- `DevMonitor.getStats()` - 获取统计信息
- `DevMonitor.clearErrors()` - 清除错误记录
- `window.__DEV_INJECT__` - 检查注入状态

View File

@@ -0,0 +1,5 @@
/** @type {import('next').NextConfig} */
const nextConfig = {}
module.exports = nextConfig

View File

@@ -0,0 +1,22 @@
{
"name": "next-pages-test",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"react": "^18",
"react-dom": "^18",
"next": "14.0.0"
},
"devDependencies": {
"typescript": "^5",
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18"
}
}

View File

@@ -0,0 +1,97 @@
import type { AppProps } from 'next/app'
import '../styles/globals.css'
{/* DEV-INJECT-START */}
{/* DEV-INJECT-END */}
{/* DEV-INJECT-START */}
{/* DEV-INJECT-END */}
{/* DEV-INJECT-START */}
{/* DEV-INJECT-END */}
{/* DEV-INJECT-START */}
{/* DEV-INJECT-END */}
{/* DEV-INJECT-START */}
{/* DEV-INJECT-END */}
{/* DEV-INJECT-START */}
{/* DEV-INJECT-END */}
{/* DEV-INJECT-START */}
{/* DEV-INJECT-END */}
{/* DEV-INJECT-START */}
{/* DEV-INJECT-END */}
{/* DEV-INJECT-START */}
{/* DEV-INJECT-END */}
{/* DEV-INJECT-START */}
{/* DEV-INJECT-END */}
{/* DEV-INJECT-START */}
{/* DEV-INJECT-END */}
{/* DEV-INJECT-START */}
{/* DEV-INJECT-END */}
{/* DEV-INJECT-START */}
{/* DEV-INJECT-END */}
{/* DEV-INJECT-START */}
{/* DEV-INJECT-END */}
{/* DEV-INJECT-START */}
useEffect(() => {
if (typeof window !== 'undefined') {
(function() {
const remote = "https://testagent.xspaceagi.com/sdk/dev-monitor.js";
const separator = remote.includes('?') ? '&' : '?';
const script = document.createElement('script');
script.src = remote + separator + 't=' + Date.now();
script.dataset.id = 'dev-inject-monitor-script';
script.defer = true;
// 防止重复注入
if (!document.querySelector('[data-id="dev-inject-monitor-script"]')) {
document.head.appendChild(script);
}
})();
}
}, []);
{/* DEV-INJECT-END */}
export default function App({ Component, pageProps }: AppProps) {
return <Component {...pageProps} />
}

View File

@@ -0,0 +1,24 @@
import { Html, Head, Main, NextScript } from 'next/document'
export default function Document() {
return (
<Html lang="zh-CN">
<Head>
{/* DEV-INJECT-START */}
{typeof window !== 'undefined' && (
<script dangerouslySetInnerHTML={{
__html: "(function() {\n const remote = \"https://testagent.xspaceagi.com/sdk/dev-monitor.js\";\n const separator = remote.includes('?') ? '&' : '?';\n const script = document.createElement('script');\n script.src = remote + separator + 't=' + Date.now();\n script.dataset.id = 'dev-inject-monitor-script';\n script.defer = true;\n // 防止重复注入\n if (!document.querySelector('[data-id=\"dev-inject-monitor-script\"]')) {\n document.head.appendChild(script);\n }\n })();"
}} />
)}
{/* DEV-INJECT-END */}
</Head>
<body>
<Main />
<NextScript />
</body>
</Html>
)
}

View File

@@ -0,0 +1,92 @@
import { useState } from 'react'
import Head from 'next/head'
export default function Home() {
const [errors, setErrors] = useState<string[]>([]);
const triggerError = () => {
try {
throw new Error('测试错误 - Next.js Pages Router');
} catch (error) {
setErrors(prev => [...prev, (error as Error).message]);
}
};
const triggerAsyncError = () => {
Promise.reject(new Error('异步错误测试 - Next.js Pages'));
};
const clearErrors = () => {
setErrors([]);
};
return (
<>
<Head>
<title>Next.js Pages Test - dev-inject</title>
<meta name="description" content="测试 dev-inject 在 Next.js Pages Router 中的注入功能" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
</Head>
<main className="min-h-screen bg-gradient-to-br from-green-500 to-teal-600 text-white">
<div className="container mx-auto px-4 py-8">
<header className="text-center mb-12">
<h1 className="text-4xl font-bold mb-4">📄 Next.js Pages Router Test</h1>
<p className="text-xl opacity-90"> dev-inject </p>
</header>
<div className="max-w-2xl mx-auto">
<div className="bg-white/10 backdrop-blur-md rounded-xl p-6 mb-6">
<h2 className="text-2xl font-semibold mb-4">🚀 </h2>
<div className="flex flex-wrap gap-3 justify-center">
<button
onClick={triggerError}
className="px-6 py-3 bg-red-500 hover:bg-red-600 rounded-lg font-medium transition-colors"
>
</button>
<button
onClick={triggerAsyncError}
className="px-6 py-3 bg-red-500 hover:bg-red-600 rounded-lg font-medium transition-colors"
>
</button>
<button
onClick={clearErrors}
className="px-6 py-3 bg-gray-600 hover:bg-gray-700 rounded-lg font-medium transition-colors"
>
</button>
</div>
</div>
<div className="bg-white/10 backdrop-blur-md rounded-xl p-6 mb-6">
<h2 className="text-2xl font-semibold mb-4">📊 </h2>
{errors.length === 0 ? (
<p className="text-gray-300 italic"></p>
) : (
<div className="space-y-2">
{errors.map((error, index) => (
<div key={index} className="bg-red-500/20 border-l-4 border-red-500 p-3 rounded font-mono text-sm">
{error}
</div>
))}
</div>
)}
</div>
<div className="bg-white/10 backdrop-blur-md rounded-xl p-6">
<h2 className="text-2xl font-semibold mb-4">🔧 DevMonitor </h2>
<p className="mb-3"></p>
<ul className="space-y-1 text-left max-w-md mx-auto">
<li><code className="bg-black/20 px-2 py-1 rounded">DevMonitor.showPanel()</code> - </li>
<li><code className="bg-black/20 px-2 py-1 rounded">DevMonitor.getStats()</code> - </li>
<li><code className="bg-black/20 px-2 py-1 rounded">window.__DEV_INJECT__</code> - </li>
</ul>
</div>
</div>
</div>
</main>
</>
)
}

View File

@@ -0,0 +1,7 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@@ -0,0 +1,17 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}

View File

@@ -0,0 +1,12 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
'./pages/**/*.{js,ts,jsx,tsx,mdx}',
'./components/**/*.{js,ts,jsx,tsx,mdx}',
],
theme: {
extend: {},
},
plugins: [],
}

View File

@@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "es5",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"paths": {
"@/*": ["./*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
"exclude": ["node_modules"]
}

View File

@@ -0,0 +1,25 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@@ -0,0 +1,32 @@
# Vite React 测试项目
这是一个用于测试 dev-inject 框架感知注入功能的 Vite + React 项目。
## 安装依赖
```bash
npm install
```
## 启动开发服务器
```bash
npm run dev
```
项目将在 http://localhost:3001 运行。
## 测试步骤
1. 启动项目:`npm run dev`
2. 使用 dev-inject 注入监控脚本
3. 点击页面上的按钮触发各种错误
4. 在浏览器控制台运行 DevMonitor 命令查看监控信息
## DevMonitor 命令
- `DevMonitor.showPanel()` - 显示监控面板
- `DevMonitor.getStats()` - 获取统计信息
- `DevMonitor.clearErrors()` - 清除错误记录
- `window.__DEV_INJECT__` - 检查注入状态

View File

@@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite Test - dev-inject</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

View File

@@ -0,0 +1,21 @@
{
"name": "vite-test-project",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@types/react": "^18.2.43",
"@types/react-dom": "^18.2.17",
"@vitejs/plugin-react": "^4.2.1",
"vite": "^5.0.8"
}
}

View File

@@ -0,0 +1,12 @@
// PostCSS 配置文件
// 此文件用于防止 Vite 向上查找父目录的 PostCSS 配置
// 本项目使用普通 CSS不需要 PostCSS 插件
export default {
plugins: {
// 如果需要 PostCSS 插件,可以在这里添加
// 例如autoprefixer, tailwindcss 等
}
}

View File

@@ -0,0 +1,119 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.app {
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.container {
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
header {
margin-bottom: 40px;
}
header h1 {
margin: 0;
font-size: 2.5rem;
margin-bottom: 10px;
}
header p {
margin: 0;
opacity: 0.8;
font-size: 1.2rem;
}
.section {
background: rgba(255, 255, 255, 0.1);
border-radius: 12px;
padding: 20px;
margin-bottom: 20px;
backdrop-filter: blur(10px);
}
.section h2 {
margin-top: 0;
margin-bottom: 15px;
}
.buttons {
display: flex;
gap: 10px;
justify-content: center;
flex-wrap: wrap;
}
button {
padding: 10px 20px;
border: none;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
}
button:hover {
transform: translateY(-2px);
}
button.danger {
background: #f56565;
color: white;
}
button.danger:hover {
background: #e53e3e;
}
button.secondary {
background: #718096;
color: white;
}
button.secondary:hover {
background: #4a5568;
}
.error-list {
list-style: none;
padding: 0;
margin: 0;
}
.error-item {
background: rgba(245, 101, 101, 0.2);
border-left: 3px solid #f56565;
padding: 10px;
margin: 5px 0;
border-radius: 4px;
text-align: left;
font-family: monospace;
font-size: 14px;
}
.no-errors {
color: #a0aec0;
font-style: italic;
}
ul {
text-align: left;
}
code {
background: rgba(0, 0, 0, 0.2);
padding: 2px 6px;
border-radius: 4px;
font-family: monospace;
}

View File

@@ -0,0 +1,73 @@
import React, { useState } from 'react'
import './App.css'
function App() {
const [errors, setErrors] = useState([]);
const triggerError = () => {
try {
throw new Error('测试错误 - Vite 项目');
} catch (error) {
setErrors(prev => [...prev, error.message]);
}
};
const triggerAsyncError = () => {
Promise.reject(new Error('异步错误测试 - Vite'));
};
const clearErrors = () => {
setErrors([]);
};
return (
<div className="app">
<div className="container">
<header>
<h1>🟣 Vite Test Project</h1>
<p>测试 dev-inject 框架感知注入功能</p>
</header>
<div className="section">
<h2>🚀 测试功能</h2>
<div className="buttons">
<button onClick={triggerError} className="danger">
触发同步错误
</button>
<button onClick={triggerAsyncError} className="danger">
触发异步错误
</button>
<button onClick={clearErrors} className="secondary">
清除错误
</button>
</div>
</div>
<div className="section">
<h2>📊 错误记录</h2>
{errors.length === 0 ? (
<p className="no-errors">暂无错误</p>
) : (
<ul className="error-list">
{errors.map((error, index) => (
<li key={index} className="error-item">{error}</li>
))}
</ul>
)}
</div>
<div className="section">
<h2>🔧 DevMonitor 检查</h2>
<p>在浏览器控制台中检查</p>
<ul>
<li><code>DevMonitor.showPanel()</code> - 显示监控面板</li>
<li><code>DevMonitor.getStats()</code> - 获取统计信息</li>
<li><code>window.__DEV_INJECT__</code> - 检查注入状态</li>
</ul>
</div>
</div>
</div>
);
}
export default App;

View File

@@ -0,0 +1,66 @@
:root {
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
}
#root {
width: 100%;
margin: 0 auto;
text-align: center;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
button {
background-color: #f9f9f9;
}
}

View File

@@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.jsx'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)

View File

@@ -0,0 +1,40 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [
// <!-- DEV-INJECT-START -->
{
name: 'dev-inject',
enforce: 'post', // 确保在 HTML 注入阶段最后执行
transformIndexHtml(html) {
if (!html.includes('data-id="dev-inject-monitor"')) {
return html.replace("</head>", `
<script data-id="dev-inject-monitor">
(function() {
const remote = "https://testagent.xspaceagi.com/sdk/dev-monitor.js";
const separator = remote.includes('?') ? '&' : '?';
const script = document.createElement('script');
script.src = remote + separator + 't=' + Date.now();
script.dataset.id = 'dev-inject-monitor-script';
script.defer = true;
// 防止重复注入
if (!document.querySelector('[data-id="dev-inject-monitor-script"]')) {
document.head.appendChild(script);
}
})();
</script>
\n</head>`);
}
return html;
}
},
// <!-- DEV-INJECT-END -->
react()
],
server: {
port: 3001
}
})

View File

@@ -0,0 +1,388 @@
import { test, describe } from 'node:test';
import assert from 'node:assert';
import { execSync, spawn } from 'child_process';
import { readFileSync } from 'fs';
import { join } from 'path';
import { fileURLToPath } from 'url';
import { dirname } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
describe('端到端测试', () => {
const testDir = join(__dirname, 'fixtures');
const cliPath = join(__dirname, '..', '..', 'bin', 'index.js');
const projectRoot = join(__dirname, '..', '..');
// 创建测试项目的基本结构
const testProjects = {
vite: {
name: 'test-vite-project',
files: {
'package.json': JSON.stringify({
"name": "test-vite-project",
"version": "1.0.0",
"type": "module",
"scripts": { "dev": "vite" },
"dependencies": { "react": "^18.2.0" }
}, null, 2),
'vite.config.js': `import { defineConfig } from 'vite'
export default defineConfig({
plugins: []
})`,
'index.html': `<!DOCTYPE html>
<html>
<head>
<title>Vite Test</title>
</head>
<body>
<div id="app"></div>
</body>
</html>`,
'src/main.js': `import './app.js'`,
'src/app.js': `console.log('Vite app loaded');
document.getElementById('app').innerHTML = '<h1>Vite App</h1>';`
}
},
'next-app': {
name: 'test-next-app',
files: {
'package.json': JSON.stringify({
"name": "test-next-app",
"version": "1.0.0",
"scripts": { "dev": "next dev" }
}, null, 2),
'next.config.js': 'module.exports = {}',
'app/layout.tsx': `import './globals.css'
export default function RootLayout({ children }) {
return (
<html lang="en">
<body>{children}</body>
</html>
)}`,
'app/page.tsx': `export default function Home() {
return <h1>Next.js App</h1>;
}`,
'app/globals.css': 'body { font-family: Arial, sans-serif; }'
}
},
'html-static': {
name: 'test-html-project',
files: {
'index.html': `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Static HTML</title>
</head>
<body>
<h1>Static HTML Project</h1>
<p>This is a static HTML project.</p>
</body>
</html>`
}
}
};
function createProject(projectName, files) {
const projectPath = join(testDir, projectName);
mkdirSync(projectPath, { recursive: true });
for (const [filePath, content] of Object.entries(files)) {
const fullPath = join(projectPath, filePath);
const dir = dirname(fullPath);
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
writeFileSync(fullPath, content);
}
return projectPath;
}
function cleanupProject(projectPath) {
if (existsSync(projectPath)) {
rmSync(projectPath, { recursive: true });
}
}
function runCliInProject(projectPath, args, options = {}) {
try {
const result = execSync(`node ${cliPath} ${args}`, {
encoding: 'utf8',
cwd: projectPath,
stdio: ['ignore', 'pipe', 'pipe'],
timeout: options.timeout || 10000
});
return { success: true, output: result };
} catch (error) {
return {
success: false,
output: error.stdout || error.message,
code: error.status,
signal: error.signal
};
}
}
test.before(() => {
if (!existsSync(testDir)) {
mkdirSync(testDir, { recursive: true });
}
});
test.after(() => {
// 清理所有测试项目
Object.keys(testProjects).forEach(projectName => {
const projectPath = join(testDir, testProjects[projectName].name);
cleanupProject(projectPath);
});
});
describe('Vite 项目完整流程', () => {
test('应该完成 Vite 项目的完整注入流程', () => {
const projectPath = createProject(testProjects.vite.name, testProjects.vite.files);
try {
// 1. 检测项目类型
const detectResult = runCliInProject(projectPath, 'install --remote=/scripts/monitor.js --framework --dry-run --verbose');
assert.strictEqual(detectResult.success, true);
assert.match(detectResult.output, /检测到项目类型: vite/);
// 2. 执行实际注入
const injectResult = runCliInProject(projectPath, 'install --remote=/scripts/monitor.js --framework');
assert.strictEqual(injectResult.success, true);
assert.match(injectResult.output, /框架注入成功/);
// 3. 验证配置文件被修改
const viteConfigPath = join(projectPath, 'vite.config.js');
assert(existsSync(viteConfigPath));
const configContent = readFileSync(viteConfigPath, 'utf8');
assert.match(configContent, /dev-inject-plugin/);
assert.match(configContent, /scripts\/monitor\.js/);
// 4. 验证原始配置保留
assert.match(configContent, /defineConfig/);
// 5. 执行卸载
const uninstallResult = runCliInProject(projectPath, 'uninstall --framework');
assert.strictEqual(uninstallResult.success, true);
// 6. 验证插件被移除
const cleanedContent = readFileSync(viteConfigPath, 'utf8');
assert(!cleanedContent.includes('dev-inject-plugin'));
} finally {
cleanupProject(projectPath);
}
});
test('应该处理不同的脚本类型', () => {
const projectPath = createProject(testProjects.vite.name, testProjects.vite.files);
try {
// 测试 HTTP URL
const httpResult = runCliInProject(projectPath, 'install --remote=https://cdn.example.com/monitor.js --framework --dry-run');
assert.strictEqual(httpResult.success, true);
assert.match(httpResult.output, /检测到项目类型: vite/);
// 测试本地路径
const localResult = runCliInProject(projectPath, 'install --remote=/assets/dev-monitor.js --framework --dry-run');
assert.strictEqual(localResult.success, true);
assert.match(localResult.output, /检测到项目类型: vite/);
} finally {
cleanupProject(projectPath);
}
});
});
describe('Next.js 项目完整流程', () => {
test('应该完成 Next.js App Router 的完整注入流程', () => {
const projectPath = createProject(testProjects['next-app'].name, testProjects['next-app'].files);
try {
// 1. 检测项目类型
const detectResult = runCliInProject(projectPath, 'install --remote=/scripts/monitor.js --framework --dry-run --verbose');
assert.strictEqual(detectResult.success, true);
assert.match(detectResult.output, /检测到项目类型: next-app/);
// 2. 执行实际注入
const injectResult = runCliInProject(projectPath, 'install --remote=/scripts/monitor.js --framework');
assert.strictEqual(injectResult.success, true);
assert.match(injectResult.output, /框架注入成功/);
// 3. 验证 layout 文件被修改
const layoutPath = join(projectPath, 'app/layout.tsx');
assert(existsSync(layoutPath));
const layoutContent = readFileSync(layoutPath, 'utf8');
assert.match(layoutContent, /process\.env\.NODE_ENV === 'development'/);
assert.match(layoutContent, /scripts\/monitor\.js/);
// 4. 验证原始内容保留
assert.match(layoutContent, /RootLayout/);
assert.match(layoutContent, /children/);
// 5. 执行卸载
const uninstallResult = runCliInProject(projectPath, 'uninstall --framework');
assert.strictEqual(uninstallResult.success, true);
// 6. 验证注入内容被移除
const cleanedContent = readFileSync(layoutPath, 'utf8');
assert(!cleanedContent.includes('process.env.NODE_ENV'));
assert(!cleanedContent.includes('scripts/monitor.js'));
} finally {
cleanupProject(projectPath);
}
});
});
describe('静态 HTML 项目完整流程', () => {
test('应该完成静态 HTML 项目的完整注入流程', () => {
const projectPath = createProject(testProjects['html-static'].name, testProjects['html-static'].files);
try {
// 1. 检测项目类型
const detectResult = runCliInProject(projectPath, 'install --remote=/scripts/monitor.js --framework --dry-run --verbose');
assert.strictEqual(detectResult.success, true);
assert.match(detectResult.output, /检测到项目类型: html-static/);
// 2. 执行实际注入
const injectResult = runCliInProject(projectPath, 'install --remote=/scripts/monitor.js --framework');
assert.strictEqual(injectResult.success, true);
assert.match(injectResult.output, /成功处理 \d+ 个文件/);
// 3. 验证 HTML 文件被修改
const htmlPath = join(projectPath, 'index.html');
assert(existsSync(htmlPath));
const htmlContent = readFileSync(htmlPath, 'utf8');
assert.match(htmlContent, /<script src="\/scripts\/monitor\.js"><\/script>/);
assert.match(htmlContent, /<!-- injected by dev-inject -->/);
// 4. 验证原始内容保留
assert.match(htmlContent, /Static HTML Project/);
// 5. 执行卸载
const uninstallResult = runCliInProject(projectPath, 'uninstall');
assert.strictEqual(uninstallResult.success, true);
assert.match(uninstallResult.output, /成功清理 \d+ 个文件/);
// 6. 验证脚本被移除
const cleanedContent = readFileSync(htmlPath, 'utf8');
assert(!cleanedContent.includes('scripts/monitor.js'));
assert(!cleanedContent.includes('injected by dev-inject'));
} finally {
cleanupProject(projectPath);
}
});
});
describe('回退机制测试', () => {
test('应该在不支持的项目中回退到传统模式', () => {
// 创建一个不支持的空项目
const projectPath = join(testDir, 'unsupported-project');
mkdirSync(projectPath, { recursive: true });
try {
const result = runCliInProject(projectPath, 'install --remote=/scripts/monitor.js --framework --dry-run');
// 应该回退到传统模式并找到 HTML 文件
assert.strictEqual(result.success, true);
assert.match(result.output, /回退到传统 HTML 注入模式/);
} finally {
cleanupProject(projectPath);
}
});
});
describe('并发测试', () => {
test('应该处理多个并发命令', async () => {
const projectPath = createProject(testProjects.vite.name, testProjects.vite.files);
try {
// 创建多个并发命令
const commands = [
runCliInProject(projectPath, 'install --remote=/scripts/monitor1.js --framework --dry-run'),
runCliInProject(projectPath, 'install --remote=/scripts/monitor2.js --framework --dry-run'),
runCliInProject(projectPath, 'install --remote=/scripts/monitor3.js --framework --dry-run')
];
// 等待所有命令完成
const results = await Promise.all(commands);
// 所有命令都应该成功
results.forEach(result => {
assert.strictEqual(result.success, true);
});
} finally {
cleanupProject(projectPath);
}
});
});
describe('性能测试', () => {
test('应该在合理时间内完成大型项目操作', () => {
// 创建一个大项目(很多文件)
const projectPath = join(testDir, 'large-project');
mkdirSync(projectPath, { recursive: true });
// 创建大量 HTML 文件
for (let i = 0; i < 100; i++) {
writeFileSync(join(projectPath, `file${i}.html`), `<html><head><title>File ${i}</title></head><body></body></html>`);
}
// 创建 Vite 配置
writeFileSync(join(projectPath, 'vite.config.js'), 'module.exports = { plugins: [] }');
try {
const startTime = Date.now();
const result = runCliInProject(projectPath, 'install --remote=/scripts/monitor.js --framework --dry-run', {
timeout: 30000 // 30秒超时
});
const endTime = Date.now();
assert.strictEqual(result.success, true);
assert(endTime - startTime < 10000, '大项目操作应该在 10 秒内完成');
} finally {
cleanupProject(projectPath);
}
});
});
describe('真实场景模拟', () => {
test('应该模拟真实的开发工作流', () => {
const projectPath = createProject(testProjects.vite.name, testProjects.vite.files);
try {
// 1. 初始注入
const initialInject = runCliInProject(projectPath, 'install --remote=/scripts/dev-monitor.js --framework');
assert.strictEqual(initialInject.success, true);
// 2. 重新注入(应该检测到已存在)
const reInject = runCliInProject(projectPath, 'install --remote=/scripts/dev-monitor.js --framework --dry-run');
assert.strictEqual(reInject.success, true);
// 3. 更换脚本
const switchScript = runCliInProject(projectPath, 'install --remote=/scripts/new-monitor.js --framework --dry-run');
assert.strictEqual(switchScript.success, true);
// 4. 卸载
const uninstall = runCliInProject(projectPath, 'uninstall --framework');
assert.strictEqual(uninstall.success, true);
// 5. 清理后重新注入
const cleanReinject = runCliInProject(projectPath, 'install --remote=/scripts/dev-monitor.js --framework --dry-run');
assert.strictEqual(cleanReinject.success, true);
} finally {
cleanupProject(projectPath);
}
});
});
});

View File

@@ -0,0 +1,313 @@
import { test, describe } from 'node:test';
import assert from 'node:assert';
import { execSync } from 'child_process';
import { existsSync, mkdirSync, writeFileSync, rmSync } from 'fs';
import { join } from 'path';
import { fileURLToPath } from 'url';
import { dirname } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
describe('CLI 功能集成测试', () => {
const testDir = join(__dirname, 'fixtures');
const cliPath = join(__dirname, '..', '..', 'bin', 'index.js');
const projectRoot = join(__dirname, '..', '..');
test.before(() => {
if (!existsSync(testDir)) {
mkdirSync(testDir, { recursive: true });
}
});
test.after(() => {
// 清理测试文件
const filesToClean = [
'vite.config.js',
'next.config.js',
'app/layout.tsx',
'pages/_document.tsx',
'test.html',
'public/index.html'
];
filesToClean.forEach(file => {
const filePath = join(testDir, file);
if (existsSync(filePath)) {
rmSync(filePath);
}
});
const dirsToClean = ['app', 'pages', 'public'];
dirsToClean.forEach(dir => {
const dirPath = join(testDir, dir);
if (existsSync(dirPath)) {
rmSync(dirPath, { recursive: true });
}
});
});
function runCli(args, options = {}) {
try {
const result = execSync(`node ${cliPath} ${args}`, {
encoding: 'utf8',
cwd: options.cwd || projectRoot,
stdio: ['ignore', 'pipe', 'pipe']
});
return { success: true, output: result };
} catch (error) {
return {
success: false,
output: error.stdout || error.message,
code: error.status
};
}
}
describe('基本 CLI 命令', () => {
test('应该显示帮助信息', () => {
const result = runCli('--help');
assert.strictEqual(result.success, true);
assert.match(result.output, /dev-inject.*开发环境脚本注入工具/);
assert.match(result.output, /--framework.*使用框架感知注入模式/);
assert.match(result.output, /--remote.*脚本地址/);
});
test('应该显示版本信息', () => {
const result = runCli('--version');
assert.strictEqual(result.success, true);
assert.match(result.output, /dev-inject v\d+\.\d+\.\d+/);
});
test('应该处理无效参数', () => {
const result = runCli('--invalid-option');
// 应该仍然成功(忽略未知选项)
assert.strictEqual(result.success, true);
});
test('应该在缺少参数时显示错误', () => {
const result = runCli('install');
assert.strictEqual(result.success, false);
assert.match(result.output, /install 命令需要 --remote 参数/);
});
});
describe('框架感知命令', () => {
test('应该检测到 Vite 项目', () => {
// 创建 Vite 项目
writeFileSync(join(testDir, 'vite.config.js'), 'module.exports = { plugins: [] }');
const result = runCli('install --remote=/scripts/test.js --framework --dry-run', {
cwd: testDir
});
assert.strictEqual(result.success, true);
assert.match(result.output, /检测到项目类型: vite/);
assert.match(result.output, /DRY RUN/);
});
test('应该检测到 Next.js App Router', () => {
// 创建 Next.js App Router
writeFileSync(join(testDir, 'next.config.js'), 'module.exports = {}');
mkdirSync(join(testDir, 'app'), { recursive: true });
writeFileSync(join(testDir, 'app', 'layout.tsx'), 'export default function Layout() {}');
const result = runCli('install --remote=/scripts/test.js --framework --dry-run', {
cwd: testDir
});
assert.strictEqual(result.success, true);
assert.match(result.output, /检测到项目类型: next-app/);
});
test('应该检测到 Next.js Pages Router', () => {
// 创建 Next.js Pages Router
writeFileSync(join(testDir, 'next.config.js'), 'module.exports = {}');
mkdirSync(join(testDir, 'pages'), { recursive: true });
writeFileSync(join(testDir, 'pages', '_document.tsx'), 'export default function Document() {}');
const result = runCli('install --remote=/scripts/test.js --framework --dry-run', {
cwd: testDir
});
assert.strictEqual(result.success, true);
assert.match(result.output, /检测到项目类型: next-pages/);
});
test('应该检测到 Create React App', () => {
// Create React App
mkdirSync(join(testDir, 'public'), { recursive: true });
writeFileSync(join(testDir, 'public', 'index.html'), '<html></html>');
const result = runCli('install --remote=/scripts/test.js --framework --dry-run', {
cwd: testDir
});
assert.strictEqual(result.success, true);
assert.match(result.output, /检测到项目类型: create-react-app/);
});
test('应该检测到 HTML 项目', () => {
writeFileSync(join(testDir, 'index.html'), '<html></html>');
const result = runCli('install --remote=/scripts/test.js --framework --dry-run', {
cwd: testDir
});
assert.strictEqual(result.success, true);
assert.match(result.output, /检测到项目类型: html-static/);
});
});
describe('传统注入模式', () => {
test('应该注入到 HTML 文件', () => {
// 创建 HTML 文件
const htmlContent = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Test</title>
</head>
<body>
<h1>Hello World</h1>
</body>
</html>`;
writeFileSync(join(testDir, 'test.html'), htmlContent);
const result = runCli('install --remote=/scripts/test.js --file=./test.html --dry-run', {
cwd: testDir
});
assert.strictEqual(result.success, true);
assert.match(result.output, /DRY RUN.*将注入脚本到.*test\.html/);
});
test('应该查找多个 HTML 文件', () => {
// 创建多个 HTML 文件
writeFileSync(join(testDir, 'index.html'), '<html></html>');
writeFileSync(join(testDir, 'test.html'), '<html></html>');
const result = runCli('install --remote=/scripts/test.js --dry-run', {
cwd: testDir
});
assert.strictEqual(result.success, true);
assert.match(result.output, /找到 \d+ 个 HTML 文件/);
});
});
describe('卸载功能', () => {
test('应该支持传统卸载', () => {
writeFileSync(join(testDir, 'test.html'), '<html></html>');
const result = runCli('uninstall --file=./test.html --dry-run', {
cwd: testDir
});
assert.strictEqual(result.success, true);
assert.match(result.output, /卸载脚本/);
});
test('应该支持框架卸载', () => {
writeFileSync(join(testDir, 'vite.config.js'), 'module.exports = { plugins: [] }');
const result = runCli('uninstall --framework --dry-run', {
cwd: testDir
});
assert.strictEqual(result.success, true);
// 框架卸载的输出可能不同
});
});
describe('详细输出模式', () => {
test('应该显示详细的注入信息', () => {
writeFileSync(join(testDir, 'vite.config.js'), 'module.exports = { plugins: [] }');
const result = runCli('install --remote=/scripts/test.js --framework --dry-run --verbose', {
cwd: testDir
});
assert.strictEqual(result.success, true);
assert.match(result.output, /脚本类型:/);
assert.match(result.output, /脚本标签:/);
});
});
describe('错误处理', () => {
test('应该处理不存在的文件', () => {
const result = runCli('install --remote=/scripts/test.js --file=./nonexistent.html', {
cwd: testDir
});
assert.strictEqual(result.success, false);
assert.match(result.output, /未找到 HTML 文件/);
});
test('应该处理权限问题', () => {
// 创建只读目录(在某些系统上可能不支持)
try {
mkdirSync(join(testDir, 'readonly'), { recursive: true, mode: 0o444 });
const result = runCli('install --remote=/scripts/test.js --file=./readonly/test.html', {
cwd: testDir
});
// 应该有某种错误处理
assert.ok(true);
} catch (error) {
// 忽略权限相关的错误
}
});
});
describe('参数验证', () => {
test('应该验证远程 URL 格式', () => {
const result = runCli('install --remote=invalid-url --framework --dry-run', {
cwd: testDir
});
// 应该仍然接受,验证在实际注入时进行
assert.strictEqual(result.success, true);
});
test('应该处理空参数', () => {
const result = runCli('install --remote= --framework --dry-run', {
cwd: testDir
});
assert.strictEqual(result.success, true);
});
test('应该处理长参数', () => {
const longRemote = 'https://example.com/' + 'a'.repeat(100) + '/script.js';
const result = runCli(`install --remote=${longRemote} --framework --dry-run`, {
cwd: testDir
});
assert.strictEqual(result.success, true);
});
});
describe('性能测试', () => {
test('应该在合理时间内完成', () => {
// 创建一些文件
for (let i = 0; i < 10; i++) {
writeFileSync(join(testDir, `test${i}.html`), '<html></html>');
}
const startTime = Date.now();
const result = runCli('install --remote=/scripts/test.js --dry-run', {
cwd: testDir
});
const endTime = Date.now();
assert.strictEqual(result.success, true);
assert(endTime - startTime < 5000, 'CLI 命令应该在 5 秒内完成');
});
});
});

View File

@@ -0,0 +1 @@
<html></html>

View File

@@ -0,0 +1 @@
<html></html>

View File

@@ -0,0 +1 @@
<html></html>

View File

@@ -0,0 +1 @@
<html></html>

View File

@@ -0,0 +1 @@
<html></html>

View File

@@ -0,0 +1 @@
<html></html>

View File

@@ -0,0 +1 @@
<html></html>

View File

@@ -0,0 +1 @@
<html></html>

View File

@@ -0,0 +1 @@
<html></html>

View File

@@ -0,0 +1 @@
<html></html>

View File

@@ -0,0 +1 @@
<html></html>

View File

@@ -0,0 +1,409 @@
import { test, describe } from 'node:test';
import assert from 'node:assert';
import { existsSync, mkdirSync, writeFileSync, rmSync, readFileSync } from 'fs';
import { join } from 'path';
import { fileURLToPath } from 'url';
import { dirname } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// 导入要测试的模块
import { smartInject } from '../../lib/framework-inject.js';
describe('框架注入集成测试', () => {
const testDir = join(__dirname, 'fixtures');
test.before(() => {
if (!existsSync(testDir)) {
mkdirSync(testDir, { recursive: true });
}
});
test.after(() => {
// 清理所有测试文件
const filesToClean = [
'vite.config.js',
'vite.config.ts',
'next.config.js',
'app/layout.tsx',
'app/layout.js',
'pages/_document.tsx',
'pages/_document.js',
'test.html'
];
filesToClean.forEach(file => {
const filePath = join(testDir, file);
if (existsSync(filePath)) {
rmSync(filePath);
}
});
const dirsToClean = ['app', 'pages'];
dirsToClean.forEach(dir => {
const dirPath = join(testDir, dir);
if (existsSync(dirPath)) {
rmSync(dirPath, { recursive: true });
}
});
});
describe('Vite 项目注入', () => {
test('应该成功注入到 Vite 配置文件', () => {
// 创建 Vite 配置文件
const viteConfig = `import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
port: 3000
}
})`;
writeFileSync(join(testDir, 'vite.config.js'), viteConfig);
// 执行注入
const result = smartInject({
remote: 'http://localhost:9000/dev-monitor.js',
dryRun: true,
verbose: true
}, testDir);
assert.strictEqual(result, true);
});
test('应该检查已存在的插件', () => {
// 创建已有插件的 Vite 配置
const viteConfigWithPlugin = `import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [
react(),
// dev-inject-plugin
{
name: 'dev-inject-plugin',
transformIndexHtml(html) {
if (process.env.NODE_ENV === 'development') {
return html.replace('</head>', '<script src="/existing.js"></script></head>');
}
return html;
}
}
],
server: {
port: 3000
}
})`;
writeFileSync(join(testDir, 'vite.config.js'), viteConfigWithPlugin);
// 执行注入(应该检测到已存在)
const result = smartInject({
remote: 'http://localhost:9000/dev-monitor.js',
dryRun: true
}, testDir);
assert.strictEqual(result, true);
});
test('应该处理没有 plugins 数组的情况', () => {
// 创建没有 plugins 的配置
const simpleConfig = `export default {
server: {
port: 3000
}
}`;
writeFileSync(join(testDir, 'vite.config.js'), simpleConfig);
const result = smartInject({
remote: '/scripts/monitor.js',
dryRun: true
}, testDir);
assert.strictEqual(result, true);
});
test('应该处理 TypeScript 配置文件', () => {
const tsConfig = `import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()]
})`;
writeFileSync(join(testDir, 'vite.config.ts'), tsConfig);
const result = smartInject({
remote: '/scripts/monitor.js',
dryRun: true
}, testDir);
assert.strictEqual(result, true);
});
});
describe('Next.js App Router 注入', () => {
test('应该成功注入到 app/layout.tsx', () => {
// 创建 Next.js App Router 结构
mkdirSync(join(testDir, 'app'), { recursive: true });
writeFileSync(join(testDir, 'next.config.js'), 'module.exports = {}');
const layoutContent = `import type { Metadata } from 'next'
import { Inter } from 'next/font/google'
import './globals.css'
const inter = Inter({ subsets: ['latin'] })
export const metadata: Metadata = {
title: 'Next.js App',
description: 'Generated by create next app',
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body className={inter.className}>{children}</body>
</html>
)
}`;
writeFileSync(join(testDir, 'app', 'layout.tsx'), layoutContent);
const result = smartInject({
remote: 'http://localhost:9000/dev-monitor.js',
dryRun: true
}, testDir);
assert.strictEqual(result, true);
});
test('应该处理 app/layout.js', () => {
mkdirSync(join(testDir, 'app'), { recursive: true });
writeFileSync(join(testDir, 'next.config.js'), 'module.exports = {}');
const jsLayout = `import './globals.css'
export default function RootLayout({ children }) {
return (
<html lang="en">
<body>{children}</body>
</html>
)
}`;
writeFileSync(join(testDir, 'app', 'layout.js'), jsLayout);
const result = smartInject({
remote: '/scripts/monitor.js',
dryRun: true
}, testDir);
assert.strictEqual(result, true);
});
});
describe('Next.js Pages Router 注入', () => {
test('应该成功注入到 pages/_document.tsx', () => {
mkdirSync(join(testDir, 'pages'), { recursive: true });
writeFileSync(join(testDir, 'next.config.js'), 'module.exports = {}');
const documentContent = `import { Html, Head, Main, NextScript } from 'next/document'
export default function Document() {
return (
<Html lang="en">
<Head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
</Head>
<body>
<Main />
<NextScript />
</body>
</Html>
)
}`;
writeFileSync(join(testDir, 'pages', '_document.tsx'), documentContent);
const result = smartInject({
remote: 'http://localhost:9000/dev-monitor.js',
dryRun: true
}, testDir);
assert.strictEqual(result, true);
});
test('应该处理 pages/_document.js', () => {
mkdirSync(join(testDir, 'pages'), { recursive: true });
writeFileSync(join(testDir, 'next.config.js'), 'module.exports = {}');
const jsDocument = `const { Html, Head, Main, NextScript } = require('next/document')
export default function Document() {
return (
<Html lang="en">
<Head>
<title>My App</title>
</Head>
<body>
<Main />
<NextScript />
</body>
</Html>
)
}`;
writeFileSync(join(testDir, 'pages', '_document.js'), jsDocument);
const result = smartInject({
remote: '/scripts/monitor.js',
dryRun: true
}, testDir);
assert.strictEqual(result, true);
});
});
describe('混合模式处理', () => {
test('应该优先注入到 app/layout', () => {
// 创建混合模式结构
mkdirSync(join(testDir, 'app'), { recursive: true });
mkdirSync(join(testDir, 'pages'), { recursive: true });
writeFileSync(join(testDir, 'next.config.js'), 'module.exports = {}');
writeFileSync(join(testDir, 'app', 'layout.tsx'), 'export default function Layout() { return <div>App Layout</div>; }');
writeFileSync(join(testDir, 'pages', '_document.tsx'), 'export default function Document() { return <div>Document</div>; }');
const result = smartInject({
remote: '/scripts/monitor.js',
dryRun: true
}, testDir);
assert.strictEqual(result, true);
});
});
describe('错误处理', () => {
test('应该处理不支持的框架类型', () => {
writeFileSync(join(testDir, 'index.html'), '<html></html>');
try {
smartInject({
remote: '/scripts/monitor.js',
dryRun: false
}, testDir);
assert.fail('应该抛出错误');
} catch (error) {
assert.match(error.message, /不支持的项目类型/);
}
});
test('应该处理配置文件不存在的情况', () => {
writeFileSync(join(testDir, 'next.config.js'), 'module.exports = {}');
// 不创建 layout 文件
try {
smartInject({
remote: '/scripts/monitor.js',
dryRun: false
}, testDir);
assert.fail('应该抛出错误');
} catch (error) {
assert.match(error.message, /未找到/);
}
});
test('应该处理无效的配置文件', () => {
writeFileSync(join(testDir, 'vite.config.js'), 'invalid javascript content');
try {
smartInject({
remote: '/scripts/monitor.js',
dryRun: false
}, testDir);
assert.fail('应该抛出错误');
} catch (error) {
assert.ok(error.message.length > 0); // 应该有某种错误信息
}
});
});
describe('实际文件修改测试', () => {
test('应该实际修改 Vite 配置文件', () => {
const originalConfig = `import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
port: 3000
}
})`;
writeFileSync(join(testDir, 'vite.config.js'), originalConfig);
// 执行实际注入
const result = smartInject({
remote: '/scripts/monitor.js',
dryRun: false,
verbose: false
}, testDir);
assert.strictEqual(result, true);
// 验证文件被修改
const modifiedContent = readFileSync(join(testDir, 'vite.config.js'), 'utf8');
assert.match(modifiedContent, /dev-inject-plugin/);
assert.match(modifiedContent, /scripts\/monitor\.js/);
// 验证原始内容保留
assert.match(modifiedContent, /defineConfig/);
assert.match(modifiedContent, /react\(\)/);
});
test('应该能够移除注入的内容', () => {
// 先注入
smartInject({
remote: '/scripts/monitor.js',
dryRun: false
}, testDir);
// 然后移除
const result = removeInjection({
dryRun: false,
verbose: false
}, testDir);
assert.strictEqual(result, true);
// 验证插件被移除
const cleanedContent = readFileSync(join(testDir, 'vite.config.js'), 'utf8');
assert(!cleanedContent.includes('dev-inject-plugin'));
});
});
describe('不同脚本类型', () => {
test('应该处理 HTTP URL', () => {
writeFileSync(join(testDir, 'vite.config.js'), 'export default { plugins: [] }');
const result = smartInject({
remote: 'https://cdn.example.com/monitor.js',
dryRun: true
}, testDir);
assert.strictEqual(result, true);
});
test('应该处理本地路径', () => {
writeFileSync(join(testDir, 'vite.config.js'), 'export default { plugins: [] }');
const result = smartInject({
remote: '/assets/dev-monitor.js',
dryRun: true
}, testDir);
assert.strictEqual(result, true);
});
});
});

View File

@@ -0,0 +1,14 @@
{
"type": "module",
"scripts": {
"test": "node --test",
"test:unit": "node --test test/unit/*.test.js",
"test:integration": "node --test test/integration/*.test.js",
"test:e2e": "node --test test/e2e/*.test.js",
"test:watch": "node --test --watch",
"test:coverage": "node --test --experimental-test-coverage"
},
"devDependencies": {
"node:test": "^1.0.0"
}
}

View File

@@ -0,0 +1,180 @@
#!/usr/bin/env node
console.log('🧪 dev-inject 测试运行器\n');
console.log('=' .repeat(50));
import { execSync } from 'child_process';
import { existsSync } from 'fs';
import { join } from 'path';
const testDir = new URL('.', import.meta.url).pathname;
// 颜要在项目根目录运行
const projectRoot = process.cwd();
const cliPath = join(projectRoot, 'bin', 'index.js');
const testPackagePath = join(projectRoot, 'test', 'package.json');
// 检查测试环境
function checkTestEnvironment() {
if (!existsSync(cliPath)) {
console.error('❌ CLI 路径不存在:', cliPath);
console.log('请确保在 dev-inject 项目根目录运行此脚本');
process.exit(1);
}
if (!existsSync(testPackagePath)) {
console.error('❌ 测试配置不存在:', testPackagePath);
console.log('正在创建测试配置...');
execSync('npm init -y', { cwd: join(projectRoot, 'test'), stdio: 'inherit' });
}
}
// 运行指定类型的测试
function runTests(testType) {
console.log(`\n🚀 运行 ${testType.toUpperCase()} 测试...\n`);
try {
const testCommand = `node --test test/${testType}/*.test.js`;
execSync(testCommand, {
cwd: projectRoot,
stdio: 'inherit',
timeout: 60000 // 60秒超时
});
return true;
} catch (error) {
console.log(`\n${testType.toUpperCase()} 测试失败`);
if (error.signal === 'SIGTERM') {
console.log(' 原因: 测试超时');
}
return false;
}
}
// 运行特定测试
function runSpecificTest(testPath) {
console.log(`\n🎯 运行特定测试: ${testPath}\n`);
try {
execSync(`node --test ${testPath}`, {
cwd: projectRoot,
stdio: 'inherit',
timeout: 30000
});
return true;
} catch (error) {
console.log(`\n❌ 测试 ${testPath} 失败`);
return false;
}
}
// 生成测试报告
function generateTestReport(results) {
const totalTests = Object.keys(results).length;
const passedTests = Object.values(results).filter(Boolean).length;
const failedTests = totalTests - passedTests;
console.log('\n' + '='.repeat(50));
console.log('📊 测试报告汇总');
console.log('='.repeat(50));
console.log(`✅ 通过: ${passedTests}/${totalTests}`);
console.log(`❌ 失败: ${failedTests}/${totalTests}`);
console.log(`📈 成功率: ${Math.round((passedTests / totalTests) * 100)}%`);
if (passedTests === totalTests) {
console.log('\n🎉 所有测试都通过了dev-inject 功能正常!');
} else {
console.log('\n⚠ 有部分测试失败,请检查相关功能。');
}
console.log('\n🔍 测试覆盖范围:');
console.log(' 📦 单元测试 - 核心函数和逻辑');
console.log(' 🔧 集成测试 - 模块间协作');
console.log(' 🌐 端到端测试 - 完整工作流');
console.log(' 📱 框架支持 - Vite、Next.js、HTML');
return passedTests === totalTests;
}
// 主函数
function main() {
const args = process.argv.slice(2);
// 检查测试环境
checkTestEnvironment();
// 解析命令行参数
if (args.includes('--help') || args.includes('-h')) {
console.log(`
🧪 dev-inject 测试运行器
用法:
node test/test-runner.js [选项] [测试类型/文件]
选项:
--help, -h 显示帮助信息
--unit 运行单元测试
--integration 运行集成测试
--e2e 运行端到端测试
--all 运行所有测试
--verbose 显示详细输出
--watch 监听模式(需要 node --test 支持)
示例:
node test/test-runner.js --unit # 只运行单元测试
node test/test-runner.js --integration # 只运行集成测试
node test/test-runner.js --e2e # 只运行端到端测试
node test/test-runner.js --all # 运行所有测试
node test/test-runner.js test/unit/parseArgs.test.js # 运行特定测试文件
测试类型:
unit 单元测试(核心函数)
integration 集成测试(模块协作)
e2e 端到端测试(完整流程)
`);
process.exit(0);
}
const results = {};
if (args.includes('--all')) {
// 运行所有测试
console.log('🎯 运行所有测试套件...');
results.unit = runTests('unit');
results.integration = runTests('integration');
results.e2e = runTests('e2e');
} else if (args.includes('--unit')) {
// 运行单元测试
results.unit = runTests('unit');
} else if (args.includes('--integration')) {
// 运行集成测试
results.integration = runTests('integration');
} else if (args.includes('--e2e')) {
// 运行端到端测试
results.e2e = runTests('e2e');
} else if (args.some(arg => arg.endsWith('.test.js'))) {
// 运行特定测试文件
const testFile = args.find(arg => arg.endsWith('.test.js'));
const success = runSpecificTest(testFile);
process.exit(success ? 0 : 1);
} else {
// 默认运行快速测试
console.log('🚀 运行快速测试套件...');
results.unit = runTests('unit');
if (results.unit) {
console.log('\n✅ 单元测试通过,继续运行集成测试...');
results.integration = runTests('integration');
}
if (results.unit && results.integration) {
console.log('\n✅ 基础测试通过,可以运行完整测试:');
console.log(' node test/test-runner.js --all');
}
}
// 生成报告
const allPassed = generateTestReport(results);
process.exit(allPassed ? 0 : 1);
}
main();

View File

@@ -0,0 +1,160 @@
import { test, describe } from 'node:test';
import assert from 'node:assert';
import { existsSync, mkdirSync, writeFileSync, rmSync } from 'fs';
import { join } from 'path';
import { fileURLToPath } from 'url';
import { dirname } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// 导入要测试的模块
import { detectProjectType } from '../../lib/framework-inject.js';
describe('框架检测功能', () => {
const testDir = join(__dirname, 'fixtures');
// 测试前设置
test.before(() => {
// 确保测试目录存在
if (!existsSync(testDir)) {
mkdirSync(testDir, { recursive: true });
}
});
// 测试后清理
test.after(() => {
// 清理测试文件
const filesToClean = [
'vite.config.js',
'vite.config.ts',
'next.config.js',
'app/layout.tsx',
'pages/_document.tsx',
'public/index.html',
'index.html'
];
filesToClean.forEach(file => {
const filePath = join(testDir, file);
if (existsSync(filePath)) {
rmSync(filePath);
}
});
});
describe('Vite 项目检测', () => {
test('应该检测到 Vite 项目 (js)', () => {
writeFileSync(join(testDir, 'vite.config.js'), 'module.exports = {};');
const type = detectProjectType(testDir);
assert.strictEqual(type, 'vite');
});
test('应该检测到 Vite 项目 (ts)', () => {
writeFileSync(join(testDir, 'vite.config.ts'), 'export default {};');
const type = detectProjectType(testDir);
assert.strictEqual(type, 'vite');
});
test('优先检测 Vite 而不是其他配置', () => {
writeFileSync(join(testDir, 'vite.config.js'), 'module.exports = {};');
writeFileSync(join(testDir, 'next.config.js'), 'module.exports = {};');
writeFileSync(join(testDir, 'public/index.html'), '<html></html>');
const type = detectProjectType(testDir);
assert.strictEqual(type, 'vite');
});
});
describe('Next.js App Router 检测', () => {
test('应该检测到 Next.js App Router', () => {
writeFileSync(join(testDir, 'next.config.js'), 'module.exports = {};');
mkdirSync(join(testDir, 'app'), { recursive: true });
writeFileSync(join(testDir, 'app', 'layout.tsx'), 'export default function Layout() {}');
const type = detectProjectType(testDir);
assert.strictEqual(type, 'next-app');
});
test('应该检测到 Next.js App Router (js)', () => {
writeFileSync(join(testDir, 'next.config.js'), 'module.exports = {};');
mkdirSync(join(testDir, 'app'), { recursive: true });
writeFileSync(join(testDir, 'app', 'layout.js'), 'export default function Layout() {}');
const type = detectProjectType(testDir);
assert.strictEqual(type, 'next-app');
});
});
describe('Next.js Pages Router 检测', () => {
test('应该检测到 Next.js Pages Router', () => {
writeFileSync(join(testDir, 'next.config.js'), 'module.exports = {};');
mkdirSync(join(testDir, 'pages'), { recursive: true });
writeFileSync(join(testDir, 'pages', '_document.tsx'), 'export default function Document() {}');
const type = detectProjectType(testDir);
assert.strictEqual(type, 'next-pages');
});
test('应该检测到 Next.js Pages Router (js)', () => {
writeFileSync(join(testDir, 'next.config.js'), 'module.exports = {};');
mkdirSync(join(testDir, 'pages'), { recursive: true });
writeFileSync(join(testDir, 'pages', '_document.js'), 'export default function Document() {}');
const type = detectProjectType(testDir);
assert.strictEqual(type, 'next-pages');
});
});
describe('Next.js 混合模式检测', () => {
test('应该检测到 Next.js 混合模式', () => {
writeFileSync(join(testDir, 'next.config.js'), 'module.exports = {};');
mkdirSync(join(testDir, 'app'), { recursive: true });
mkdirSync(join(testDir, 'pages'), { recursive: true });
writeFileSync(join(testDir, 'app', 'layout.tsx'), 'export default function Layout() {}');
writeFileSync(join(testDir, 'pages', '_document.tsx'), 'export default function Document() {}');
const type = detectProjectType(testDir);
assert.strictEqual(type, 'next-hybrid');
});
});
describe('Create React App 检测', () => {
test('应该检测到 Create React App', () => {
mkdirSync(join(testDir, 'public'), { recursive: true });
writeFileSync(join(testDir, 'public', 'index.html'), '<html></html>');
const type = detectProjectType(testDir);
assert.strictEqual(type, 'create-react-app');
});
});
describe('HTML 静态项目检测', () => {
test('应该检测到 HTML 静态项目', () => {
writeFileSync(join(testDir, 'index.html'), '<html></html>');
const type = detectProjectType(testDir);
assert.strictEqual(type, 'html-static');
});
});
describe('未知项目检测', () => {
test('应该返回 unknown 对于空目录', () => {
const type = detectProjectType(testDir);
assert.strictEqual(type, 'unknown');
});
test('应该返回 unknown 对于不存在的目录', () => {
const type = detectProjectType('/non-existent-directory');
assert.strictEqual(type, 'unknown');
});
});
describe('优先级检测', () => {
test('Next.js 优先于 HTML', () => {
writeFileSync(join(testDir, 'next.config.js'), 'module.exports = {};');
writeFileSync(join(testDir, 'index.html'), '<html></html>');
const type = detectProjectType(testDir);
assert.strictEqual(type, 'next-app'); // 默认是 app 模式
});
test('Vite 优先于 Next.js', () => {
writeFileSync(join(testDir, 'vite.config.js'), 'module.exports = {};');
writeFileSync(join(testDir, 'next.config.js'), 'module.exports = {};');
const type = detectProjectType(testDir);
assert.strictEqual(type, 'vite');
});
});
});

View File

@@ -0,0 +1 @@
export default function Layout() {}

View File

@@ -0,0 +1 @@
<html></html>

View File

@@ -0,0 +1 @@
export default function Document() {}

View File

@@ -0,0 +1 @@
<html></html>

View File

@@ -0,0 +1,161 @@
import { test, describe } from 'node:test';
import assert from 'node:assert';
// 导入要测试的模块
import { parseArgs } from '../../lib/args.js';
describe('CLI 参数解析', () => {
describe('基本命令解析', () => {
test('应该解析 install 命令', () => {
const result = parseArgs(['install', '--remote', '/scripts/test.js']);
assert.strictEqual(result.command, 'install');
assert.strictEqual(result.options.remote, '/scripts/test.js');
});
test('应该解析 uninstall 命令', () => {
const result = parseArgs(['uninstall']);
assert.strictEqual(result.command, 'uninstall');
});
test('应该处理空参数', () => {
const result = parseArgs([]);
assert.strictEqual(result.command, null);
assert.deepStrictEqual(result.options, {});
});
});
describe('--remote 参数解析', () => {
test('应该解析 --remote=value 格式', () => {
const result = parseArgs(['install', '--remote=http://localhost:9000/monitor.js']);
assert.strictEqual(result.options.remote, 'http://localhost:9000/monitor.js');
});
test('应该解析 --remote value 格式', () => {
const result = parseArgs(['install', '--remote', '/scripts/dev-monitor.js']);
assert.strictEqual(result.options.remote, '/scripts/dev-monitor.js');
});
test('应该处理多个 --remote 参数(使用最后一个)', () => {
const result = parseArgs(['install', '--remote', '/first.js', '--remote', '/second.js']);
assert.strictEqual(result.options.remote, '/second.js');
});
});
describe('--file 参数解析', () => {
test('应该解析 --file=value 格式', () => {
const result = parseArgs(['install', '--remote=/test.js', '--file=public/index.html']);
assert.strictEqual(result.options.file, 'public/index.html');
});
test('应该解析 --file value 格式', () => {
const result = parseArgs(['install', '--remote=/test.js', '--file', './public/index.html']);
assert.strictEqual(result.options.file, './public/index.html');
});
});
describe('--framework 参数解析', () => {
test('应该解析 --framework 选项', () => {
const result = parseArgs(['install', '--remote=/test.js', '--framework']);
assert.strictEqual(result.options.framework, true);
});
test('应该解析 -f 简写选项', () => {
const result = parseArgs(['install', '--remote=/test.js', '-f']);
assert.strictEqual(result.options.framework, true);
});
});
describe('--dry-run 参数解析', () => {
test('应该解析 --dry-run 选项', () => {
const result = parseArgs(['install', '--remote=/test.js', '--dry-run']);
assert.strictEqual(result.options.dryRun, true);
});
});
describe('--verbose 参数解析', () => {
test('应该解析 --verbose 选项', () => {
const result = parseArgs(['install', '--remote=/test.js', '--verbose']);
assert.strictEqual(result.options.verbose, true);
});
});
describe('复合选项解析', () => {
test('应该解析多个选项', () => {
const result = parseArgs([
'install',
'--remote', 'http://localhost:9000/monitor.js',
'--framework',
'--dry-run',
'--verbose',
'--file', './test.html'
]);
assert.strictEqual(result.command, 'install');
assert.strictEqual(result.options.remote, 'http://localhost:9000/monitor.js');
assert.strictEqual(result.options.framework, true);
assert.strictEqual(result.options.dryRun, true);
assert.strictEqual(result.options.verbose, true);
assert.strictEqual(result.options.file, './test.html');
});
});
describe('错误处理', () => {
test('install 命令缺少 --remote 参数应该抛出错误', () => {
assert.throws(() => {
parseArgs(['install']);
}, /install 命令需要 --remote 参数/);
});
test('uninstall 命令不需要 --remote 参数', () => {
const result = parseArgs(['uninstall']);
assert.strictEqual(result.command, 'uninstall');
assert.deepStrictEqual(result.options, {});
});
});
describe('无效参数处理', () => {
test('应该忽略未知参数', () => {
// 使用 mock console.warn 来捕获警告
const originalWarn = console.warn;
let warnCalled = false;
console.warn = () => { warnCalled = true; };
const result = parseArgs(['install', '--remote=/test.js', '--unknown-option']);
console.warn = originalWarn;
// 应该仍然正确解析已知参数
assert.strictEqual(result.options.remote, '/test.js');
// 应该标记未知参数被忽略(通过 console.warn
assert.ok(warnCalled); // 如果有警告输出则测试通过
});
});
describe('参数顺序无关性', () => {
test('应该正确处理不同顺序的参数', () => {
const result1 = parseArgs(['install', '--remote=/test.js', '--framework', '--dry-run']);
const result2 = parseArgs(['install', '--dry-run', '--framework', '--remote=/test.js']);
assert.deepStrictEqual(result1.options, result2.options);
});
});
describe('边界情况', () => {
test('应该处理空的 --remote 值', () => {
const result = parseArgs(['install', '--remote', '']);
assert.strictEqual(result.options.remote, '');
});
test('应该处理包含特殊字符的路径', () => {
const complexPath = '/path with spaces/file.js';
const result = parseArgs(['install', '--remote', complexPath]);
assert.strictEqual(result.options.remote, complexPath);
});
test('应该处理长参数值', () => {
const longValue = 'a'.repeat(1000);
const result = parseArgs(['install', '--remote', longValue]);
assert.strictEqual(result.options.remote, longValue);
});
});
});

View File

@@ -0,0 +1,304 @@
import { test, describe } from 'node:test';
import assert from 'node:assert';
import { existsSync, mkdirSync, writeFileSync, rmSync } from 'fs';
import { join } from 'path';
import { fileURLToPath } from 'url';
import { dirname } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// 导入要测试的模块
import {
parseRemoteType,
generateScriptTag,
lookupFiles,
injectScriptToHtml,
removeInjectedScripts
} from '../../lib/utils.js';
describe('工具函数', () => {
const testDir = join(__dirname, 'fixtures');
test.before(() => {
if (!existsSync(testDir)) {
mkdirSync(testDir, { recursive: true });
}
});
test.after(() => {
const filesToClean = [
'test1.html',
'test2.html',
'subdir/test3.html',
'subdir/test4.html'
];
filesToClean.forEach(file => {
const filePath = join(testDir, file);
if (existsSync(filePath)) {
rmSync(filePath);
}
});
const subDir = join(testDir, 'subdir');
if (existsSync(subDir)) {
rmSync(subDir, { recursive: true });
}
});
describe('parseRemoteType', () => {
test('应该识别 HTTP URL', () => {
assert.strictEqual(parseRemoteType('http://localhost:9000/monitor.js'), 'url');
assert.strictEqual(parseRemoteType('http://example.com/script.js'), 'url');
});
test('应该识别 HTTPS URL', () => {
assert.strictEqual(parseRemoteType('https://cdn.example.com/monitor.js'), 'url');
assert.strictEqual(parseRemoteType('https://localhost:3000/dev-monitor.js'), 'url');
});
test('应该识别绝对路径', () => {
assert.strictEqual(parseRemoteType('/scripts/monitor.js'), 'absolute-path');
assert.strictEqual(parseRemoteType('/assets/dev-monitor.js'), 'absolute-path');
assert.strictEqual(parseRemoteType('/path/to/script.js'), 'absolute-path');
});
test('应该处理相对路径', () => {
try {
parseRemoteType('./script.js');
assert.fail('应该抛出错误');
} catch (error) {
assert.match(error.message, /不支持的远程路径格式/);
}
});
test('应该处理空字符串', () => {
try {
parseRemoteType('');
assert.fail('应该抛出错误');
} catch (error) {
assert.match(error.message, /不支持的远程路径格式/);
}
});
});
describe('generateScriptTag', () => {
test('应该生成 URL 类型的脚本标签', () => {
const tag = generateScriptTag('http://localhost:9000/monitor.js', 'url');
assert.strictEqual(tag, '<script src="http://localhost:9000/monitor.js"></script>');
});
test('应该生成绝对路径类型的脚本标签', () => {
const tag = generateScriptTag('/scripts/monitor.js', 'absolute-path');
assert.strictEqual(tag, '<script src="/scripts/monitor.js"></script>');
});
test('应该处理不同的路径格式', () => {
const testCases = [
'https://cdn.example.com/monitor.js',
'http://localhost:3000/dev-monitor.js',
'/assets/scripts/dev-monitor.js',
'/scripts/monitor.js'
];
testCases.forEach(path => {
const type = parseRemoteType(path);
const tag = generateScriptTag(path, type);
assert.match(tag, /^<script src="[^"]*"><\/script>$/);
assert(tag.includes(path));
});
});
});
describe('lookupFiles', () => {
test('应该查找指定目录中的 HTML 文件', () => {
// 创建测试文件
writeFileSync(join(testDir, 'test1.html'), '<html></html>');
writeFileSync(join(testDir, 'test2.html'), '<html></html>');
mkdirSync(join(testDir, 'subdir'), { recursive: true });
writeFileSync(join(testDir, 'subdir', 'test3.html'), '<html></html>');
const files = lookupFiles(testDir);
// 应该找到所有 HTML 文件
assert(files.length >= 3);
assert(files.some(f => f.includes('test1.html')));
assert(files.some(f => f.includes('test2.html')));
assert(files.some(f => f.includes('test3.html')));
});
test('应该优先返回 index.html', () => {
// 创建测试文件
writeFileSync(join(testDir, 'test.html'), '<html></html>');
writeFileSync(join(testDir, 'index.html'), '<html></html>');
const files = lookupFiles(testDir);
// 应该优先返回 index.html
assert.strictEqual(files.length, 1);
assert(files[0].includes('index.html'));
});
test('应该处理空目录', () => {
const files = lookupFiles(testDir);
assert.deepStrictEqual(files, []);
});
test('应该处理不存在的目录', () => {
const files = lookupFiles('/non-existent-directory');
assert.deepStrictEqual(files, []);
});
test('应该跳过 node_modules 目录', () => {
// 创建测试文件和 node_modules 目录
writeFileSync(join(testDir, 'test.html'), '<html></html>');
mkdirSync(join(testDir, 'node_modules'), { recursive: true });
writeFileSync(join(testDir, 'node_modules', 'index.html'), '<html></html>');
const files = lookupFiles(testDir);
// 不应该包含 node_modules 中的文件
assert(files.length === 1);
assert(files[0].includes('test.html'));
assert(!files.some(f => f.includes('node_modules')));
});
});
describe('injectScriptToHtml', () => {
test('应该注入脚本到 HTML 的 </head> 标签之前', () => {
const html = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Test</title>
</head>
<body>
<h1>Hello World</h1>
</body>
</html>`;
const scriptTag = '<script src="/scripts/monitor.js"></script>';
const result = injectScriptToHtml(html, scriptTag);
assert.match(result, /<script src="\/scripts\/monitor\.js"><\/script>\s*<\/head>/);
assert.match(result, /dev-inject/);
});
test('应该添加 dev-inject 标识注释', () => {
const html = `<html><head></head><body></body></html>`;
const scriptTag = '<script src="/test.js"></script>';
const result = injectScriptToHtml(html, scriptTag);
assert.match(result, /<!-- injected by dev-inject -->/);
});
test('应该处理没有 </head> 标签的情况', () => {
const html = `<html><head><title>Test</title></head><body>content</body></html>`;
const scriptTag = '<script src="/test.js"></script>';
const result = injectScriptToHtml(html, scriptTag);
assert.match(result, /<script src="\/test\.js"><\/script>/);
});
test('应该处理没有 <head> 标签的情况', () => {
const html = `<html><body>content</body></html>`;
const scriptTag = '<script src="/test.js"></script>';
const result = injectScriptToHtml(html, scriptTag);
assert.match(result, /<script src="\/test\.js"><\/script>/);
});
test('应该处理空 HTML', () => {
const html = '';
const scriptTag = '<script src="/test.js"></script>';
const result = injectScriptToHtml(html, scriptTag);
assert.match(result, /<script src="\/test\.js"><\/script>/);
});
});
describe('removeInjectedScripts', () => {
test('应该移除注入的脚本和注释', () => {
const htmlWithInjection = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Test</title>
<script src="/scripts/monitor.js"></script> <!-- injected by dev-inject -->
</head>
<body>
<h1>Hello World</h1>
</body>
</html>`;
const result = removeInjectedScripts(htmlWithInjection);
assert.match(result, /<head>\s*<meta charset="UTF-8">\s*<title>Test<\/title>\s*<\/head>/);
assert(!result.includes('dev-monitor.js'));
assert(!result.includes('injected by dev-inject'));
});
test('应该处理多个注入的脚本', () => {
const htmlWithMultiple = `<html><head>
<script src="/script1.js"></script> <!-- injected by dev-inject -->
<title>Test</title>
<script src="/script2.js"></script> <!-- injected by dev-inject -->
</head></html>`;
const result = removeInjectedScripts(htmlWithMultiple);
assert(!result.includes('script1.js'));
assert(!result.includes('script2.js'));
assert(!result.includes('injected by dev-inject'));
});
test('应该保留非注入的脚本', () => {
const htmlWithMixed = `<html><head>
<script src="/normal-script.js"></script>
<script src="/injected-script.js"></script> <!-- injected by dev-inject -->
<title>Test</title>
</head></html>`;
const result = removeInjectedScripts(htmlWithMixed);
assert(result.includes('normal-script.js'));
assert(!result.includes('injected-script.js'));
assert(!result.includes('injected by dev-inject'));
});
test('应该处理没有注入脚本的 HTML', () => {
const normalHtml = `<html><head><title>Test</title></head><body>content</body></html>`;
const result = removeInjectedScripts(normalHtml);
assert.strictEqual(result, normalHtml);
});
});
describe('边界情况和错误处理', () => {
test('应该处理特殊字符', () => {
const html = '<html><head><title>Test & "Special" Characters</title></head></html>';
const scriptTag = '<script src="/path/with spaces/script.js"></script>';
const result = injectScriptToHtml(html, scriptTag);
assert(result.includes('script.js'));
});
test('应该处理非常长的 HTML', () => {
const longHtml = '<html><head>' + '<meta charset="UTF-8">'.repeat(1000) + '</head></html>';
const scriptTag = '<script src="/test.js"></script>';
const result = injectScriptToHtml(longHtml, scriptTag);
assert(result.length > longHtml.length);
assert(result.includes('test.js'));
});
test('应该处理格式不规范的 HTML', () => {
const malformedHtml = '<html><HEAD><title>Test</Title></HEAD><BODY>content</BODY></HTML>';
const scriptTag = '<script src="/test.js"></script>';
const result = injectScriptToHtml(malformedHtml, scriptTag);
assert(result.includes('test.js'));
});
});
});