mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-09-24 23:10:56 +08:00
chore: bump version to 0.17.4
This commit is contained in:
@@ -0,0 +1,123 @@
|
||||
# Playwright Login Sidecar
|
||||
|
||||
ProxyCast 的 Playwright 指纹浏览器登录 Sidecar 脚本。
|
||||
|
||||
## 文件索引
|
||||
|
||||
| 文件 | 描述 |
|
||||
|------|------|
|
||||
| `index.js` | Sidecar 主入口,处理 stdin/stdout JSON 通信 |
|
||||
| `browser-context.js` | 浏览器上下文工厂,配置反检测参数 |
|
||||
| `oauth-handler.js` | OAuth 流程处理器,处理授权码提取 |
|
||||
| `test/browser-config.property.test.js` | 浏览器配置属性测试 |
|
||||
| `test/oauth-url.property.test.js` | OAuth URL 解析属性测试 |
|
||||
|
||||
## 安装
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
## 测试
|
||||
|
||||
```bash
|
||||
# 运行所有测试
|
||||
npm test
|
||||
|
||||
# 运行属性测试
|
||||
npm run test:property
|
||||
```
|
||||
|
||||
## 使用
|
||||
|
||||
此脚本通过 Tauri Sidecar 机制调用,不建议直接运行。
|
||||
|
||||
## 通信协议
|
||||
|
||||
### 请求格式
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "login" | "cancel" | "check",
|
||||
"provider": "Google" | "Github" | "BuilderId",
|
||||
"authUrl": "https://oauth.provider.com/authorize?...",
|
||||
"callbackUrl": "http://localhost:PORT/callback"
|
||||
}
|
||||
```
|
||||
|
||||
### 响应格式
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true | false,
|
||||
"action": "login" | "cancel" | "check" | "progress" | "ready",
|
||||
"data": {
|
||||
"code": "authorization_code",
|
||||
"state": "state_value",
|
||||
"error": "error_message",
|
||||
"available": true,
|
||||
"browserPath": "/path/to/chromium",
|
||||
"message": "进度消息"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 支持的操作
|
||||
|
||||
### check - 检查 Playwright 可用性
|
||||
|
||||
请求:
|
||||
```json
|
||||
{ "action": "check" }
|
||||
```
|
||||
|
||||
响应:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"action": "check",
|
||||
"data": {
|
||||
"available": true,
|
||||
"browserPath": "/Users/xxx/Library/Caches/ms-playwright/chromium-xxx/..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### login - 启动 OAuth 登录
|
||||
|
||||
请求:
|
||||
```json
|
||||
{
|
||||
"action": "login",
|
||||
"authUrl": "https://accounts.google.com/o/oauth2/v2/auth?...",
|
||||
"callbackUrl": "http://localhost:8080/callback"
|
||||
}
|
||||
```
|
||||
|
||||
响应:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"action": "login",
|
||||
"data": {
|
||||
"code": "4/0AX4XfWh...",
|
||||
"state": "random_state"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### cancel - 取消登录
|
||||
|
||||
请求:
|
||||
```json
|
||||
{ "action": "cancel" }
|
||||
```
|
||||
|
||||
响应:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"action": "cancel",
|
||||
"data": { "message": "登录已取消" }
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,292 @@
|
||||
/**
|
||||
* @file browser-context.js
|
||||
* @description 浏览器上下文工厂,配置 Playwright 反检测参数
|
||||
* @module playwright-login/browser-context
|
||||
*
|
||||
* Requirements: 3.2, 3.3
|
||||
*/
|
||||
|
||||
import { chromium } from 'playwright';
|
||||
import { join } from 'path';
|
||||
import { homedir, platform } from 'os';
|
||||
import { existsSync } from 'fs';
|
||||
|
||||
/**
|
||||
* 默认浏览器配置
|
||||
* @type {BrowserConfig}
|
||||
*/
|
||||
export const DEFAULT_CONFIG = {
|
||||
// 视口大小 - 使用常见的桌面分辨率
|
||||
viewport: {
|
||||
width: 1920,
|
||||
height: 1080
|
||||
},
|
||||
// 真实的 Chrome 用户代理
|
||||
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
// 反检测启动参数
|
||||
args: [
|
||||
'--disable-blink-features=AutomationControlled',
|
||||
'--disable-features=IsolateOrigins,site-per-process',
|
||||
'--disable-site-isolation-trials',
|
||||
'--disable-web-security',
|
||||
'--disable-features=BlockInsecurePrivateNetworkRequests',
|
||||
'--no-first-run',
|
||||
'--no-default-browser-check',
|
||||
'--disable-infobars',
|
||||
'--window-position=0,0',
|
||||
'--ignore-certificate-errors',
|
||||
'--ignore-certificate-errors-spki-list',
|
||||
'--disable-gpu',
|
||||
'--disable-extensions',
|
||||
'--disable-default-apps',
|
||||
'--enable-features=NetworkService,NetworkServiceInProcess',
|
||||
'--disable-background-timer-throttling',
|
||||
'--disable-backgrounding-occluded-windows',
|
||||
'--disable-renderer-backgrounding'
|
||||
]
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取系统 Chrome 可执行文件路径
|
||||
* @returns {string | null} Chrome 路径,如果未找到则返回 null
|
||||
*/
|
||||
export function getSystemChromePath() {
|
||||
const os = platform();
|
||||
|
||||
if (os === 'darwin') {
|
||||
// macOS: 检查常见的 Chrome 安装位置
|
||||
const paths = [
|
||||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
||||
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
||||
join(homedir(), 'Applications/Google Chrome.app/Contents/MacOS/Google Chrome'),
|
||||
];
|
||||
for (const p of paths) {
|
||||
if (existsSync(p)) return p;
|
||||
}
|
||||
} else if (os === 'win32') {
|
||||
// Windows: 检查常见的 Chrome 安装位置
|
||||
const paths = [
|
||||
'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
|
||||
'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
|
||||
join(homedir(), 'AppData\\Local\\Google\\Chrome\\Application\\chrome.exe'),
|
||||
];
|
||||
for (const p of paths) {
|
||||
if (existsSync(p)) return p;
|
||||
}
|
||||
} else {
|
||||
// Linux: 检查常见的 Chrome 安装位置
|
||||
const paths = [
|
||||
'/usr/bin/google-chrome',
|
||||
'/usr/bin/google-chrome-stable',
|
||||
'/usr/bin/chromium',
|
||||
'/usr/bin/chromium-browser',
|
||||
'/snap/bin/chromium',
|
||||
];
|
||||
for (const p of paths) {
|
||||
if (existsSync(p)) return p;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Playwright Chromium 可执行文件路径
|
||||
* @returns {string | null} Playwright Chromium 路径,如果未找到则返回 null
|
||||
*/
|
||||
export function getPlaywrightChromiumPath() {
|
||||
const home = homedir();
|
||||
const os = platform();
|
||||
|
||||
// Playwright 浏览器缓存目录
|
||||
let cacheDir;
|
||||
if (os === 'darwin') {
|
||||
cacheDir = join(home, 'Library', 'Caches', 'ms-playwright');
|
||||
} else if (os === 'win32') {
|
||||
cacheDir = join(home, 'AppData', 'Local', 'ms-playwright');
|
||||
} else {
|
||||
cacheDir = join(home, '.cache', 'ms-playwright');
|
||||
}
|
||||
|
||||
// 查找 chromium 目录
|
||||
const chromiumDirs = [
|
||||
'chromium-1140', 'chromium-1134', 'chromium-1124', 'chromium-1117',
|
||||
'chromium-1112', 'chromium-1108', 'chromium-1105', 'chromium-1097',
|
||||
'chromium-1091', 'chromium-1084', 'chromium-1080', 'chromium-1076',
|
||||
'chromium-1067', 'chromium-1060', 'chromium-1055', 'chromium-1048',
|
||||
'chromium-1045', 'chromium-1041', 'chromium-1033', 'chromium-1028',
|
||||
'chromium-1024', 'chromium-1020', 'chromium-1015', 'chromium-1012',
|
||||
'chromium-1008', 'chromium-1005', 'chromium-1000', 'chromium'
|
||||
];
|
||||
|
||||
for (const dir of chromiumDirs) {
|
||||
let execPath;
|
||||
if (os === 'darwin') {
|
||||
execPath = join(cacheDir, dir, 'chrome-mac', 'Chromium.app', 'Contents', 'MacOS', 'Chromium');
|
||||
} else if (os === 'win32') {
|
||||
execPath = join(cacheDir, dir, 'chrome-win', 'chrome.exe');
|
||||
} else {
|
||||
execPath = join(cacheDir, dir, 'chrome-linux', 'chrome');
|
||||
}
|
||||
|
||||
if (existsSync(execPath)) {
|
||||
return execPath;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取可用的浏览器路径(优先系统 Chrome)
|
||||
* @returns {{ path: string, source: 'system' | 'playwright' } | null}
|
||||
*/
|
||||
export function getAvailableBrowserPath() {
|
||||
// 优先使用系统 Chrome
|
||||
const systemChrome = getSystemChromePath();
|
||||
if (systemChrome) {
|
||||
return { path: systemChrome, source: 'system' };
|
||||
}
|
||||
|
||||
// 其次使用 Playwright Chromium
|
||||
const playwrightChromium = getPlaywrightChromiumPath();
|
||||
if (playwrightChromium) {
|
||||
return { path: playwrightChromium, source: 'playwright' };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户数据目录路径
|
||||
* @returns {string} 用户数据目录的绝对路径
|
||||
*/
|
||||
export function getUserDataDir() {
|
||||
return join(homedir(), '.proxycast', 'playwright-data');
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建浏览器配置对象
|
||||
* @param {Partial<BrowserConfig>} [overrides] - 覆盖默认配置的选项
|
||||
* @returns {BrowserConfig} 完整的浏览器配置
|
||||
*/
|
||||
export function createBrowserConfig(overrides = {}) {
|
||||
const config = {
|
||||
userDataDir: overrides.userDataDir || getUserDataDir(),
|
||||
viewport: {
|
||||
...DEFAULT_CONFIG.viewport,
|
||||
...(overrides.viewport || {})
|
||||
},
|
||||
userAgent: overrides.userAgent || DEFAULT_CONFIG.userAgent,
|
||||
args: overrides.args || [...DEFAULT_CONFIG.args]
|
||||
};
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证浏览器配置是否完整有效
|
||||
* @param {BrowserConfig} config - 浏览器配置对象
|
||||
* @returns {{ valid: boolean, errors: string[] }} 验证结果
|
||||
*/
|
||||
export function validateBrowserConfig(config) {
|
||||
const errors = [];
|
||||
|
||||
// 检查 userDataDir
|
||||
if (!config.userDataDir || typeof config.userDataDir !== 'string') {
|
||||
errors.push('userDataDir 必须是有效的字符串路径');
|
||||
}
|
||||
|
||||
// 检查 viewport
|
||||
if (!config.viewport) {
|
||||
errors.push('viewport 配置缺失');
|
||||
} else {
|
||||
if (typeof config.viewport.width !== 'number' || config.viewport.width < 1024) {
|
||||
errors.push('viewport.width 必须 >= 1024');
|
||||
}
|
||||
if (typeof config.viewport.height !== 'number' || config.viewport.height < 768) {
|
||||
errors.push('viewport.height 必须 >= 768');
|
||||
}
|
||||
}
|
||||
|
||||
// 检查 userAgent
|
||||
if (!config.userAgent || typeof config.userAgent !== 'string') {
|
||||
errors.push('userAgent 必须是有效的字符串');
|
||||
} else if (!config.userAgent.includes('Mozilla') || !config.userAgent.includes('Chrome')) {
|
||||
errors.push('userAgent 必须包含真实的浏览器标识');
|
||||
}
|
||||
|
||||
// 检查反检测参数
|
||||
if (!Array.isArray(config.args)) {
|
||||
errors.push('args 必须是数组');
|
||||
} else {
|
||||
const hasAntiDetection = config.args.some(arg =>
|
||||
arg.includes('AutomationControlled')
|
||||
);
|
||||
if (!hasAntiDetection) {
|
||||
errors.push('args 必须包含反检测参数 --disable-blink-features=AutomationControlled');
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 Playwright 浏览器上下文
|
||||
* @param {Partial<BrowserConfig>} [options] - 配置选项
|
||||
* @returns {Promise<{ context: BrowserContext, config: BrowserConfig, browserSource: string }>} 浏览器上下文和配置
|
||||
*/
|
||||
export async function createBrowserContext(options = {}) {
|
||||
const config = createBrowserConfig(options);
|
||||
const validation = validateBrowserConfig(config);
|
||||
|
||||
if (!validation.valid) {
|
||||
throw new Error(`浏览器配置无效: ${validation.errors.join(', ')}`);
|
||||
}
|
||||
|
||||
// 获取可用的浏览器路径
|
||||
const browserInfo = getAvailableBrowserPath();
|
||||
|
||||
if (!browserInfo) {
|
||||
throw new Error('未找到可用的浏览器。请安装 Google Chrome 或运行: npx playwright install chromium');
|
||||
}
|
||||
|
||||
console.error(`[Browser] 使用 ${browserInfo.source} 浏览器: ${browserInfo.path}`);
|
||||
|
||||
// 使用 launchPersistentContext 以支持持久化用户数据
|
||||
const context = await chromium.launchPersistentContext(config.userDataDir, {
|
||||
headless: false,
|
||||
executablePath: browserInfo.path, // 使用检测到的浏览器
|
||||
viewport: config.viewport,
|
||||
userAgent: config.userAgent,
|
||||
args: config.args,
|
||||
ignoreHTTPSErrors: true,
|
||||
// 额外的反检测设置
|
||||
bypassCSP: true,
|
||||
javaScriptEnabled: true,
|
||||
// 模拟真实用户环境
|
||||
locale: 'zh-CN',
|
||||
timezoneId: 'Asia/Shanghai',
|
||||
// 权限设置
|
||||
permissions: ['geolocation', 'notifications'],
|
||||
// 设备像素比
|
||||
deviceScaleFactor: 2,
|
||||
// 禁用 WebDriver 标志
|
||||
extraHTTPHeaders: {
|
||||
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8'
|
||||
}
|
||||
});
|
||||
|
||||
return { context, config, browserSource: browserInfo.source };
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {Object} BrowserConfig
|
||||
* @property {string} userDataDir - 用户数据目录路径
|
||||
* @property {{ width: number, height: number }} viewport - 视口大小
|
||||
* @property {string} userAgent - 用户代理字符串
|
||||
* @property {string[]} args - 浏览器启动参数
|
||||
*/
|
||||
@@ -0,0 +1,384 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* @file index.js
|
||||
* @description Playwright Sidecar 主入口,处理 stdin/stdout JSON 通信
|
||||
* @module playwright-login
|
||||
*
|
||||
* Requirements: 3.1, 3.4, 3.5, 5.1, 5.2, 5.3, 5.4
|
||||
*
|
||||
* 通信协议:
|
||||
* - 请求格式: { action: 'login' | 'cancel' | 'check', provider?: string, callbackUrl?: string, authUrl?: string }
|
||||
* - 响应格式: { success: boolean, action: string, data?: { code?, state?, error?, available?, browserPath? } }
|
||||
*
|
||||
* 错误处理 (Requirements 5.1, 5.2, 5.3, 5.4):
|
||||
* - 浏览器启动失败: 返回详细错误信息和故障排除建议
|
||||
* - OAuth 超时: 返回超时错误,允许重试
|
||||
* - 用户取消: 优雅处理取消操作
|
||||
* - 详细日志: 记录所有错误用于调试
|
||||
*/
|
||||
|
||||
import { createInterface } from 'readline';
|
||||
import { chromium } from 'playwright';
|
||||
import { existsSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { homedir, platform } from 'os';
|
||||
import { createBrowserContext, getAvailableBrowserPath, getSystemChromePath, getPlaywrightChromiumPath } from './browser-context.js';
|
||||
import { createOAuthHandler } from './oauth-handler.js';
|
||||
|
||||
/** @type {import('playwright').BrowserContext | null} */
|
||||
let currentContext = null;
|
||||
|
||||
/** @type {ReturnType<typeof createOAuthHandler> | null} */
|
||||
let currentOAuthHandler = null;
|
||||
|
||||
/**
|
||||
* 发送响应到 stdout
|
||||
* @param {SidecarResponse} response
|
||||
*/
|
||||
function sendResponse(response) {
|
||||
console.log(JSON.stringify(response));
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送进度消息
|
||||
* @param {string} message
|
||||
*/
|
||||
function sendProgress(message) {
|
||||
sendResponse({
|
||||
success: true,
|
||||
action: 'progress',
|
||||
data: { message }
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查浏览器是否可用(优先系统 Chrome)
|
||||
* @returns {Promise<{ available: boolean, browserPath?: string, browserSource?: string, error?: string }>}
|
||||
*/
|
||||
async function checkPlaywrightAvailable() {
|
||||
try {
|
||||
const browserInfo = getAvailableBrowserPath();
|
||||
|
||||
if (browserInfo) {
|
||||
return {
|
||||
available: true,
|
||||
browserPath: browserInfo.path,
|
||||
browserSource: browserInfo.source
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
available: false,
|
||||
error: '未找到可用的浏览器。请安装 Google Chrome 或运行: npx playwright install chromium'
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
available: false,
|
||||
error: `检查浏览器时出错: ${error.message}`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理登录请求
|
||||
* @param {SidecarRequest} request
|
||||
*/
|
||||
async function handleLogin(request) {
|
||||
const { authUrl, callbackUrl } = request;
|
||||
|
||||
if (!authUrl) {
|
||||
sendResponse({
|
||||
success: false,
|
||||
action: 'login',
|
||||
data: { error: '缺少 authUrl 参数' }
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!callbackUrl) {
|
||||
sendResponse({
|
||||
success: false,
|
||||
action: 'login',
|
||||
data: { error: '缺少 callbackUrl 参数' }
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
sendProgress('正在启动浏览器...');
|
||||
|
||||
// 创建浏览器上下文
|
||||
// Requirements: 5.1 - 处理浏览器启动失败
|
||||
let context, config;
|
||||
try {
|
||||
const result = await createBrowserContext();
|
||||
context = result.context;
|
||||
config = result.config;
|
||||
currentContext = context;
|
||||
} catch (browserError) {
|
||||
// 浏览器启动失败,返回详细错误信息
|
||||
const errorMessage = browserError.message || '未知错误';
|
||||
console.error('[Playwright] 浏览器启动失败:', errorMessage);
|
||||
|
||||
sendResponse({
|
||||
success: false,
|
||||
action: 'login',
|
||||
data: {
|
||||
error: `启动浏览器失败: ${errorMessage}`,
|
||||
errorType: 'BROWSER_LAUNCH_FAILED'
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
sendProgress(`浏览器已启动,用户数据目录: ${config.userDataDir}`);
|
||||
|
||||
// 创建 OAuth 处理器
|
||||
// Requirements: 5.2 - 处理 OAuth 超时
|
||||
currentOAuthHandler = createOAuthHandler(context, {
|
||||
authUrl,
|
||||
callbackUrl,
|
||||
timeout: 5 * 60 * 1000, // 5 分钟超时
|
||||
onProgress: sendProgress
|
||||
});
|
||||
|
||||
// 启动 OAuth 流程
|
||||
const result = await currentOAuthHandler.start();
|
||||
|
||||
// 关闭浏览器
|
||||
await cleanup();
|
||||
|
||||
if (result.success) {
|
||||
sendResponse({
|
||||
success: true,
|
||||
action: 'login',
|
||||
data: {
|
||||
code: result.code,
|
||||
state: result.state
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// 根据错误类型返回不同的错误信息
|
||||
// Requirements: 5.2, 5.3, 5.4
|
||||
let errorType = 'UNKNOWN';
|
||||
const errorMessage = result.error || '未知错误';
|
||||
|
||||
if (errorMessage.includes('超时') || errorMessage.includes('timeout')) {
|
||||
errorType = 'OAUTH_TIMEOUT';
|
||||
} else if (errorMessage.includes('取消') || errorMessage.includes('cancel')) {
|
||||
errorType = 'USER_CANCELLED';
|
||||
} else if (errorMessage.includes('关闭') || errorMessage.includes('closed')) {
|
||||
errorType = 'BROWSER_CLOSED';
|
||||
} else if (errorMessage.includes('code') || errorMessage.includes('授权码')) {
|
||||
errorType = 'CODE_EXTRACTION_FAILED';
|
||||
}
|
||||
|
||||
console.error('[Playwright] OAuth 流程失败:', errorMessage, 'Type:', errorType);
|
||||
|
||||
sendResponse({
|
||||
success: false,
|
||||
action: 'login',
|
||||
data: {
|
||||
error: errorMessage,
|
||||
errorType
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
await cleanup();
|
||||
|
||||
// Requirements: 5.4 - 记录详细错误日志
|
||||
const errorMessage = error.message || '未知错误';
|
||||
console.error('[Playwright] 登录过程出错:', errorMessage);
|
||||
console.error('[Playwright] 错误堆栈:', error.stack);
|
||||
|
||||
sendResponse({
|
||||
success: false,
|
||||
action: 'login',
|
||||
data: {
|
||||
error: errorMessage,
|
||||
errorType: 'SCRIPT_ERROR'
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理取消请求
|
||||
* Requirements: 5.3 - 处理用户取消
|
||||
*/
|
||||
async function handleCancel() {
|
||||
try {
|
||||
console.log('[Playwright] 收到取消请求');
|
||||
|
||||
if (currentOAuthHandler) {
|
||||
await currentOAuthHandler.cancel();
|
||||
}
|
||||
await cleanup();
|
||||
|
||||
sendResponse({
|
||||
success: true,
|
||||
action: 'cancel',
|
||||
data: { message: '登录已取消' }
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Playwright] 取消登录时出错:', error.message);
|
||||
|
||||
sendResponse({
|
||||
success: false,
|
||||
action: 'cancel',
|
||||
data: { error: error.message || '取消失败' }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理检查请求
|
||||
*/
|
||||
async function handleCheck() {
|
||||
const status = await checkPlaywrightAvailable();
|
||||
|
||||
sendResponse({
|
||||
success: status.available,
|
||||
action: 'check',
|
||||
data: status
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理资源
|
||||
*/
|
||||
async function cleanup() {
|
||||
currentOAuthHandler = null;
|
||||
|
||||
if (currentContext) {
|
||||
try {
|
||||
await currentContext.close();
|
||||
} catch {
|
||||
// 忽略关闭错误
|
||||
}
|
||||
currentContext = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理请求
|
||||
* @param {string} line - JSON 格式的请求
|
||||
*/
|
||||
async function handleRequest(line) {
|
||||
let request;
|
||||
|
||||
try {
|
||||
request = JSON.parse(line);
|
||||
} catch {
|
||||
sendResponse({
|
||||
success: false,
|
||||
action: 'unknown',
|
||||
data: { error: '无效的 JSON 格式' }
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const { action } = request;
|
||||
|
||||
switch (action) {
|
||||
case 'login':
|
||||
await handleLogin(request);
|
||||
break;
|
||||
case 'cancel':
|
||||
await handleCancel();
|
||||
break;
|
||||
case 'check':
|
||||
await handleCheck();
|
||||
break;
|
||||
default:
|
||||
sendResponse({
|
||||
success: false,
|
||||
action: action || 'unknown',
|
||||
data: { error: `未知的 action: ${action}` }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 主函数
|
||||
*/
|
||||
async function main() {
|
||||
// 设置 stdin 为行模式
|
||||
const rl = createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
terminal: false
|
||||
});
|
||||
|
||||
// 监听输入
|
||||
rl.on('line', async (line) => {
|
||||
if (line.trim()) {
|
||||
await handleRequest(line.trim());
|
||||
}
|
||||
});
|
||||
|
||||
// 监听关闭
|
||||
rl.on('close', async () => {
|
||||
await cleanup();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
// 监听错误
|
||||
process.on('uncaughtException', async (error) => {
|
||||
sendResponse({
|
||||
success: false,
|
||||
action: 'error',
|
||||
data: { error: `未捕获的异常: ${error.message}` }
|
||||
});
|
||||
await cleanup();
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
process.on('unhandledRejection', async (reason) => {
|
||||
sendResponse({
|
||||
success: false,
|
||||
action: 'error',
|
||||
data: { error: `未处理的 Promise 拒绝: ${reason}` }
|
||||
});
|
||||
});
|
||||
|
||||
// 发送就绪信号
|
||||
sendResponse({
|
||||
success: true,
|
||||
action: 'ready',
|
||||
data: { message: 'Playwright Sidecar 已就绪' }
|
||||
});
|
||||
}
|
||||
|
||||
// 启动
|
||||
main().catch(async (error) => {
|
||||
sendResponse({
|
||||
success: false,
|
||||
action: 'error',
|
||||
data: { error: `启动失败: ${error.message}` }
|
||||
});
|
||||
await cleanup();
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
/**
|
||||
* @typedef {Object} SidecarRequest
|
||||
* @property {'login' | 'cancel' | 'check'} action - 操作类型
|
||||
* @property {string} [provider] - OAuth 提供商
|
||||
* @property {string} [authUrl] - OAuth 授权 URL
|
||||
* @property {string} [callbackUrl] - 回调 URL
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} SidecarResponse
|
||||
* @property {boolean} success - 是否成功
|
||||
* @property {string} action - 操作类型
|
||||
* @property {Object} [data] - 响应数据
|
||||
* @property {string} [data.code] - 授权码
|
||||
* @property {string} [data.state] - 状态参数
|
||||
* @property {string} [data.error] - 错误信息
|
||||
* @property {boolean} [data.available] - Playwright 是否可用
|
||||
* @property {string} [data.browserPath] - 浏览器路径
|
||||
* @property {string} [data.message] - 消息
|
||||
*/
|
||||
@@ -0,0 +1,249 @@
|
||||
/**
|
||||
* @file oauth-handler.js
|
||||
* @description OAuth 流程处理器,处理授权码提取
|
||||
* @module playwright-login/oauth-handler
|
||||
*
|
||||
* Requirements: 4.1, 4.2, 4.3
|
||||
*/
|
||||
|
||||
/**
|
||||
* OAuth 回调 URL 模式
|
||||
* @type {RegExp}
|
||||
*/
|
||||
const CALLBACK_URL_PATTERN = /^https?:\/\/localhost(:\d+)?\/callback/;
|
||||
|
||||
/**
|
||||
* 解析 OAuth 回调 URL,提取授权码和状态
|
||||
* @param {string} url - 回调 URL
|
||||
* @returns {OAuthCallbackResult} 解析结果
|
||||
*/
|
||||
export function parseCallbackUrl(url) {
|
||||
if (!url || typeof url !== 'string') {
|
||||
return {
|
||||
success: false,
|
||||
error: 'URL 不能为空'
|
||||
};
|
||||
}
|
||||
|
||||
let parsedUrl;
|
||||
try {
|
||||
parsedUrl = new URL(url);
|
||||
} catch (e) {
|
||||
return {
|
||||
success: false,
|
||||
error: `无效的 URL 格式: ${e.message}`
|
||||
};
|
||||
}
|
||||
|
||||
const params = parsedUrl.searchParams;
|
||||
|
||||
// 检查是否有错误参数
|
||||
const error = params.get('error');
|
||||
if (error) {
|
||||
const errorDescription = params.get('error_description');
|
||||
return {
|
||||
success: false,
|
||||
error: errorDescription ? `${error}: ${errorDescription}` : error
|
||||
};
|
||||
}
|
||||
|
||||
// 提取授权码
|
||||
const code = params.get('code');
|
||||
if (!code) {
|
||||
return {
|
||||
success: false,
|
||||
error: '回调 URL 中缺少 code 参数'
|
||||
};
|
||||
}
|
||||
|
||||
// 提取状态(可选)
|
||||
const state = params.get('state');
|
||||
|
||||
return {
|
||||
success: true,
|
||||
code,
|
||||
state: state || undefined
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查 URL 是否为 OAuth 回调 URL
|
||||
* @param {string} url - 要检查的 URL
|
||||
* @param {string} [expectedCallbackBase] - 期望的回调基础 URL
|
||||
* @returns {boolean} 是否为回调 URL
|
||||
*/
|
||||
export function isCallbackUrl(url, expectedCallbackBase) {
|
||||
if (!url || typeof url !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsedUrl = new URL(url);
|
||||
|
||||
// 如果提供了期望的回调基础 URL,进行精确匹配
|
||||
if (expectedCallbackBase) {
|
||||
const expectedParsed = new URL(expectedCallbackBase);
|
||||
return (
|
||||
parsedUrl.hostname === expectedParsed.hostname &&
|
||||
parsedUrl.port === expectedParsed.port &&
|
||||
parsedUrl.pathname === expectedParsed.pathname
|
||||
);
|
||||
}
|
||||
|
||||
// 默认匹配 localhost 回调模式
|
||||
return CALLBACK_URL_PATTERN.test(url);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 OAuth 流程处理器
|
||||
* @param {BrowserContext} context - Playwright 浏览器上下文
|
||||
* @param {OAuthHandlerOptions} options - 处理器选项
|
||||
* @returns {OAuthHandler} OAuth 处理器实例
|
||||
*/
|
||||
export function createOAuthHandler(context, options) {
|
||||
const {
|
||||
authUrl,
|
||||
callbackUrl,
|
||||
timeout = 5 * 60 * 1000, // 默认 5 分钟超时
|
||||
onProgress
|
||||
} = options;
|
||||
|
||||
let page = null;
|
||||
let cancelled = false;
|
||||
let timeoutId = null;
|
||||
|
||||
/**
|
||||
* 启动 OAuth 流程
|
||||
* @returns {Promise<OAuthCallbackResult>}
|
||||
*/
|
||||
async function start() {
|
||||
if (cancelled) {
|
||||
return { success: false, error: '流程已取消' };
|
||||
}
|
||||
|
||||
try {
|
||||
// 创建新页面
|
||||
page = await context.newPage();
|
||||
|
||||
onProgress?.('正在打开授权页面...');
|
||||
|
||||
// 设置超时
|
||||
const timeoutPromise = new Promise((_, reject) => {
|
||||
timeoutId = setTimeout(() => {
|
||||
reject(new Error('OAuth 流程超时'));
|
||||
}, timeout);
|
||||
});
|
||||
|
||||
// 导航到授权 URL
|
||||
await page.goto(authUrl, { waitUntil: 'domcontentloaded' });
|
||||
|
||||
onProgress?.('等待用户授权...');
|
||||
|
||||
// 等待回调 URL
|
||||
const resultPromise = waitForCallback(page, callbackUrl);
|
||||
|
||||
// 竞争:回调完成 vs 超时
|
||||
const result = await Promise.race([resultPromise, timeoutPromise]);
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
return result;
|
||||
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (cancelled) {
|
||||
return { success: false, error: '用户取消了登录' };
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: error.message || '未知错误'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 等待回调 URL
|
||||
* @param {Page} page - Playwright 页面
|
||||
* @param {string} callbackUrl - 期望的回调 URL
|
||||
* @returns {Promise<OAuthCallbackResult>}
|
||||
*/
|
||||
async function waitForCallback(page, callbackUrl) {
|
||||
return new Promise((resolve, reject) => {
|
||||
// 监听页面导航
|
||||
const handleNavigation = (frame) => {
|
||||
if (frame !== page.mainFrame()) return;
|
||||
|
||||
const currentUrl = page.url();
|
||||
|
||||
if (isCallbackUrl(currentUrl, callbackUrl)) {
|
||||
onProgress?.('检测到回调,正在提取授权码...');
|
||||
const result = parseCallbackUrl(currentUrl);
|
||||
resolve(result);
|
||||
}
|
||||
};
|
||||
|
||||
page.on('framenavigated', handleNavigation);
|
||||
|
||||
// 监听页面关闭
|
||||
page.on('close', () => {
|
||||
if (!cancelled) {
|
||||
resolve({ success: false, error: '用户关闭了浏览器窗口' });
|
||||
}
|
||||
});
|
||||
|
||||
// 检查当前 URL(可能已经在回调页面)
|
||||
const currentUrl = page.url();
|
||||
if (isCallbackUrl(currentUrl, callbackUrl)) {
|
||||
const result = parseCallbackUrl(currentUrl);
|
||||
resolve(result);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消 OAuth 流程
|
||||
*/
|
||||
async function cancel() {
|
||||
cancelled = true;
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (page && !page.isClosed()) {
|
||||
try {
|
||||
await page.close();
|
||||
} catch {
|
||||
// 忽略关闭错误
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
start,
|
||||
cancel
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {Object} OAuthCallbackResult
|
||||
* @property {boolean} success - 是否成功
|
||||
* @property {string} [code] - 授权码
|
||||
* @property {string} [state] - 状态参数
|
||||
* @property {string} [error] - 错误信息
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} OAuthHandlerOptions
|
||||
* @property {string} authUrl - OAuth 授权 URL
|
||||
* @property {string} callbackUrl - 回调 URL
|
||||
* @property {number} [timeout] - 超时时间(毫秒)
|
||||
* @property {(message: string) => void} [onProgress] - 进度回调
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} OAuthHandler
|
||||
* @property {() => Promise<OAuthCallbackResult>} start - 启动 OAuth 流程
|
||||
* @property {() => Promise<void>} cancel - 取消 OAuth 流程
|
||||
*/
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "playwright-login",
|
||||
"version": "1.0.0",
|
||||
"description": "Playwright sidecar script for ProxyCast fingerprint browser login",
|
||||
"main": "index.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "node index.js",
|
||||
"test": "node --test",
|
||||
"test:property": "node --test test/*.property.test.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"playwright": "^1.40.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"fast-check": "^3.15.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"author": "ProxyCast",
|
||||
"license": "MIT"
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
/**
|
||||
* @file browser-config.property.test.js
|
||||
* @description 浏览器配置属性测试
|
||||
*
|
||||
* **Property 2: 浏览器配置完整性**
|
||||
* **Validates: Requirements 3.2, 3.3**
|
||||
*
|
||||
* *For any* Playwright 浏览器配置对象,该配置应该:
|
||||
* - 包含有效的 `userDataDir` 路径
|
||||
* - 包含反检测所需的启动参数
|
||||
* - 设置合理的视口大小(宽度 >= 1024,高度 >= 768)
|
||||
* - 包含真实的用户代理字符串
|
||||
*/
|
||||
|
||||
import { test, describe } from 'node:test';
|
||||
import assert from 'node:assert';
|
||||
import fc from 'fast-check';
|
||||
import {
|
||||
createBrowserConfig,
|
||||
validateBrowserConfig,
|
||||
DEFAULT_CONFIG,
|
||||
getUserDataDir
|
||||
} from '../browser-context.js';
|
||||
|
||||
describe('Property 2: 浏览器配置完整性', () => {
|
||||
|
||||
/**
|
||||
* Feature: playwright-fingerprint-login, Property 2: 浏览器配置完整性
|
||||
*
|
||||
* *For any* 默认创建的浏览器配置,应该包含有效的 userDataDir 路径
|
||||
*/
|
||||
test('默认配置应包含有效的 userDataDir 路径', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.constant(null), // 不需要生成输入,测试默认行为
|
||||
() => {
|
||||
const config = createBrowserConfig();
|
||||
|
||||
// userDataDir 必须是非空字符串
|
||||
assert.strictEqual(typeof config.userDataDir, 'string');
|
||||
assert.ok(config.userDataDir.length > 0, 'userDataDir 不能为空');
|
||||
|
||||
// 应该包含 .proxycast 目录
|
||||
assert.ok(
|
||||
config.userDataDir.includes('.proxycast') ||
|
||||
config.userDataDir.includes('proxycast'),
|
||||
'userDataDir 应该在 proxycast 相关目录下'
|
||||
);
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Feature: playwright-fingerprint-login, Property 2: 浏览器配置完整性
|
||||
*
|
||||
* *For any* 生成的视口配置,宽度应 >= 1024,高度应 >= 768
|
||||
*/
|
||||
test('视口大小应满足最小要求', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.record({
|
||||
width: fc.integer({ min: 1024, max: 3840 }),
|
||||
height: fc.integer({ min: 768, max: 2160 })
|
||||
}),
|
||||
(viewport) => {
|
||||
const config = createBrowserConfig({ viewport });
|
||||
const validation = validateBrowserConfig(config);
|
||||
|
||||
// 配置应该有效
|
||||
assert.ok(validation.valid, `配置应该有效: ${validation.errors.join(', ')}`);
|
||||
|
||||
// 视口尺寸应该符合要求
|
||||
assert.ok(config.viewport.width >= 1024, '宽度应 >= 1024');
|
||||
assert.ok(config.viewport.height >= 768, '高度应 >= 768');
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Feature: playwright-fingerprint-login, Property 2: 浏览器配置完整性
|
||||
*
|
||||
* *For any* 小于最小要求的视口配置,验证应该失败
|
||||
*/
|
||||
test('小于最小要求的视口应验证失败', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.oneof(
|
||||
fc.record({
|
||||
width: fc.integer({ min: 1, max: 1023 }),
|
||||
height: fc.integer({ min: 768, max: 2160 })
|
||||
}),
|
||||
fc.record({
|
||||
width: fc.integer({ min: 1024, max: 3840 }),
|
||||
height: fc.integer({ min: 1, max: 767 })
|
||||
})
|
||||
),
|
||||
(viewport) => {
|
||||
const config = {
|
||||
userDataDir: '/tmp/test',
|
||||
viewport,
|
||||
userAgent: DEFAULT_CONFIG.userAgent,
|
||||
args: DEFAULT_CONFIG.args
|
||||
};
|
||||
const validation = validateBrowserConfig(config);
|
||||
|
||||
// 配置应该无效
|
||||
assert.ok(!validation.valid, '小于最小要求的视口应验证失败');
|
||||
assert.ok(validation.errors.length > 0, '应该有错误信息');
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Feature: playwright-fingerprint-login, Property 2: 浏览器配置完整性
|
||||
*
|
||||
* *For any* 默认配置,应包含反检测启动参数
|
||||
*/
|
||||
test('默认配置应包含反检测启动参数', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.constant(null),
|
||||
() => {
|
||||
const config = createBrowserConfig();
|
||||
|
||||
// 必须包含 AutomationControlled 禁用参数
|
||||
const hasAntiDetection = config.args.some(arg =>
|
||||
arg.includes('AutomationControlled')
|
||||
);
|
||||
assert.ok(hasAntiDetection, '应包含 --disable-blink-features=AutomationControlled');
|
||||
|
||||
// args 应该是数组
|
||||
assert.ok(Array.isArray(config.args), 'args 应该是数组');
|
||||
assert.ok(config.args.length > 0, 'args 不应为空');
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Feature: playwright-fingerprint-login, Property 2: 浏览器配置完整性
|
||||
*
|
||||
* *For any* 默认配置,应包含真实的用户代理字符串
|
||||
*/
|
||||
test('默认配置应包含真实的用户代理字符串', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.constant(null),
|
||||
() => {
|
||||
const config = createBrowserConfig();
|
||||
|
||||
// userAgent 必须是字符串
|
||||
assert.strictEqual(typeof config.userAgent, 'string');
|
||||
|
||||
// 必须包含 Mozilla 标识
|
||||
assert.ok(
|
||||
config.userAgent.includes('Mozilla'),
|
||||
'userAgent 应包含 Mozilla'
|
||||
);
|
||||
|
||||
// 必须包含 Chrome 标识
|
||||
assert.ok(
|
||||
config.userAgent.includes('Chrome'),
|
||||
'userAgent 应包含 Chrome'
|
||||
);
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Feature: playwright-fingerprint-login, Property 2: 浏览器配置完整性
|
||||
*
|
||||
* *For any* 自定义 userDataDir,配置应正确使用该路径
|
||||
*/
|
||||
test('自定义 userDataDir 应被正确使用', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.string({ minLength: 1, maxLength: 200 }).filter(s => s.trim().length > 0),
|
||||
(customPath) => {
|
||||
const config = createBrowserConfig({ userDataDir: customPath });
|
||||
|
||||
// 应该使用自定义路径
|
||||
assert.strictEqual(config.userDataDir, customPath);
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Feature: playwright-fingerprint-login, Property 2: 浏览器配置完整性
|
||||
*
|
||||
* *For any* 缺少反检测参数的配置,验证应该失败
|
||||
*/
|
||||
test('缺少反检测参数的配置应验证失败', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(fc.string().filter(s => !s.includes('AutomationControlled')), { minLength: 0, maxLength: 10 }),
|
||||
(args) => {
|
||||
const config = {
|
||||
userDataDir: '/tmp/test',
|
||||
viewport: { width: 1920, height: 1080 },
|
||||
userAgent: DEFAULT_CONFIG.userAgent,
|
||||
args
|
||||
};
|
||||
const validation = validateBrowserConfig(config);
|
||||
|
||||
// 配置应该无效
|
||||
assert.ok(!validation.valid, '缺少反检测参数应验证失败');
|
||||
assert.ok(
|
||||
validation.errors.some(e => e.includes('反检测参数')),
|
||||
'错误信息应提及反检测参数'
|
||||
);
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Feature: playwright-fingerprint-login, Property 2: 浏览器配置完整性
|
||||
*
|
||||
* *For any* 完整有效的配置,验证应该通过
|
||||
*/
|
||||
test('完整有效的配置应验证通过', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.record({
|
||||
userDataDir: fc.string({ minLength: 1, maxLength: 200 }).filter(s => s.trim().length > 0),
|
||||
viewport: fc.record({
|
||||
width: fc.integer({ min: 1024, max: 3840 }),
|
||||
height: fc.integer({ min: 768, max: 2160 })
|
||||
}),
|
||||
userAgent: fc.constant(DEFAULT_CONFIG.userAgent),
|
||||
args: fc.constant([...DEFAULT_CONFIG.args])
|
||||
}),
|
||||
(config) => {
|
||||
const validation = validateBrowserConfig(config);
|
||||
|
||||
// 配置应该有效
|
||||
assert.ok(validation.valid, `配置应该有效: ${validation.errors.join(', ')}`);
|
||||
assert.strictEqual(validation.errors.length, 0, '不应有错误');
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,250 @@
|
||||
/**
|
||||
* @file oauth-url.property.test.js
|
||||
* @description OAuth URL 解析属性测试
|
||||
*
|
||||
* **Property 3: OAuth 回调 URL 解析正确性**
|
||||
* **Validates: Requirements 4.1, 4.2**
|
||||
*
|
||||
* *For any* 有效的 OAuth 回调 URL,URL 解析函数应该:
|
||||
* - 正确提取 `code` 参数
|
||||
* - 正确提取 `state` 参数
|
||||
* - 对于缺少必要参数的 URL 返回错误
|
||||
* - 正确处理 URL 编码的参数值
|
||||
*/
|
||||
|
||||
import { test, describe } from 'node:test';
|
||||
import assert from 'node:assert';
|
||||
import fc from 'fast-check';
|
||||
import { parseCallbackUrl, isCallbackUrl } from '../oauth-handler.js';
|
||||
|
||||
describe('Property 3: OAuth 回调 URL 解析正确性', () => {
|
||||
|
||||
/**
|
||||
* Feature: playwright-fingerprint-login, Property 3: OAuth 回调 URL 解析正确性
|
||||
*
|
||||
* *For any* 包含有效 code 参数的回调 URL,应正确提取 code
|
||||
*/
|
||||
test('应正确提取 code 参数', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
// 生成随机的授权码(字母数字组合)
|
||||
fc.stringMatching(/^[a-zA-Z0-9_-]{10,100}$/),
|
||||
fc.integer({ min: 3000, max: 65535 }),
|
||||
(code, port) => {
|
||||
const url = `http://localhost:${port}/callback?code=${code}`;
|
||||
const result = parseCallbackUrl(url);
|
||||
|
||||
assert.ok(result.success, `解析应成功: ${result.error}`);
|
||||
assert.strictEqual(result.code, code, 'code 应正确提取');
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Feature: playwright-fingerprint-login, Property 3: OAuth 回调 URL 解析正确性
|
||||
*
|
||||
* *For any* 包含 code 和 state 参数的回调 URL,应正确提取两者
|
||||
*/
|
||||
test('应正确提取 code 和 state 参数', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.stringMatching(/^[a-zA-Z0-9_-]{10,100}$/),
|
||||
fc.stringMatching(/^[a-zA-Z0-9_-]{10,50}$/),
|
||||
fc.integer({ min: 3000, max: 65535 }),
|
||||
(code, state, port) => {
|
||||
const url = `http://localhost:${port}/callback?code=${code}&state=${state}`;
|
||||
const result = parseCallbackUrl(url);
|
||||
|
||||
assert.ok(result.success, `解析应成功: ${result.error}`);
|
||||
assert.strictEqual(result.code, code, 'code 应正确提取');
|
||||
assert.strictEqual(result.state, state, 'state 应正确提取');
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Feature: playwright-fingerprint-login, Property 3: OAuth 回调 URL 解析正确性
|
||||
*
|
||||
* *For any* 缺少 code 参数的 URL,应返回错误
|
||||
*/
|
||||
test('缺少 code 参数应返回错误', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.integer({ min: 3000, max: 65535 }),
|
||||
fc.option(fc.stringMatching(/^[a-zA-Z0-9_-]{10,50}$/), { nil: undefined }),
|
||||
(port, state) => {
|
||||
let url = `http://localhost:${port}/callback`;
|
||||
if (state) {
|
||||
url += `?state=${state}`;
|
||||
}
|
||||
|
||||
const result = parseCallbackUrl(url);
|
||||
|
||||
assert.ok(!result.success, '缺少 code 应返回失败');
|
||||
assert.ok(result.error, '应有错误信息');
|
||||
assert.ok(result.error.includes('code'), '错误信息应提及 code');
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Feature: playwright-fingerprint-login, Property 3: OAuth 回调 URL 解析正确性
|
||||
*
|
||||
* *For any* URL 编码的参数值,应正确解码
|
||||
*/
|
||||
test('应正确处理 URL 编码的参数值', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
// 生成包含特殊字符的字符串
|
||||
fc.stringMatching(/^[a-zA-Z0-9]{5,20}$/),
|
||||
fc.constantFrom('+', '/', '=', '%', '&'),
|
||||
fc.stringMatching(/^[a-zA-Z0-9]{5,20}$/),
|
||||
fc.integer({ min: 3000, max: 65535 }),
|
||||
(prefix, special, suffix, port) => {
|
||||
const originalCode = `${prefix}${special}${suffix}`;
|
||||
const encodedCode = encodeURIComponent(originalCode);
|
||||
const url = `http://localhost:${port}/callback?code=${encodedCode}`;
|
||||
|
||||
const result = parseCallbackUrl(url);
|
||||
|
||||
assert.ok(result.success, `解析应成功: ${result.error}`);
|
||||
assert.strictEqual(result.code, originalCode, 'URL 编码的 code 应正确解码');
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Feature: playwright-fingerprint-login, Property 3: OAuth 回调 URL 解析正确性
|
||||
*
|
||||
* *For any* 包含 error 参数的 URL,应返回错误
|
||||
*/
|
||||
test('包含 error 参数应返回错误', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.constantFrom('access_denied', 'invalid_request', 'unauthorized_client', 'server_error'),
|
||||
fc.option(fc.string({ minLength: 5, maxLength: 50 }), { nil: undefined }),
|
||||
fc.integer({ min: 3000, max: 65535 }),
|
||||
(error, errorDescription, port) => {
|
||||
let url = `http://localhost:${port}/callback?error=${error}`;
|
||||
if (errorDescription) {
|
||||
url += `&error_description=${encodeURIComponent(errorDescription)}`;
|
||||
}
|
||||
|
||||
const result = parseCallbackUrl(url);
|
||||
|
||||
assert.ok(!result.success, '包含 error 应返回失败');
|
||||
assert.ok(result.error, '应有错误信息');
|
||||
assert.ok(result.error.includes(error), '错误信息应包含 error 值');
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Feature: playwright-fingerprint-login, Property 3: OAuth 回调 URL 解析正确性
|
||||
*
|
||||
* *For any* 无效的 URL 格式,应返回错误
|
||||
*/
|
||||
test('无效的 URL 格式应返回错误', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.oneof(
|
||||
fc.constant(''),
|
||||
fc.constant('not-a-url'),
|
||||
fc.constant('://missing-protocol'),
|
||||
fc.constant('http://'),
|
||||
fc.constant(null),
|
||||
fc.constant(undefined)
|
||||
),
|
||||
(invalidUrl) => {
|
||||
const result = parseCallbackUrl(invalidUrl);
|
||||
|
||||
assert.ok(!result.success, '无效 URL 应返回失败');
|
||||
assert.ok(result.error, '应有错误信息');
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Feature: playwright-fingerprint-login, Property 3: OAuth 回调 URL 解析正确性
|
||||
*
|
||||
* *For any* localhost 回调 URL,isCallbackUrl 应返回 true
|
||||
*/
|
||||
test('localhost 回调 URL 应被正确识别', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.integer({ min: 3000, max: 65535 }),
|
||||
fc.stringMatching(/^[a-zA-Z0-9_-]{10,50}$/),
|
||||
(port, code) => {
|
||||
const url = `http://localhost:${port}/callback?code=${code}`;
|
||||
|
||||
assert.ok(isCallbackUrl(url), 'localhost 回调 URL 应被识别');
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Feature: playwright-fingerprint-login, Property 3: OAuth 回调 URL 解析正确性
|
||||
*
|
||||
* *For any* 非回调 URL,isCallbackUrl 应返回 false
|
||||
*/
|
||||
test('非回调 URL 应返回 false', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.constantFrom(
|
||||
'https://accounts.google.com/oauth',
|
||||
'https://github.com/login/oauth/authorize',
|
||||
'http://localhost:3000/home',
|
||||
'http://localhost:3000/api/auth',
|
||||
'https://example.com/callback'
|
||||
),
|
||||
(url) => {
|
||||
assert.ok(!isCallbackUrl(url), '非回调 URL 应返回 false');
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Feature: playwright-fingerprint-login, Property 3: OAuth 回调 URL 解析正确性
|
||||
*
|
||||
* *For any* 指定的期望回调 URL,应精确匹配
|
||||
*/
|
||||
test('应精确匹配期望的回调 URL', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.integer({ min: 3000, max: 65535 }),
|
||||
fc.stringMatching(/^[a-zA-Z0-9_-]{10,50}$/),
|
||||
(port, code) => {
|
||||
const expectedCallback = `http://localhost:${port}/callback`;
|
||||
const actualUrl = `http://localhost:${port}/callback?code=${code}`;
|
||||
const wrongPortUrl = `http://localhost:${port + 1}/callback?code=${code}`;
|
||||
|
||||
assert.ok(
|
||||
isCallbackUrl(actualUrl, expectedCallback),
|
||||
'匹配的回调 URL 应返回 true'
|
||||
);
|
||||
assert.ok(
|
||||
!isCallbackUrl(wrongPortUrl, expectedCallback),
|
||||
'端口不匹配应返回 false'
|
||||
);
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user