chore: bump version to 0.17.4

This commit is contained in:
coso
2025-12-24 13:39:37 +08:00
parent 100884781f
commit 05b2e24484
37 changed files with 5807 additions and 15 deletions
+123
View File
@@ -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": "登录已取消" }
}
```
+292
View File
@@ -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 - 浏览器启动参数
*/
+384
View File
@@ -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] - 消息
*/
+249
View File
@@ -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 流程
*/
+23
View File
@@ -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 }
);
});
});