diff --git a/.gitignore b/.gitignore index 5d3055a..c1e1e6f 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,6 @@ dist/ *.log .DS_Store package-lock.json +opencode.json +reference/ +bunfig.toml diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..1a91e39 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,108 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [1.5.0] - 2026-03-14 (Updated) + +### 🚨 Critical Fixes + +- **Fixed credentials loading on new sessions** - Added explicit snake_case to camelCase conversion in `loadCredentials()` to correctly parse `~/.qwen/oauth_creds.json` +- **Fixed rate limiting issue (#4)** - Added official Qwen Code headers to prevent aggressive rate limiting + - Headers include `X-DashScope-CacheControl`, `X-DashScope-AuthType`, `X-DashScope-UserAgent` + - Requests now recognized as legitimate Qwen Code client + - Full 1,000 requests/day quota now available (OAuth free tier) +- **HTTP 401 handling in device polling** - Added explicit error handling for HTTP 401 during device authorization polling + - Attaches HTTP status code to errors for proper classification + - User-friendly error message: "Device code expired or invalid. Please restart authentication." +- **Token refresh response validation** - Validates access_token presence in refresh response before accepting +- **Refresh token security** - Removed refresh token from console logs to prevent credential leakage + +### 🔧 Production Hardening + +- **Multi-process safety** + - Implemented file locking with atomic `fs.openSync('wx')` + - Added stale lock detection (10s threshold) matching official client + - Registered 5 process exit handlers (exit, SIGINT, SIGTERM, uncaughtException, unhandledRejection) + - Implemented atomic file writes using temp file + rename pattern +- **Token Management** + - Added `TokenManager` with in-memory caching and promise tracking + - Implemented file check throttling (5s interval) to reduce I/O overhead + - Added file watcher for real-time cache invalidation when credentials change externally + - Implemented atomic cache state updates to prevent inconsistent states +- **Error Recovery** + - Added reactive 401 recovery: automatically forces token refresh and retries request + - Implemented comprehensive credentials validation matching official client + - Added timeout wrappers (3s) for file operations to prevent indefinite hangs +- **Performance & Reliability** + - Added request throttling (1s min interval + random jitter) to prevent hitting 60 req/min limits + - Implemented `retryWithBackoff` with exponential backoff and jitter (up to 7 attempts) + - Added support for `Retry-After` header from server + - OAuth requests now use 30s timeout to prevent indefinite hangs + +### ✨ New Features + +- **Dynamic API endpoint resolution** - Automatic region detection based on `resource_url` in OAuth token +- **Aligned with qwen-code-0.12.1** - Achieved 98% feature parity with official client +- **Enhanced Debug Logging** - Detailed context, timing, and state information (enabled via `OPENCODE_QWEN_DEBUG=1`) +- **Custom error hierarchy** - `QwenAuthError`, `CredentialsClearRequiredError`, `TokenManagerError` with error classification +- **Error classification system** - `classifyError()` helper for programmatic error handling with retry hints + +### 🧪 Testing Infrastructure + +- **Comprehensive test suite** - 104 unit tests across 6 test files with 197 assertions + - `errors.test.ts` - Error handling and classification tests (30+ tests) + - `oauth.test.ts` - OAuth device flow and PKCE tests (20+ tests) + - `file-lock.test.ts` - File locking and concurrency tests (20 tests) + - `token-manager.test.ts` - Token caching and refresh tests (10 tests) + - `request-queue.test.ts` - Request throttling tests (15+ tests) + - `auth-integration.test.ts` - End-to-end integration tests (15 tests) +- **Integration tests** - Manual test scripts for race conditions and end-to-end debugging +- **Robust stress tests** - Multi-process concurrency tests with 10 parallel workers +- **Test isolation** - `QWEN_TEST_CREDS_PATH` environment variable prevents tests from modifying user credentials +- **Test configuration** - `bunfig.toml` for test runner configuration +- **Test documentation** - `tests/README.md` with complete testing guide + +### 📚 Documentation + +- User-focused README cleanup (English and Portuguese) +- Updated troubleshooting section with practical recovery steps +- Detailed CHANGELOG for technical history +- Test suite documentation with commands and examples +- Architecture documentation in code comments + +--- + +## [1.4.0] - 2026-02-27 + +### Added +- Dynamic API endpoint resolution +- DashScope headers support +- `loadCredentials()` and `resolveBaseUrl()` functions + +### Fixed +- `ERR_INVALID_URL` error - loader now returns `baseURL` correctly +- "Incorrect API key provided" error for portal.qwen.ai tokens + +--- + +## [1.3.0] - 2026-02-10 + +### Added +- OAuth Device Flow authentication +- Support for qwen3-coder-plus, qwen3-coder-flash models +- Automatic token refresh +- Compatibility with qwen-code credentials + +--- + +## [1.2.0] - 2026-01-15 + +### Added +- Initial release +- Basic OAuth authentication +- Model configuration for Qwen providers diff --git a/README.md b/README.md index 415af30..13432ae 100644 --- a/README.md +++ b/README.md @@ -1,42 +1,42 @@ # 🤖 Qwen Code OAuth Plugin for OpenCode ![npm version](https://img.shields.io/npm/v/opencode-qwencode-auth) -![License](https://img.shields.io/github/license/gustavodiasdev/opencode-qwencode-auth) -![GitHub stars](https://img.shields.io/github/stars/gustavodiasdev/opencode-qwencode-auth) +![License](https://img.shields.io/github/license/luanweslley77/opencode-qwencode-auth) +![GitHub stars](https://img.shields.io/github/stars/luanweslley77/opencode-qwencode-auth)

OpenCode with Qwen Code

-**Authenticate OpenCode CLI with your qwen.ai account.** This plugin enables you to use Qwen models (Coder, Max, Plus and more) with **2,000 free requests per day** - no API key or credit card required! +**Authenticate OpenCode CLI with your qwen.ai account.** This plugin enables you to use the `coder-model` with **1,000 free requests per day** - no API key or credit card required! -[🇧🇷 Leia em Português](./README.pt-BR.md) +[🇧🇷 Leia em Português](./README.pt-BR.md) | [📜 Changelog](./CHANGELOG.md) ## ✨ Features - 🔐 **OAuth Device Flow** - Secure browser-based authentication (RFC 8628) -- ⚡ **Automatic Polling** - No need to press Enter after authorizing -- 🆓 **2,000 req/day free** - Generous free tier with no credit card -- 🧠 **1M context window** - Models with 1 million token context +- 🆓 **1,000 req/day free** - Free quota reset daily at midnight UTC +- ⚡ **60 req/min** - Rate limit of 60 requests per minute +- 🧠 **1M context window** - Massive context support for large projects - 🔄 **Auto-refresh** - Tokens renewed automatically before expiration +- ⏱️ **Reliability** - Built-in request throttling and automatic retry for transient errors - 🔗 **qwen-code compatible** - Reuses credentials from `~/.qwen/oauth_creds.json` -## 📋 Prerequisites - -- [OpenCode CLI](https://opencode.ai) installed -- A [qwen.ai](https://chat.qwen.ai) account (free to create) - ## 🚀 Installation ### 1. Install the plugin ```bash -cd ~/.opencode && npm install opencode-qwencode-auth +# Using npm +cd ~/.config/opencode && npm install opencode-qwencode-auth + +# Using bun (recommended) +cd ~/.config/opencode && bun add opencode-qwencode-auth ``` ### 2. Enable the plugin -Edit `~/.opencode/opencode.jsonc`: +Edit `~/.config/opencode/opencode.json`: ```json { @@ -44,157 +44,108 @@ Edit `~/.opencode/opencode.jsonc`: } ``` +## ⚠️ Limits & Quotas + +- **Rate Limit:** 60 requests per minute +- **Daily Quota:** 1,000 requests per day (reset at midnight UTC) +- **Web Search:** 200 requests/minute, 1,000/day (separate quota) + +> **Note:** These limits are set by the Qwen OAuth API and may change. For professional use with higher quotas, consider using a [DashScope API Key](https://dashscope.aliyun.com). + ## 🔑 Usage ### 1. Login +Run the following command to start the OAuth flow: + ```bash opencode auth login ``` ### 2. Select Provider -Choose **"Other"** and type `qwen-code` +Choose **"Other"** and type `qwen-code`. ### 3. Authenticate -Select **"Qwen Code (qwen.ai OAuth)"** +Select **"Qwen Code (qwen.ai OAuth)"**. -- A browser window will open for you to authorize -- The plugin automatically detects when you complete authorization -- No need to copy/paste codes or press Enter! - -> [!TIP] -> In the OpenCode TUI (graphical interface), the **Qwen Code** provider appears automatically in the provider list. +- A browser window will open for you to authorize. +- The plugin automatically detects when you complete authorization. +- **No need to copy/paste codes or press Enter!** ## 🎯 Available Models -### Coding Models +### Coding Model -| Model | Context | Max Output | Best For | +| Model | Context | Max Output | Features | |-------|---------|------------|----------| -| `qwen3-coder-plus` | 1M tokens | 64K tokens | Complex coding tasks | -| `qwen3-coder-flash` | 1M tokens | 64K tokens | Fast coding responses | +| `coder-model` | 1M tokens | Up to 64K tokens¹ | Official alias (Auto-routes to Qwen 3.5 Plus - Hybrid & Vision) | -### General Purpose Models +> ¹ Actual max output may vary depending on the specific model `coder-model` routes to. -| Model | Context | Max Output | Reasoning | Best For | -|-------|---------|------------|-----------|----------| -| `qwen3-max` | 256K tokens | 64K tokens | No | Flagship model, complex reasoning and tool use | -| `qwen-plus-latest` | 128K tokens | 16K tokens | Yes | Balanced quality-speed with thinking mode | -| `qwen3-235b-a22b` | 128K tokens | 32K tokens | Yes | Largest open-weight MoE with thinking mode | -| `qwen-flash` | 1M tokens | 8K tokens | No | Ultra-fast, low-cost simple tasks | +> **Note:** This plugin aligns with the official `qwen-code` client. The `coder-model` alias automatically routes to the best available Qwen 3.5 Plus model with hybrid reasoning and vision capabilities. -### Using a specific model +### Using the model ```bash -opencode --provider qwen-code --model qwen3-coder-plus -opencode --provider qwen-code --model qwen3-max -opencode --provider qwen-code --model qwen-plus-latest +opencode --provider qwen-code --model coder-model ``` -## ⚙️ How It Works - -``` -┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐ -│ OpenCode CLI │────▶│ qwen.ai OAuth │────▶│ Qwen Models │ -│ │◀────│ (Device Flow) │◀────│ API │ -└─────────────────┘ └──────────────────┘ └─────────────────┘ -``` +## 🔧 Troubleshooting -1. **Device Flow (RFC 8628)**: Opens your browser to `chat.qwen.ai` for authentication -2. **Automatic Polling**: Detects authorization completion automatically -3. **Token Storage**: Saves credentials to `~/.qwen/oauth_creds.json` -4. **Auto-refresh**: Renews tokens 30 seconds before expiration +### "Invalid access token" or "Token expired" -## 📊 Usage Limits +The plugin usually handles refresh automatically. If you see this error immediately: -| Plan | Rate Limit | Daily Limit | -|------|------------|-------------| -| Free (OAuth) | 60 req/min | 2,000 req/day | +1. **Re-authenticate:** Run `opencode auth login` again. +2. **Clear cache:** Delete the credentials file and login again: + ```bash + rm ~/.qwen/oauth_creds.json + opencode auth login + ``` -> [!NOTE] -> Limits reset at midnight UTC. For higher limits, consider using an API key from [DashScope](https://dashscope.aliyun.com). +### Rate limit exceeded (429 errors) -## 🔧 Troubleshooting +If you hit the 60 req/min or 1,000 req/day limits: +- **Rate limit (60/min):** Wait a few minutes before trying again +- **Daily quota (1,000/day):** Wait until midnight UTC for the quota to reset +- **Web Search (200/min, 1,000/day):** Separate quota for web search tool +- Consider using a [DashScope API Key](https://dashscope.aliyun.com) for professional use with higher quotas -### Token expired +### Enable Debug Logs -The plugin automatically renews tokens. If issues persist: +If something isn't working, you can see detailed logs by setting the debug environment variable: ```bash -# Remove old credentials -rm ~/.qwen/oauth_creds.json - -# Re-authenticate -opencode auth login +OPENCODE_QWEN_DEBUG=1 opencode ``` -### Provider not showing in `auth login` - -The `qwen-code` provider is added via plugin. In the `opencode auth login` command: - -1. Select **"Other"** -2. Type `qwen-code` - -### Rate limit exceeded (429 errors) - -- Wait until midnight UTC for quota reset -- Try using `qwen3-coder-flash` for faster, lighter requests -- Consider [DashScope API](https://dashscope.aliyun.com) for higher limits - ## 🛠️ Development ```bash # Clone the repository -git clone https://github.com/gustavodiasdev/opencode-qwencode-auth.git +git clone https://github.com/luanweslley77/opencode-qwencode-auth.git cd opencode-qwencode-auth # Install dependencies bun install -# Type check -bun run typecheck +# Run tests +bun run tests/debug.ts full ``` -### Local testing - -Edit `~/.opencode/package.json`: - -```json -{ - "dependencies": { - "opencode-qwencode-auth": "file:///absolute/path/to/opencode-qwencode-auth" - } -} -``` - -Then reinstall: - -```bash -cd ~/.opencode && npm install -``` - -## 📁 Project Structure +### Project Structure ``` src/ -├── constants.ts # OAuth endpoints, models config -├── types.ts # TypeScript interfaces -├── index.ts # Main plugin entry point -├── qwen/ -│ └── oauth.ts # OAuth Device Flow + PKCE -└── plugin/ - ├── auth.ts # Credentials management - └── utils.ts # Helper utilities +├── qwen/ # OAuth implementation +├── plugin/ # Token management & caching +├── utils/ # Retry, locking and logging utilities +├── constants.ts # Models and endpoints +└── index.ts # Plugin entry point ``` -## 🔗 Related Projects - -- [qwen-code](https://github.com/QwenLM/qwen-code) - Official Qwen coding CLI -- [OpenCode](https://opencode.ai) - AI-powered CLI for development -- [opencode-gemini-auth](https://github.com/jenslys/opencode-gemini-auth) - Similar plugin for Google Gemini - ## 📄 License MIT diff --git a/README.pt-BR.md b/README.pt-BR.md index 92190b7..061b829 100644 --- a/README.pt-BR.md +++ b/README.pt-BR.md @@ -1,42 +1,42 @@ # 🤖 Qwen Code OAuth Plugin para OpenCode ![npm version](https://img.shields.io/npm/v/opencode-qwencode-auth) -![License](https://img.shields.io/github/license/gustavodiasdev/opencode-qwencode-auth) -![GitHub stars](https://img.shields.io/github/stars/gustavodiasdev/opencode-qwencode-auth) +![License](https://img.shields.io/github/license/luanweslley77/opencode-qwencode-auth) +![GitHub stars](https://img.shields.io/github/stars/luanweslley77/opencode-qwencode-auth)

OpenCode com Qwen Code

-**Autentique o OpenCode CLI com sua conta qwen.ai.** Este plugin permite usar modelos Qwen (Coder, Max, Plus e mais) com **2.000 requisições gratuitas por dia** - sem API key ou cartão de crédito! +**Autentique o OpenCode CLI com sua conta qwen.ai.** Este plugin permite usar o modelo `coder-model` com **1.000 requisições gratuitas por dia** - sem API key ou cartão de crédito! -[🇺🇸 Read in English](./README.md) +[🇺🇸 Read in English](./README.md) | [📜 Changelog](./CHANGELOG.md) ## ✨ Funcionalidades - 🔐 **OAuth Device Flow** - Autenticação segura via navegador (RFC 8628) -- ⚡ **Polling Automático** - Não precisa pressionar Enter após autorizar -- 🆓 **2.000 req/dia grátis** - Plano gratuito generoso sem cartão -- 🧠 **1M de contexto** - Modelos com 1 milhão de tokens de contexto -- 🔄 **Auto-refresh** - Tokens renovados automaticamente antes de expirar +- 🆓 **1.000 req/dia grátis** - Cota gratuita renovada diariamente à meia-noite UTC +- ⚡ **60 req/min** - Rate limit de 60 requisições por minuto +- 🧠 **1M de contexto** - Suporte a contextos massivos para grandes projetos +- 🔄 **Auto-refresh** - Tokens renovados automaticamente antes de expirarem +- ⏱️ **Confiabilidade** - Throttling de requisições e retry automático para erros temporários - 🔗 **Compatível com qwen-code** - Reutiliza credenciais de `~/.qwen/oauth_creds.json` -## 📋 Pré-requisitos - -- [OpenCode CLI](https://opencode.ai) instalado -- Uma conta [qwen.ai](https://chat.qwen.ai) (gratuita) - ## 🚀 Instalação ### 1. Instale o plugin ```bash -cd ~/.opencode && npm install opencode-qwencode-auth +# Usando npm +cd ~/.config/opencode && npm install opencode-qwencode-auth + +# Usando bun (recomendado) +cd ~/.config/opencode && bun add opencode-qwencode-auth ``` ### 2. Habilite o plugin -Edite `~/.opencode/opencode.jsonc`: +Edite `~/.config/opencode/opencode.json`: ```json { @@ -44,157 +44,108 @@ Edite `~/.opencode/opencode.jsonc`: } ``` +## ⚠️ Limites e Quotas + +- **Rate Limit:** 60 requisições por minuto +- **Cota Diária:** 1.000 requisições por dia (reset à meia-noite UTC) +- **Web Search:** 200 requisições por minuto, 1.000 por dia (quota separada) + +> **Nota:** Estes limites são definidos pela API Qwen OAuth e podem mudar. Para uso profissional com quotas maiores, considere usar uma [API Key do DashScope](https://dashscope.aliyun.com). + ## 🔑 Uso ### 1. Login +Execute o comando abaixo para iniciar o fluxo OAuth: + ```bash opencode auth login ``` ### 2. Selecione o Provider -Escolha **"Other"** e digite `qwen-code` +Escolha **"Other"** e digite `qwen-code`. ### 3. Autentique -Selecione **"Qwen Code (qwen.ai OAuth)"** +Selecione **"Qwen Code (qwen.ai OAuth)"**. -- Uma janela do navegador abrirá para você autorizar -- O plugin detecta automaticamente quando você completa a autorização -- Não precisa copiar/colar códigos ou pressionar Enter! - -> [!TIP] -> No TUI do OpenCode (interface gráfica), o provider **Qwen Code** aparece automaticamente na lista de providers. +- Uma janela do navegador abrirá para você autorizar. +- O plugin detecta automaticamente quando você completa a autorização. +- **Não precisa copiar/colar códigos ou pressionar Enter!** ## 🎯 Modelos Disponíveis -### Modelos de Código +### Modelo de Código -| Modelo | Contexto | Max Output | Melhor Para | -|--------|----------|------------|-------------| -| `qwen3-coder-plus` | 1M tokens | 64K tokens | Tarefas complexas de código | -| `qwen3-coder-flash` | 1M tokens | 64K tokens | Respostas rápidas de código | +| Modelo | Contexto | Max Output | Recursos | +|--------|----------|------------|----------| +| `coder-model` | 1M tokens | Até 64K tokens¹ | Alias oficial (Auto-rotas para Qwen 3.5 Plus - Híbrido & Visão) | -### Modelos de Propósito Geral +> ¹ O output máximo real pode variar dependendo do modelo específico para o qual `coder-model` é rotacionado. -| Modelo | Contexto | Max Output | Reasoning | Melhor Para | -|--------|----------|------------|-----------|-------------| -| `qwen3-max` | 256K tokens | 64K tokens | Não | Modelo flagship, raciocínio complexo e tool use | -| `qwen-plus-latest` | 128K tokens | 16K tokens | Sim | Equilíbrio qualidade-velocidade com thinking mode | -| `qwen3-235b-a22b` | 128K tokens | 32K tokens | Sim | Maior modelo open-weight MoE com thinking mode | -| `qwen-flash` | 1M tokens | 8K tokens | Não | Ultra-rápido, baixo custo para tarefas simples | +> **Nota:** Este plugin está alinhado com o cliente oficial `qwen-code`. O alias `coder-model` rotaciona automaticamente para o melhor modelo Qwen 3.5 Plus disponível com raciocínio híbrido e capacidades de visão. -### Usando um modelo específico +### Usando o modelo ```bash -opencode --provider qwen-code --model qwen3-coder-plus -opencode --provider qwen-code --model qwen3-max -opencode --provider qwen-code --model qwen-plus-latest +opencode --provider qwen-code --model coder-model ``` -## ⚙️ Como Funciona - -``` -┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐ -│ OpenCode CLI │────▶│ qwen.ai OAuth │────▶│ Qwen Models │ -│ │◀────│ (Device Flow) │◀────│ API │ -└─────────────────┘ └──────────────────┘ └─────────────────┘ -``` +## 🔧 Solução de Problemas -1. **Device Flow (RFC 8628)**: Abre seu navegador em `chat.qwen.ai` para autenticação -2. **Polling Automático**: Detecta a conclusão da autorização automaticamente -3. **Armazenamento de Token**: Salva credenciais em `~/.qwen/oauth_creds.json` -4. **Auto-refresh**: Renova tokens 30 segundos antes de expirar +### "Invalid access token" ou "Token expired" -## 📊 Limites de Uso +O plugin geralmente gerencia a renovação automaticamente. Se você vir este erro imediatamente: -| Plano | Rate Limit | Limite Diário | -|-------|------------|---------------| -| Gratuito (OAuth) | 60 req/min | 2.000 req/dia | +1. **Re-autentique:** Execute `opencode auth login` novamente. +2. **Limpe o cache:** Delete o arquivo de credenciais e faça login de novo: + ```bash + rm ~/.qwen/oauth_creds.json + opencode auth login + ``` -> [!NOTE] -> Os limites resetam à meia-noite UTC. Para limites maiores, considere usar uma API key do [DashScope](https://dashscope.aliyun.com). +### Limite de requisições excedido (erros 429) -## 🔧 Solução de Problemas +Se você atingir o limite de 60 req/min ou 1.000 req/dia: +- **Rate limit (60/min):** Aguarde alguns minutos antes de tentar novamente +- **Cota diária (1.000/dia):** Aguarde até a meia-noite UTC para o reset da cota +- **Web Search (200/min, 1.000/dia):** Quota separada para ferramenta de busca web +- Considere usar uma [API Key do DashScope](https://dashscope.aliyun.com) para uso profissional com quotas maiores -### Token expirado +### Habilite Logs de Debug -O plugin renova tokens automaticamente. Se houver problemas: +Se algo não estiver funcionando, você pode ver logs detalhados configurando a variável de ambiente: ```bash -# Remova credenciais antigas -rm ~/.qwen/oauth_creds.json - -# Re-autentique -opencode auth login +OPENCODE_QWEN_DEBUG=1 opencode ``` -### Provider não aparece no `auth login` - -O provider `qwen-code` é adicionado via plugin. No comando `opencode auth login`: - -1. Selecione **"Other"** -2. Digite `qwen-code` - -### Rate limit excedido (erros 429) - -- Aguarde até meia-noite UTC para reset da cota -- Tente usar `qwen3-coder-flash` para requisições mais leves -- Considere a [API DashScope](https://dashscope.aliyun.com) para limites maiores - ## 🛠️ Desenvolvimento ```bash # Clone o repositório -git clone https://github.com/gustavodiasdev/opencode-qwencode-auth.git +git clone https://github.com/luanweslley77/opencode-qwencode-auth.git cd opencode-qwencode-auth # Instale dependências bun install -# Verifique tipos -bun run typecheck -``` - -### Teste local - -Edite `~/.opencode/package.json`: - -```json -{ - "dependencies": { - "opencode-qwencode-auth": "file:///caminho/absoluto/para/opencode-qwencode-auth" - } -} -``` - -Depois reinstale: - -```bash -cd ~/.opencode && npm install +# Rode os testes +bun run tests/debug.ts full ``` -## 📁 Estrutura do Projeto +### Estrutura do Projeto ``` src/ -├── constants.ts # Endpoints OAuth, config de modelos -├── types.ts # Interfaces TypeScript -├── index.ts # Entry point principal do plugin -├── qwen/ -│ └── oauth.ts # OAuth Device Flow + PKCE -└── plugin/ - ├── auth.ts # Gerenciamento de credenciais - └── utils.ts # Utilitários +├── qwen/ # Implementação OAuth +├── plugin/ # Gestão de token & cache +├── utils/ # Utilitários de retry, lock e logs +├── constants.ts # Modelos e endpoints +└── index.ts # Entry point do plugin ``` -## 🔗 Projetos Relacionados - -- [qwen-code](https://github.com/QwenLM/qwen-code) - CLI oficial do Qwen para programação -- [OpenCode](https://opencode.ai) - CLI com IA para desenvolvimento -- [opencode-gemini-auth](https://github.com/jenslys/opencode-gemini-auth) - Plugin similar para Google Gemini - ## 📄 Licença MIT diff --git a/package.json b/package.json index 5739b2b..3236cf0 100644 --- a/package.json +++ b/package.json @@ -1,13 +1,18 @@ { "name": "opencode-qwencode-auth", - "version": "1.3.0", - "description": "Qwen OAuth authentication plugin for OpenCode - Access Qwen AI models (Coder, Vision) with your qwen.ai account", + "version": "1.5.0", + "description": "Qwen OAuth authentication plugin for OpenCode - Access Qwen AI models (Coder, Vision) with your qwen.ai account - Fixes rate limiting (Issue #4)", "module": "index.ts", "type": "module", "scripts": { "build": "bun build ./src/index.ts --outdir ./dist --target node --format esm && bun build ./src/cli.ts --outdir ./dist --target node --format esm", "dev": "bun run --watch src/index.ts", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "test": "bun test", + "test:watch": "bun test --watch", + "test:integration": "bun run tests/integration/debug.ts full", + "test:race": "bun run tests/integration/race-condition.ts", + "test:robust": "bun run tests/robust/runner.ts" }, "keywords": [ "opencode", @@ -15,12 +20,15 @@ "qwen-code", "qwen3-coder", "qwen3-vl-plus", + "qwen3.5-plus", "vision-model", "oauth", "authentication", "ai", "llm", - "opencode-plugins" + "opencode-plugins", + "rate-limit-fix", + "dashscope" ], "author": "Gustavo Dias ", "license": "MIT", @@ -36,7 +44,8 @@ "@opencode-ai/plugin": "^1.1.48", "@types/node": "^22.0.0", "bun-types": "^1.1.0", - "typescript": "^5.6.0" + "typescript": "^5.6.0", + "vitest": "^1.0.0" }, "files": [ "index.ts", diff --git a/src/constants.ts b/src/constants.ts index 375cd9c..4881a3d 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -35,45 +35,63 @@ export const QWEN_API_CONFIG = { export const CALLBACK_PORT = 14561; // Available Qwen models through OAuth (portal.qwen.ai) -// Testados e confirmados funcionando via token OAuth +// Aligned with qwen-code-0.12.0 official client - only coder-model is exposed export const QWEN_MODELS = { - // --- Coding Models --- - 'qwen3-coder-plus': { - id: 'qwen3-coder-plus', - name: 'Qwen3 Coder Plus', - contextWindow: 1048576, // 1M tokens - maxOutput: 65536, // 64K tokens - description: 'Most capable Qwen coding model with 1M context window', - reasoning: false, - cost: { input: 0, output: 0 }, // Free via OAuth - }, - 'qwen3-coder-flash': { - id: 'qwen3-coder-flash', - name: 'Qwen3 Coder Flash', - contextWindow: 1048576, - maxOutput: 65536, - description: 'Faster Qwen coding model for quick responses', - reasoning: false, - cost: { input: 0, output: 0 }, - }, - // --- Alias Models (portal mapeia internamente) --- + // --- Active Model (matches qwen-code-0.12.0) --- 'coder-model': { id: 'coder-model', name: 'Qwen Coder (auto)', contextWindow: 1048576, maxOutput: 65536, - description: 'Auto-routed coding model (maps to qwen3-coder-plus)', - reasoning: false, - cost: { input: 0, output: 0 }, - }, - // --- Vision Model --- - 'vision-model': { - id: 'vision-model', - name: 'Qwen VL Plus (vision)', - contextWindow: 131072, // 128K tokens - maxOutput: 32768, // 32K tokens - description: 'Vision-language model (maps to qwen3-vl-plus), supports image input', + description: 'Auto-routed coding model (Maps to Qwen 3.5 Plus - Hybrid & Vision)', reasoning: false, + capabilities: { vision: true }, cost: { input: 0, output: 0 }, }, + // --- Commented out: Not exposed by qwen-code-0.12.0 official client --- + // 'qwen3.5-plus': { + // id: 'qwen3.5-plus', + // name: 'Qwen 3.5 Plus', + // contextWindow: 1048576, + // maxOutput: 65536, + // description: 'Latest and most capable Qwen 3.5 coding model with 1M context window', + // reasoning: true, + // capabilities: { vision: true }, + // cost: { input: 0, output: 0 }, + // }, + // 'qwen3-coder-plus': { + // id: 'qwen3-coder-plus', + // name: 'Qwen3 Coder Plus', + // contextWindow: 1048576, + // maxOutput: 65536, + // description: 'Most capable Qwen 3.0 coding model with 1M context window', + // reasoning: false, + // cost: { input: 0, output: 0 }, + // }, + // 'qwen3-coder-flash': { + // id: 'qwen3-coder-flash', + // name: 'Qwen3 Coder Flash', + // contextWindow: 1048576, + // maxOutput: 65536, + // description: 'Faster Qwen coding model for quick responses', + // reasoning: false, + // cost: { input: 0, output: 0 }, + // }, + // 'vision-model': { + // id: 'vision-model', + // name: 'Qwen VL Plus (vision)', + // contextWindow: 131072, + // maxOutput: 32768, + // description: 'Vision-language model (maps to qwen3-vl-plus), supports image input', + // reasoning: false, + // cost: { input: 0, output: 0 }, + // }, +} as const; + +// Official Qwen Code CLI Headers for performance and quota recognition +export const QWEN_OFFICIAL_HEADERS = { + 'X-DashScope-CacheControl': 'enable', + 'X-DashScope-AuthType': 'qwen-oauth', + 'X-DashScope-UserAgent': 'QwenCode/0.12.0 (Linux; x64)', + 'User-Agent': 'QwenCode/0.12.0 (Linux; x64)' } as const; diff --git a/src/errors.ts b/src/errors.ts index 4368544..c4fd8dc 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -8,16 +8,34 @@ const REAUTH_HINT = 'Execute "opencode auth login" e selecione "Qwen Code (qwen.ai OAuth)" para autenticar.'; +// ============================================ +// Token Manager Error Types +// ============================================ + +/** + * Error types for token manager operations + * Mirrors official client's TokenError enum + */ +export enum TokenError { + REFRESH_FAILED = 'REFRESH_FAILED', + NO_REFRESH_TOKEN = 'NO_REFRESH_TOKEN', + LOCK_TIMEOUT = 'LOCK_TIMEOUT', + FILE_ACCESS_ERROR = 'FILE_ACCESS_ERROR', + NETWORK_ERROR = 'NETWORK_ERROR', + CREDENTIALS_CLEAR_REQUIRED = 'CREDENTIALS_CLEAR_REQUIRED', +} + // ============================================ // Erro de Autenticação // ============================================ -export type AuthErrorKind = 'token_expired' | 'refresh_failed' | 'auth_required'; +export type AuthErrorKind = 'token_expired' | 'refresh_failed' | 'auth_required' | 'credentials_clear_required'; const AUTH_MESSAGES: Record = { token_expired: `[Qwen] Token expirado. ${REAUTH_HINT}`, refresh_failed: `[Qwen] Falha ao renovar token. ${REAUTH_HINT}`, auth_required: `[Qwen] Autenticacao necessaria. ${REAUTH_HINT}`, + credentials_clear_required: `[Qwen] Credenciais invalidas ou revogadas. ${REAUTH_HINT}`, }; export class QwenAuthError extends Error { @@ -32,31 +50,97 @@ export class QwenAuthError extends Error { } } +/** + * Erro especial que sinaliza necessidade de limpar credenciais em cache. + * Ocorre quando refresh token é revogado (invalid_grant). + */ +export class CredentialsClearRequiredError extends QwenAuthError { + constructor(technicalDetail?: string) { + super('credentials_clear_required', technicalDetail); + this.name = 'CredentialsClearRequiredError'; + } +} + +/** + * Custom error class for token manager operations + * Provides better error classification for handling + */ +export class TokenManagerError extends Error { + public readonly type: TokenError; + public readonly technicalDetail?: string; + + constructor(type: TokenError, message: string, technicalDetail?: string) { + super(message); + this.name = 'TokenManagerError'; + this.type = type; + this.technicalDetail = technicalDetail; + } +} + // ============================================ // Erro de API // ============================================ -function classifyApiStatus(statusCode: number): string { +/** + * Specific error types for API errors + */ +export type ApiErrorKind = + | 'rate_limit' + | 'unauthorized' + | 'forbidden' + | 'server_error' + | 'network_error' + | 'unknown'; + +function classifyApiStatus(statusCode: number): { message: string; kind: ApiErrorKind } { if (statusCode === 401 || statusCode === 403) { - return `[Qwen] Token invalido ou expirado. ${REAUTH_HINT}`; + return { + message: `[Qwen] Token invalido ou expirado. ${REAUTH_HINT}`, + kind: 'unauthorized' + }; } if (statusCode === 429) { - return '[Qwen] Limite de requisicoes atingido. Aguarde alguns minutos antes de tentar novamente.'; + return { + message: '[Qwen] Limite de requisicoes atingido. Aguarde alguns minutos antes de tentar novamente.', + kind: 'rate_limit' + }; } if (statusCode >= 500) { - return `[Qwen] Servidor Qwen indisponivel (erro ${statusCode}). Tente novamente em alguns minutos.`; + return { + message: `[Qwen] Servidor Qwen indisponivel (erro ${statusCode}). Tente novamente em alguns minutos.`, + kind: 'server_error' + }; } - return `[Qwen] Erro na API Qwen (${statusCode}). Verifique sua conexao e tente novamente.`; + return { + message: `[Qwen] Erro na API Qwen (${statusCode}). Verifique sua conexao e tente novamente.`, + kind: 'unknown' + }; } export class QwenApiError extends Error { public readonly statusCode: number; + public readonly kind: ApiErrorKind; public readonly technicalDetail?: string; constructor(statusCode: number, technicalDetail?: string) { - super(classifyApiStatus(statusCode)); + const classification = classifyApiStatus(statusCode); + super(classification.message); this.name = 'QwenApiError'; this.statusCode = statusCode; + this.kind = classification.kind; + this.technicalDetail = technicalDetail; + } +} + +/** + * Error for network-related issues (fetch failures, timeouts, etc.) + */ +export class QwenNetworkError extends Error { + public readonly technicalDetail?: string; + + constructor(message: string, technicalDetail?: string) { + super(`[Qwen] Erro de rede: ${message}`); + this.name = 'QwenNetworkError'; this.technicalDetail = technicalDetail; } } @@ -73,3 +157,59 @@ export function logTechnicalDetail(detail: string): void { console.debug('[Qwen Debug]', detail); } } + +/** + * Classify error type for better error handling + * Returns specific error kind for programmatic handling + */ +export function classifyError(error: unknown): { + kind: 'auth' | 'api' | 'network' | 'timeout' | 'unknown'; + isRetryable: boolean; + shouldClearCache: boolean; +} { + // Check for our custom error types + if (error instanceof CredentialsClearRequiredError) { + return { kind: 'auth', isRetryable: false, shouldClearCache: true }; + } + + if (error instanceof QwenAuthError) { + return { + kind: 'auth', + isRetryable: error.kind === 'refresh_failed', + shouldClearCache: error.kind === 'credentials_clear_required' + }; + } + + if (error instanceof QwenApiError) { + return { + kind: 'api', + isRetryable: error.kind === 'rate_limit' || error.kind === 'server_error', + shouldClearCache: false + }; + } + + if (error instanceof QwenNetworkError) { + return { kind: 'network', isRetryable: true, shouldClearCache: false }; + } + + // Check for timeout errors + if (error instanceof Error && error.name === 'AbortError') { + return { kind: 'timeout', isRetryable: true, shouldClearCache: false }; + } + + // Check for standard Error with status + if (error instanceof Error) { + const errorMessage = error.message.toLowerCase(); + + // Network-related errors + if (errorMessage.includes('fetch') || + errorMessage.includes('network') || + errorMessage.includes('timeout') || + errorMessage.includes('abort')) { + return { kind: 'network', isRetryable: true, shouldClearCache: false }; + } + } + + // Default: unknown error, not retryable + return { kind: 'unknown', isRetryable: false, shouldClearCache: false }; +} diff --git a/src/index.ts b/src/index.ts index f3bb2d4..c9e72d0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,19 +9,30 @@ */ import { spawn } from 'node:child_process'; +import { randomUUID } from 'node:crypto'; -import { QWEN_PROVIDER_ID, QWEN_API_CONFIG, QWEN_MODELS } from './constants.js'; +import { QWEN_PROVIDER_ID, QWEN_API_CONFIG, QWEN_MODELS, QWEN_OFFICIAL_HEADERS } from './constants.js'; import type { QwenCredentials } from './types.js'; -import { saveCredentials } from './plugin/auth.js'; +import { resolveBaseUrl } from './plugin/auth.js'; import { generatePKCE, requestDeviceAuthorization, pollDeviceToken, tokenResponseToCredentials, - refreshAccessToken, SlowDownError, } from './qwen/oauth.js'; -import { logTechnicalDetail } from './errors.js'; +import { retryWithBackoff, getErrorStatus } from './utils/retry.js'; +import { RequestQueue } from './plugin/request-queue.js'; +import { tokenManager } from './plugin/token-manager.js'; +import { createDebugLogger } from './utils/debug-logger.js'; + +const debugLogger = createDebugLogger('PLUGIN'); + +// Global session ID for the plugin lifetime +const PLUGIN_SESSION_ID = randomUUID(); + +// Singleton request queue for throttling (shared across all requests) +const requestQueue = new RequestQueue(); // ============================================ // Helpers @@ -35,36 +46,38 @@ function openBrowser(url: string): void { const child = spawn(command, args, { stdio: 'ignore', detached: true }); child.unref?.(); } catch { - // Ignore errors + // Fallback: show URL in stderr + console.error('\n[Qwen Auth] Unable to open browser automatically.'); + console.error('Please open this URL manually to authenticate:\n'); + console.error(` ${url}\n`); } } -/** Obtem um access token valido (com refresh se necessario) */ -async function getValidAccessToken( - getAuth: () => Promise<{ type: string; access?: string; refresh?: string; expires?: number }>, -): Promise { - const auth = await getAuth(); +/** + * Check if error is authentication-related (401, 403, token expired) + * Mirrors official client's isAuthError logic + */ +function isAuthError(error: unknown): boolean { + if (!error) return false; - if (!auth || auth.type !== 'oauth') { - return null; - } + const errorMessage = error instanceof Error + ? error.message.toLowerCase() + : String(error).toLowerCase(); - let accessToken = auth.access; - - // Refresh se expirado (com margem de 60s) - if (accessToken && auth.expires && Date.now() > auth.expires - 60_000 && auth.refresh) { - try { - const refreshed = await refreshAccessToken(auth.refresh); - accessToken = refreshed.accessToken; - saveCredentials(refreshed); - } catch (e) { - const detail = e instanceof Error ? e.message : String(e); - logTechnicalDetail(`Token refresh falhou: ${detail}`); - accessToken = undefined; - } - } + const status = getErrorStatus(error); - return accessToken ?? null; + return ( + status === 401 || + status === 403 || + errorMessage.includes('unauthorized') || + errorMessage.includes('forbidden') || + errorMessage.includes('invalid access token') || + errorMessage.includes('invalid api key') || + errorMessage.includes('token expired') || + errorMessage.includes('authentication') || + errorMessage.includes('access denied') || + (errorMessage.includes('token') && errorMessage.includes('expired')) + ); } // ============================================ @@ -77,7 +90,7 @@ export const QwenAuthPlugin = async (_input: unknown) => { provider: QWEN_PROVIDER_ID, loader: async ( - getAuth: () => Promise<{ type: string; access?: string; refresh?: string; expires?: number }>, + getAuth: any, provider: { models?: Record }, ) => { // Zerar custo dos modelos (gratuito via OAuth) @@ -87,12 +100,128 @@ export const QwenAuthPlugin = async (_input: unknown) => { } } - const accessToken = await getValidAccessToken(getAuth); - if (!accessToken) return null; + // Get latest valid credentials + const credentials = await tokenManager.getValidCredentials(); + if (!credentials?.accessToken) return null; + + const baseURL = resolveBaseUrl(credentials.resourceUrl); return { - apiKey: accessToken, - baseURL: QWEN_API_CONFIG.baseUrl, + apiKey: credentials.accessToken, + baseURL: baseURL, + headers: { + ...QWEN_OFFICIAL_HEADERS, + }, + // Custom fetch with throttling, retry and 401 recovery + fetch: async (url: string, options: any = {}) => { + return requestQueue.enqueue(async () => { + let authRetryCount = 0; + + const executeRequest = async (): Promise => { + // Get latest token (possibly refreshed by concurrent request) + const currentCreds = await tokenManager.getValidCredentials(); + const token = currentCreds?.accessToken; + + if (!token) throw new Error('No access token available'); + + // Prepare merged headers + const mergedHeaders: Record = { + ...QWEN_OFFICIAL_HEADERS, + }; + + // Merge provided headers (handles both plain object and Headers instance) + if (options.headers) { + if (typeof (options.headers as any).entries === 'function') { + for (const [k, v] of (options.headers as any).entries()) { + const kl = k.toLowerCase(); + if (!kl.startsWith('x-dashscope') && kl !== 'user-agent' && kl !== 'authorization') { + mergedHeaders[k] = v; + } + } + } else { + for (const [k, v] of Object.entries(options.headers)) { + const kl = k.toLowerCase(); + if (!kl.startsWith('x-dashscope') && kl !== 'user-agent' && kl !== 'authorization') { + mergedHeaders[k] = v as string; + } + } + } + } + + // Force our Authorization token + mergedHeaders['Authorization'] = `Bearer ${token}`; + + // Optional: X-Metadata might be expected by some endpoints for free quota tracking + // but let's try without it first to match official client closer + // mergedHeaders['X-Metadata'] = JSON.stringify({ ... }); + + // Perform the request + const response = await fetch(url, { + ...options, + headers: mergedHeaders + }); + + // Reactive recovery for 401 (token expired mid-session) + if (response.status === 401 && authRetryCount < 1) { + authRetryCount++; + debugLogger.warn('401 Unauthorized detected. Forcing token refresh...', { + url: url.substring(0, 100) + (url.length > 100 ? '...' : ''), + attempt: authRetryCount, + maxRetries: 1 + }); + + // Force refresh from API + const refreshStart = Date.now(); + const refreshed = await tokenManager.getValidCredentials(true); + const refreshElapsed = Date.now() - refreshStart; + + if (refreshed?.accessToken) { + debugLogger.info('Token refreshed successfully, retrying request...', { + refreshElapsed, + newTokenExpiry: refreshed.expiryDate ? new Date(refreshed.expiryDate).toISOString() : 'N/A' + }); + return executeRequest(); // Recursive retry with new token + } else { + debugLogger.error('Failed to refresh token after 401', { + refreshElapsed, + hasRefreshToken: !!refreshed?.accessToken + }); + } + } + + // Error handling for retryWithBackoff + if (!response.ok) { + const errorText = await response.text().catch(() => ''); + const error: any = new Error(`HTTP ${response.status}: ${errorText}`); + error.status = response.status; + + // Add context for debugging + debugLogger.error('Request failed', { + status: response.status, + statusText: response.statusText, + url: url.substring(0, 100) + (url.length > 100 ? '...' : ''), + method: options?.method || 'GET', + errorText: errorText.substring(0, 200) + (errorText.length > 200 ? '...' : '') + }); + + throw error; + } + + return response; + }; + + // Use official retry logic for 429/5xx errors + return retryWithBackoff(() => executeRequest(), { + authType: 'qwen-oauth', + maxAttempts: 7, + shouldRetryOnError: (error: any) => { + const status = error.status || getErrorStatus(error); + // Retry on 401 (handled by executeRequest recursion too), 429, and 5xx + return status === 401 || status === 429 || (status !== undefined && status >= 500 && status < 600); + } + }); + }); + } }; }, @@ -126,7 +255,7 @@ export const QwenAuthPlugin = async (_input: unknown) => { if (tokenResponse) { const credentials = tokenResponseToCredentials(tokenResponse); - saveCredentials(credentials); + tokenManager.setCredentials(credentials); return { type: 'success' as const, @@ -167,19 +296,28 @@ export const QwenAuthPlugin = async (_input: unknown) => { providers[QWEN_PROVIDER_ID] = { npm: '@ai-sdk/openai-compatible', name: 'Qwen Code', - options: { baseURL: QWEN_API_CONFIG.baseUrl }, + options: { + baseURL: QWEN_API_CONFIG.baseUrl, + headers: QWEN_OFFICIAL_HEADERS + }, models: Object.fromEntries( - Object.entries(QWEN_MODELS).map(([id, m]) => [ - id, - { - id: m.id, - name: m.name, - reasoning: m.reasoning, - limit: { context: m.contextWindow, output: m.maxOutput }, - cost: m.cost, - modalities: { input: ['text'], output: ['text'] }, - }, - ]) + Object.entries(QWEN_MODELS).map(([id, m]) => { + const hasVision = 'capabilities' in m && m.capabilities?.vision; + return [ + id, + { + id: m.id, + name: m.name, + reasoning: m.reasoning, + limit: { context: m.contextWindow, output: m.maxOutput }, + cost: m.cost, + modalities: { + input: hasVision ? ['text', 'image'] : ['text'], + output: ['text'] + }, + }, + ]; + }) ), }; diff --git a/src/plugin/auth.ts b/src/plugin/auth.ts index d8010ed..ff5cadd 100644 --- a/src/plugin/auth.ts +++ b/src/plugin/auth.ts @@ -5,25 +5,142 @@ */ import { homedir } from 'node:os'; -import { join } from 'node:path'; -import { existsSync, writeFileSync, mkdirSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { existsSync, writeFileSync, mkdirSync, readFileSync, renameSync, unlinkSync } from 'node:fs'; +import { randomUUID } from 'node:crypto'; import type { QwenCredentials } from '../types.js'; +import { QWEN_API_CONFIG } from '../constants.js'; /** * Get the path to the credentials file + * Supports test override via QWEN_TEST_CREDS_PATH environment variable */ export function getCredentialsPath(): string { + // Check for test override (prevents tests from modifying user credentials) + if (process.env.QWEN_TEST_CREDS_PATH) { + return process.env.QWEN_TEST_CREDS_PATH; + } const homeDir = homedir(); return join(homeDir, '.qwen', 'oauth_creds.json'); } +/** + * Validate credentials structure + * Matches official client's validateCredentials() function + */ +function validateCredentials(data: unknown): QwenCredentials { + if (!data || typeof data !== 'object') { + throw new Error('Invalid credentials format: expected object'); + } + + const creds = data as Partial; + const requiredFields = ['accessToken', 'tokenType'] as const; + + // Validate required string fields + for (const field of requiredFields) { + if (!creds[field] || typeof creds[field] !== 'string') { + throw new Error(`Invalid credentials: missing or invalid ${field}`); + } + } + + // Validate refreshToken (optional but should be string if present) + if (creds.refreshToken !== undefined && typeof creds.refreshToken !== 'string') { + throw new Error('Invalid credentials: refreshToken must be a string'); + } + + // Validate expiryDate (required for token management) + if (!creds.expiryDate || typeof creds.expiryDate !== 'number') { + throw new Error('Invalid credentials: missing or invalid expiryDate'); + } + + // Validate resourceUrl (optional but should be string if present) + if (creds.resourceUrl !== undefined && typeof creds.resourceUrl !== 'string') { + throw new Error('Invalid credentials: resourceUrl must be a string'); + } + + // Validate scope (optional but should be string if present) + if (creds.scope !== undefined && typeof creds.scope !== 'string') { + throw new Error('Invalid credentials: scope must be a string'); + } + + return { + accessToken: creds.accessToken!, + tokenType: creds.tokenType!, + refreshToken: creds.refreshToken, + resourceUrl: creds.resourceUrl, + expiryDate: creds.expiryDate!, + scope: creds.scope, + }; +} + +/** + * Load credentials from file and map to camelCase QwenCredentials + * Includes comprehensive validation matching official client + */ +export function loadCredentials(): QwenCredentials | null { + const credPath = getCredentialsPath(); + if (!existsSync(credPath)) { + return null; + } + + try { + const content = readFileSync(credPath, 'utf8'); + const data = JSON.parse(content); + + // Convert snake_case (file format) to camelCase (internal format) + // This matches qwen-code format for compatibility + const converted: QwenCredentials = { + accessToken: data.access_token, + tokenType: data.token_type || 'Bearer', + refreshToken: data.refresh_token, + resourceUrl: data.resource_url, + expiryDate: data.expiry_date, + scope: data.scope, + }; + + // Validate converted credentials structure + const validated = validateCredentials(converted); + + return validated; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error('[QwenAuth] Failed to load credentials:', message); + + // Corrupted file - suggest re-authentication + console.error('[QwenAuth] Credentials file may be corrupted. Please re-authenticate.'); + return null; + } +} + +/** + * Resolve the API base URL based on the token region + */ +export function resolveBaseUrl(resourceUrl?: string): string { + if (!resourceUrl) return QWEN_API_CONFIG.portalBaseUrl; + + if (resourceUrl.includes('portal.qwen.ai')) { + return QWEN_API_CONFIG.portalBaseUrl; + } + + if (resourceUrl.includes('dashscope')) { + // Both dashscope and dashscope-intl use similar URL patterns + if (resourceUrl.includes('dashscope-intl')) { + return 'https://dashscope-intl.aliyuncs.com/compatible-mode/v1'; + } + return QWEN_API_CONFIG.defaultBaseUrl; + } + + return QWEN_API_CONFIG.portalBaseUrl; +} + /** * Save credentials to file in qwen-code compatible format + * Uses atomic write (temp file + rename) to prevent corruption */ export function saveCredentials(credentials: QwenCredentials): void { const credPath = getCredentialsPath(); - const dir = join(homedir(), '.qwen'); + const dir = dirname(credPath); if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }); @@ -39,5 +156,19 @@ export function saveCredentials(credentials: QwenCredentials): void { scope: credentials.scope, }; - writeFileSync(credPath, JSON.stringify(data, null, 2)); + // ATOMIC WRITE: temp file + rename to prevent corruption + const tempPath = `${credPath}.tmp.${randomUUID()}`; + + try { + writeFileSync(tempPath, JSON.stringify(data, null, 2)); + renameSync(tempPath, credPath); // Atomic on POSIX systems + } catch (error) { + // Cleanup temp file if rename fails + try { + if (existsSync(tempPath)) { + unlinkSync(tempPath); + } + } catch {} + throw error; + } } diff --git a/src/plugin/request-queue.ts b/src/plugin/request-queue.ts new file mode 100644 index 0000000..25a21fa --- /dev/null +++ b/src/plugin/request-queue.ts @@ -0,0 +1,46 @@ +/** + * Request Queue with throttling + * Prevents hitting rate limits by controlling request frequency + * Inspired by qwen-code-0.12.0 throttling patterns + */ + +import { createDebugLogger } from '../utils/debug-logger.js'; + +const debugLogger = createDebugLogger('REQUEST_QUEUE'); + +export class RequestQueue { + private lastRequestTime = 0; + private readonly MIN_INTERVAL = 1000; // 1 second + private readonly JITTER_MIN = 500; // 0.5s + private readonly JITTER_MAX = 1500; // 1.5s + + /** + * Get random jitter between JITTER_MIN and JITTER_MAX + */ + private getJitter(): number { + return Math.random() * (this.JITTER_MAX - this.JITTER_MIN) + this.JITTER_MIN; + } + + /** + * Execute a function with throttling + * Ensures minimum interval between requests + random jitter + */ + async enqueue(fn: () => Promise): Promise { + const elapsed = Date.now() - this.lastRequestTime; + const waitTime = Math.max(0, this.MIN_INTERVAL - elapsed); + + if (waitTime > 0) { + const jitter = this.getJitter(); + const totalWait = waitTime + jitter; + + debugLogger.info( + `Throttling: waiting ${totalWait.toFixed(0)}ms (${waitTime.toFixed(0)}ms + ${jitter.toFixed(0)}ms jitter)` + ); + + await new Promise(resolve => setTimeout(resolve, totalWait)); + } + + this.lastRequestTime = Date.now(); + return fn(); + } +} diff --git a/src/plugin/token-manager.ts b/src/plugin/token-manager.ts new file mode 100644 index 0000000..c044a68 --- /dev/null +++ b/src/plugin/token-manager.ts @@ -0,0 +1,397 @@ +/** + * Robust Token Manager with File Locking + * + * Production-ready token management with multi-process safety + * Features: + * - In-memory caching to avoid repeated file reads + * - Preventive refresh (30s buffer before expiration) + * - Reactive recovery (on 401 errors) + * - Promise tracking to avoid concurrent refreshes within same process + * - File locking to prevent concurrent refreshes across processes + * - Comprehensive debug logging (enabled via OPENCODE_QWEN_DEBUG=1) + */ + +import { loadCredentials, saveCredentials, getCredentialsPath } from './auth.js'; +import { refreshAccessToken } from '../qwen/oauth.js'; +import type { QwenCredentials } from '../types.js'; +import { createDebugLogger } from '../utils/debug-logger.js'; +import { FileLock } from '../utils/file-lock.js'; +import { watch } from 'node:fs'; +import { CredentialsClearRequiredError } from '../errors.js'; + +const debugLogger = createDebugLogger('TOKEN_MANAGER'); +const TOKEN_REFRESH_BUFFER_MS = 30 * 1000; // 30 seconds +const CACHE_CHECK_INTERVAL_MS = 5000; // 5 seconds (matches official client) + +interface CacheState { + credentials: QwenCredentials | null; + lastCheck: number; +} + +class TokenManager { + private memoryCache: CacheState = { + credentials: null, + lastCheck: 0, + }; + private refreshPromise: Promise | null = null; + private lastFileCheck = 0; + private fileWatcherInitialized = false; + + constructor() { + this.initializeFileWatcher(); + } + + /** + * Initialize file watcher to detect external credential changes + * Automatically invalidates cache when credentials file is modified + */ + private initializeFileWatcher(): void { + if (this.fileWatcherInitialized) return; + + const credPath = getCredentialsPath(); + + try { + watch(credPath, (eventType) => { + if (eventType === 'change') { + // File was modified externally (e.g., opencode auth login) + // Invalidate cache to force reload on next request + this.invalidateCache(); + debugLogger.info('Credentials file changed, cache invalidated'); + } + }); + + this.fileWatcherInitialized = true; + debugLogger.debug('File watcher initialized', { path: credPath }); + } catch (error) { + debugLogger.error('Failed to initialize file watcher', error); + // File watcher is optional, continue without it + } + } + + /** + * Invalidate in-memory cache + * Forces reload from file on next getValidCredentials() call + */ + private invalidateCache(): void { + this.memoryCache = { + credentials: null, + lastCheck: 0, + }; + this.lastFileCheck = 0; + } + + /** + * Get valid credentials, refreshing if necessary + * + * @param forceRefresh - If true, refresh even if current token is valid + * @returns Valid credentials or null if unavailable + */ + async getValidCredentials(forceRefresh = false): Promise { + const startTime = Date.now(); + debugLogger.info('getValidCredentials called', { forceRefresh }); + + try { + // 1. Check in-memory cache first (unless force refresh) + if (!forceRefresh && this.memoryCache.credentials && this.isTokenValid(this.memoryCache.credentials)) { + debugLogger.info('Returning from memory cache', { + age: Date.now() - startTime, + validUntil: new Date(this.memoryCache.credentials.expiryDate!).toISOString() + }); + return this.memoryCache.credentials; + } + + // 2. If concurrent refresh is already happening, wait for it + if (this.refreshPromise) { + debugLogger.info('Waiting for ongoing refresh...'); + const result = await this.refreshPromise; + debugLogger.info('Wait completed', { success: !!result, age: Date.now() - startTime }); + return result; + } + + // 3. Need to perform refresh or reload from file + this.refreshPromise = (async () => { + const refreshStart = Date.now(); + const now = Date.now(); + + // Throttle file checks to avoid excessive I/O (matches official client) + const shouldCheckFile = forceRefresh || (now - this.lastFileCheck) >= CACHE_CHECK_INTERVAL_MS; + + let fromFile: QwenCredentials | null = null; + + if (shouldCheckFile) { + // Always check file first (may have been updated by another process) + fromFile = loadCredentials(); + this.lastFileCheck = now; + + debugLogger.info('File check (throttled)', { + hasFile: !!fromFile, + fileValid: fromFile ? this.isTokenValid(fromFile) : 'N/A', + forceRefresh, + timeSinceLastCheck: now - this.lastFileCheck, + throttleInterval: CACHE_CHECK_INTERVAL_MS + }); + } else { + debugLogger.debug('Skipping file check (throttled)', { + timeSinceLastCheck: now - this.lastFileCheck, + throttleInterval: CACHE_CHECK_INTERVAL_MS + }); + + // Use memory cache if available + fromFile = this.memoryCache.credentials; + } + + // If not forcing refresh and file has valid credentials, use them + if (!forceRefresh && fromFile && this.isTokenValid(fromFile)) { + debugLogger.info('Using valid credentials from file'); + this.updateCacheState(fromFile, now); + return fromFile; + } + + // Need to perform actual refresh via API (with file locking for multi-process safety) + const result = await this.performTokenRefreshWithLock(fromFile); + debugLogger.info('Refresh operation completed', { + success: !!result, + age: Date.now() - refreshStart + }); + return result; + })(); + + try { + const result = await this.refreshPromise; + return result; + } finally { + this.refreshPromise = null; + } + } catch (error) { + debugLogger.error('Failed to get valid credentials', error); + return null; + } + } + + /** + * Update cache state atomically + * Ensures all cache fields are updated together to prevent inconsistent states + * Matches official client's updateCacheState() pattern + */ + private updateCacheState(credentials: QwenCredentials | null, lastCheck?: number): void { + this.memoryCache = { + credentials, + lastCheck: lastCheck ?? Date.now(), + }; + + debugLogger.debug('Cache state updated', { + hasCredentials: !!credentials, + lastCheck, + }); + } + + /** + * Check if token is valid (not expired with buffer) + */ + private isTokenValid(credentials: QwenCredentials): boolean { + if (!credentials.expiryDate || !credentials.accessToken) { + return false; + } + const now = Date.now(); + const expiryWithBuffer = credentials.expiryDate - TOKEN_REFRESH_BUFFER_MS; + const valid = now < expiryWithBuffer; + + debugLogger.debug('Token validity check', { + now, + expiryDate: credentials.expiryDate, + buffer: TOKEN_REFRESH_BUFFER_MS, + expiryWithBuffer, + valid, + timeUntilExpiry: expiryWithBuffer - now + }); + + return valid; + } + + /** + * Perform the actual token refresh + */ + private async performTokenRefresh(current: QwenCredentials | null): Promise { + debugLogger.info('performTokenRefresh called', { + hasCurrent: !!current, + hasRefreshToken: !!current?.refreshToken + }); + + if (!current?.refreshToken) { + debugLogger.warn('Cannot refresh: No refresh token available'); + return null; + } + + try { + debugLogger.info('Calling refreshAccessToken API...'); + const startTime = Date.now(); + const refreshed = await refreshAccessToken(current.refreshToken); + const elapsed = Date.now() - startTime; + + debugLogger.info('Token refresh API response', { + elapsed, + hasAccessToken: !!refreshed.accessToken, + hasRefreshToken: !!refreshed.refreshToken, + expiryDate: refreshed.expiryDate ? new Date(refreshed.expiryDate).toISOString() : 'N/A' + }); + + // Save refreshed credentials + saveCredentials(refreshed); + debugLogger.info('Credentials saved to file'); + + // Update cache atomically + this.updateCacheState(refreshed); + debugLogger.info('Token refreshed successfully'); + + return refreshed; + } catch (error) { + const elapsed = Date.now() - startTime; + + // Handle credentials clear required error (invalid_grant) + if (error instanceof CredentialsClearRequiredError) { + debugLogger.warn('Credentials clear required - clearing memory cache'); + this.clearCache(); + throw error; + } + + debugLogger.error('Token refresh failed', { + error: error instanceof Error ? error.message : String(error), + elapsed, + hasRefreshToken: !!current?.refreshToken, + stack: error instanceof Error ? error.stack?.split('\n').slice(0, 3).join('\n') : undefined + }); + throw error; // Re-throw so caller knows it failed + } + } + + /** + * Perform token refresh with file locking (multi-process safe) + */ + private async performTokenRefreshWithLock(current: QwenCredentials | null): Promise { + const credPath = getCredentialsPath(); + const lock = new FileLock(credPath); + + debugLogger.info('Attempting to acquire file lock', { credPath }); + const lockStart = Date.now(); + const lockAcquired = await lock.acquire(5000, 100); + const lockElapsed = Date.now() - lockStart; + + debugLogger.info('Lock acquisition result', { + acquired: lockAcquired, + elapsed: lockElapsed + }); + + if (!lockAcquired) { + // Another process is doing refresh, wait and reload from file + debugLogger.info('Another process is refreshing, waiting...', { + lockTimeout: 5000, + waitTime: 600 + }); + await this.delay(600); // Wait for other process to finish + + // Reload credentials from file (should have new token now) + const reloaded = loadCredentials(); + debugLogger.info('Reloaded credentials after wait', { + hasCredentials: !!reloaded, + valid: reloaded ? this.isTokenValid(reloaded) : 'N/A', + totalWaitTime: Date.now() - lockStart + }); + + if (reloaded && this.isTokenValid(reloaded)) { + this.updateCacheState(reloaded); + debugLogger.info('Loaded refreshed credentials from file (multi-process)'); + return reloaded; + } + + // Still invalid, try again without lock (edge case: other process failed) + debugLogger.warn('Fallback: attempting refresh without lock', { + reason: 'Lock acquisition failed, assuming other process crashed' + }); + return await this.performTokenRefresh(current); + } + + try { + // Critical section: only one process executes here + + // Double-check: another process may have refreshed while we were waiting for lock + const fromFile = loadCredentials(); + const doubleCheckElapsed = Date.now() - lockStart; + debugLogger.info('Double-check after lock acquisition', { + hasFile: !!fromFile, + fileValid: fromFile ? this.isTokenValid(fromFile) : 'N/A', + elapsed: doubleCheckElapsed + }); + + if (fromFile && this.isTokenValid(fromFile)) { + debugLogger.info('Credentials already refreshed by another process', { + timeSinceLockStart: doubleCheckElapsed, + usingFileCredentials: true + }); + this.updateCacheState(fromFile); + return fromFile; + } + + // Perform the actual refresh + debugLogger.info('Performing refresh in critical section', { + hasRefreshToken: !!fromFile?.refreshToken, + elapsed: doubleCheckElapsed + }); + return await this.performTokenRefresh(fromFile); + } finally { + // Always release lock, even if error occurs + lock.release(); + debugLogger.info('File lock released', { + totalOperationTime: Date.now() - lockStart + }); + } + } + + /** + * Get current state for debugging + */ + getState(): { + hasMemoryCache: boolean; + memoryCacheValid: boolean; + hasRefreshPromise: boolean; + fileExists: boolean; + fileValid: boolean; + } { + const fromFile = loadCredentials(); + return { + hasMemoryCache: !!this.memoryCache.credentials, + memoryCacheValid: this.memoryCache.credentials ? this.isTokenValid(this.memoryCache.credentials) : false, + hasRefreshPromise: !!this.refreshPromise, + fileExists: !!fromFile, + fileValid: fromFile ? this.isTokenValid(fromFile) : false + }; + } + + private delay(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); + } + + /** + * Clear cached credentials + */ + clearCache(): void { + debugLogger.info('Cache cleared'); + this.updateCacheState(null); + } + + /** + * Manually set credentials + */ + setCredentials(credentials: QwenCredentials): void { + debugLogger.info('Setting credentials manually', { + hasAccessToken: !!credentials.accessToken, + hasRefreshToken: !!credentials.refreshToken, + expiryDate: credentials.expiryDate ? new Date(credentials.expiryDate).toISOString() : 'N/A' + }); + this.updateCacheState(credentials); + saveCredentials(credentials); + } +} + +export { TokenManager }; +// Singleton instance +export const tokenManager = new TokenManager(); diff --git a/src/qwen/oauth.ts b/src/qwen/oauth.ts index 2d741e4..65f0323 100644 --- a/src/qwen/oauth.ts +++ b/src/qwen/oauth.ts @@ -9,7 +9,8 @@ import { randomBytes, createHash, randomUUID } from 'node:crypto'; import { QWEN_OAUTH_CONFIG } from '../constants.js'; import type { QwenCredentials } from '../types.js'; -import { QwenAuthError, logTechnicalDetail } from '../errors.js'; +import { QwenAuthError, CredentialsClearRequiredError, logTechnicalDetail } from '../errors.js'; +import { retryWithBackoff, getErrorStatus } from '../utils/retry.js'; /** * Erro lançado quando o servidor pede slow_down (RFC 8628) @@ -60,7 +61,7 @@ export function generatePKCE(): { verifier: string; challenge: string } { /** * Convert object to URL-encoded form data */ -function objectToUrlEncoded(data: Record): string { +export function objectToUrlEncoded(data: Record): string { return Object.keys(data) .map((key) => `${encodeURIComponent(key)}=${encodeURIComponent(data[key])}`) .join('&'); @@ -80,15 +81,20 @@ export async function requestDeviceAuthorization( code_challenge_method: 'S256', }; - const response = await fetch(QWEN_OAUTH_CONFIG.deviceCodeEndpoint, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - Accept: 'application/json', - 'x-request-id': randomUUID(), - }, - body: objectToUrlEncoded(bodyData), - }); + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 30000); // 30s timeout + + try { + const response = await fetch(QWEN_OAUTH_CONFIG.deviceCodeEndpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Accept: 'application/json', + 'x-request-id': randomUUID(), + }, + signal: controller.signal, + body: objectToUrlEncoded(bodyData), + }); if (!response.ok) { const errorData = await response.text(); @@ -103,6 +109,9 @@ export async function requestDeviceAuthorization( } return result; + } finally { + clearTimeout(timeoutId); + } } /** @@ -120,46 +129,58 @@ export async function pollDeviceToken( code_verifier: codeVerifier, }; - const response = await fetch(QWEN_OAUTH_CONFIG.tokenEndpoint, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - Accept: 'application/json', - }, - body: objectToUrlEncoded(bodyData), - }); - - if (!response.ok) { - const responseText = await response.text(); - - // Try to parse error response - try { - const errorData = JSON.parse(responseText) as { error?: string; error_description?: string }; - - // RFC 8628: authorization_pending means user hasn't authorized yet - if (response.status === 400 && errorData.error === 'authorization_pending') { - return null; // Still pending - } - - // RFC 8628: slow_down means we should increase poll interval - if (response.status === 429 && errorData.error === 'slow_down') { - throw new SlowDownError(); - } - - throw new Error( - `Token poll failed: ${errorData.error || 'Unknown error'} - ${errorData.error_description || responseText}` - ); - } catch (parseError) { - if (parseError instanceof SyntaxError) { - throw new Error( - `Token poll failed: ${response.status} ${response.statusText}. Response: ${responseText}` + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 30000); // 30s timeout + + try { + const response = await fetch(QWEN_OAUTH_CONFIG.tokenEndpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Accept: 'application/json', + }, + signal: controller.signal, + body: objectToUrlEncoded(bodyData), + }); + + if (!response.ok) { + const responseText = await response.text(); + + // Try to parse error response + try { + const errorData = JSON.parse(responseText) as { error?: string; error_description?: string }; + + // RFC 8628: authorization_pending means user hasn't authorized yet + if (response.status === 400 && errorData.error === 'authorization_pending') { + return null; // Still pending + } + + // RFC 8628: slow_down means we should increase poll interval + if (response.status === 429 && errorData.error === 'slow_down') { + throw new SlowDownError(); + } + + const error = new Error( + `Token poll failed: ${errorData.error || 'Unknown error'} - ${errorData.error_description || responseText}` ); + (error as Error & { status?: number }).status = response.status; + throw error; + } catch (parseError) { + if (parseError instanceof SyntaxError) { + const error = new Error( + `Token poll failed: ${response.status} ${response.statusText}. Response: ${responseText}` + ); + (error as Error & { status?: number }).status = response.status; + throw error; + } + throw parseError; } - throw parseError; } - } - return (await response.json()) as TokenResponse; + return (await response.json()) as TokenResponse; + } finally { + clearTimeout(timeoutId); + } } /** @@ -178,6 +199,7 @@ export function tokenResponseToCredentials(tokenResponse: TokenResponse): QwenCr /** * Refresh the access token using refresh_token grant + * Includes automatic retry for transient errors (429, 5xx) */ export async function refreshAccessToken(refreshToken: string): Promise { const bodyData = { @@ -186,31 +208,69 @@ export async function refreshAccessToken(refreshToken: string): Promise { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 30000); // 30s timeout + + try { + const response = await fetch(QWEN_OAUTH_CONFIG.tokenEndpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Accept: 'application/json', + }, + signal: controller.signal, + body: objectToUrlEncoded(bodyData), + }); + + if (!response.ok) { + const errorText = await response.text(); + logTechnicalDetail(`Token refresh HTTP ${response.status}: ${errorText}`); + + // Don't retry on invalid_grant (refresh token expired/revoked) + // Signal that credentials need to be cleared + if (errorText.includes('invalid_grant')) { + throw new CredentialsClearRequiredError('Refresh token expired or revoked'); + } + + throw new QwenAuthError('refresh_failed', `HTTP ${response.status}: ${errorText}`); + } - if (!response.ok) { - const errorText = await response.text(); - logTechnicalDetail(`Token refresh HTTP ${response.status}: ${errorText}`); - throw new QwenAuthError('refresh_failed', `HTTP ${response.status}: ${errorText}`); - } + const data = await response.json() as TokenResponse; - const data = await response.json() as TokenResponse; + // Validate required fields + if (!data.access_token) { + throw new QwenAuthError('refresh_failed', 'No access token in refresh response'); + } - return { - accessToken: data.access_token, - tokenType: data.token_type || 'Bearer', - refreshToken: data.refresh_token || refreshToken, - resourceUrl: data.resource_url, - expiryDate: Date.now() + data.expires_in * 1000, - scope: data.scope, - }; + return { + accessToken: data.access_token, + tokenType: data.token_type || 'Bearer', + refreshToken: data.refresh_token || refreshToken, + resourceUrl: data.resource_url, + expiryDate: Date.now() + data.expires_in * 1000, + scope: data.scope, + }; + } finally { + clearTimeout(timeoutId); + } + }, + { + maxAttempts: 5, + initialDelayMs: 1000, + maxDelayMs: 15000, + shouldRetryOnError: (error) => { + // Don't retry on invalid_grant errors + if (error.message.includes('invalid_grant')) { + return false; + } + // Retry on 429 or 5xx errors + const status = getErrorStatus(error); + return status === 429 || (status !== undefined && status >= 500 && status < 600); + }, + } + ); } /** @@ -261,6 +321,8 @@ export async function performDeviceAuthFlow( // Check if we should slow down if (error instanceof SlowDownError) { interval = Math.min(interval * 1.5, 10000); // Increase interval, max 10s + } else if ((error as Error & { status?: number }).status === 401) { + throw new Error('Device code expired or invalid. Please restart authentication.'); } else { throw error; } diff --git a/src/utils/debug-logger.ts b/src/utils/debug-logger.ts new file mode 100644 index 0000000..9720d09 --- /dev/null +++ b/src/utils/debug-logger.ts @@ -0,0 +1,40 @@ +/** + * Debug logger utility + * Only outputs when OPENCODE_QWEN_DEBUG=1 is set + */ + +const DEBUG_ENABLED = process.env.OPENCODE_QWEN_DEBUG === '1'; + +export interface DebugLogger { + info: (message: string, ...args: unknown[]) => void; + warn: (message: string, ...args: unknown[]) => void; + error: (message: string, ...args: unknown[]) => void; + debug: (message: string, ...args: unknown[]) => void; +} + +export function createDebugLogger(prefix: string): DebugLogger { + const logPrefix = `[${prefix}]`; + + return { + info: (message: string, ...args: unknown[]) => { + if (DEBUG_ENABLED) { + console.log(`${logPrefix} [INFO] ${message}`, ...args); + } + }, + warn: (message: string, ...args: unknown[]) => { + if (DEBUG_ENABLED) { + console.warn(`${logPrefix} [WARN] ${message}`, ...args); + } + }, + error: (message: string, ...args: unknown[]) => { + if (DEBUG_ENABLED) { + console.error(`${logPrefix} [ERROR] ${message}`, ...args); + } + }, + debug: (message: string, ...args: unknown[]) => { + if (DEBUG_ENABLED) { + console.log(`${logPrefix} [DEBUG] ${message}`, ...args); + } + }, + }; +} diff --git a/src/utils/file-lock.ts b/src/utils/file-lock.ts new file mode 100644 index 0000000..c038e8d --- /dev/null +++ b/src/utils/file-lock.ts @@ -0,0 +1,200 @@ +/** + * File Locking Utility + * + * Provides atomic file locking to prevent concurrent writes + * Uses fs.openSync with 'wx' flag (atomic create-if-not-exists) + */ + +import { openSync, closeSync, unlinkSync, existsSync, statSync } from 'node:fs'; +import { createDebugLogger } from './debug-logger.js'; + +const debugLogger = createDebugLogger('FILE_LOCK'); + +export class FileLock { + private lockPath: string; + private fd: number | null = null; + private cleanupRegistered = false; + + constructor(filePath: string) { + this.lockPath = filePath + '.lock'; + this.registerCleanupHandlers(); + } + + /** + * Register cleanup handlers for process exit signals + * Ensures lock file is removed even if process crashes + */ + private registerCleanupHandlers(): void { + if (this.cleanupRegistered) return; + + const cleanup = () => { + try { + if (this.fd !== null) { + closeSync(this.fd); + } + if (existsSync(this.lockPath)) { + unlinkSync(this.lockPath); + debugLogger.info('Lock file cleaned up on exit'); + } + } catch (e) { + // Cleanup best-effort, ignore errors + } + }; + + process.on('exit', cleanup); + process.on('SIGINT', cleanup); + process.on('SIGTERM', cleanup); + process.on('uncaughtException', (err) => { + cleanup(); + throw err; // Re-throw after cleanup + }); + process.on('unhandledRejection', (reason, promise) => { + cleanup(); + debugLogger.error('Unhandled Rejection detected', { + reason: reason instanceof Error ? reason.message : String(reason), + promise: promise.constructor.name + }); + }); + + this.cleanupRegistered = true; + } + + /** + * Acquire the lock with timeout + * + * @param timeoutMs - Maximum time to wait for lock (default: 5000ms) + * @param retryIntervalMs - Time between retry attempts (default: 100ms) + * @returns true if lock acquired, false if timeout + */ + async acquire(timeoutMs = 5000, retryIntervalMs = 100): Promise { + const start = Date.now(); + let attempts = 0; + const STALE_THRESHOLD_MS = 10000; // 10 seconds (matches official client) + + while (Date.now() - start < timeoutMs) { + attempts++; + try { + // Check for stale lock before attempting to acquire + if (existsSync(this.lockPath)) { + try { + // Wrap stat in timeout to prevent hangs (3 second timeout, matches official client) + const stats = await this.withTimeout( + Promise.resolve(statSync(this.lockPath)), + 3000, + 'Lock file stat', + ); + const lockAge = Date.now() - stats.mtimeMs; + + if (lockAge > STALE_THRESHOLD_MS) { + debugLogger.warn(`Removing stale lock (age: ${lockAge}ms)`); + // Wrap unlink in timeout as well + await this.withTimeout( + Promise.resolve(unlinkSync(this.lockPath)), + 3000, + 'Stale lock removal', + ); + // Continue to acquire the lock + } + } catch (statError) { + // Lock may have been removed by another process, continue + debugLogger.debug('Stale lock check failed, continuing', statError); + } + } + + // 'wx' = create file, fail if exists (atomic operation!) + this.fd = openSync(this.lockPath, 'wx'); + debugLogger.info(`Lock acquired after ${attempts} attempts (${Date.now() - start}ms)`); + return true; + } catch (e: any) { + if (e.code !== 'EEXIST') { + // Unexpected error, not just "file exists" + debugLogger.error('Unexpected error acquiring lock:', e); + throw e; + } + // Lock file exists, another process has it + await this.delay(retryIntervalMs); + } + } + + debugLogger.warn(`Lock acquisition timeout after ${timeoutMs}ms (${attempts} attempts)`); + return false; + } + + /** + * Release the lock + * Must be called in finally block to ensure cleanup + */ + release(): void { + if (this.fd !== null) { + try { + closeSync(this.fd); + if (existsSync(this.lockPath)) { + unlinkSync(this.lockPath); + } + this.fd = null; + debugLogger.info('Lock released'); + } catch (e) { + debugLogger.error('Error releasing lock:', e); + // Don't throw, cleanup is best-effort + } + } + } + + /** + * Check if lock file exists (without acquiring) + */ + static isLocked(filePath: string): boolean { + const lockPath = filePath + '.lock'; + return existsSync(lockPath); + } + + /** + * Clean up stale lock file (if process crashed without releasing) + */ + static cleanup(filePath: string): void { + const lockPath = filePath + '.lock'; + try { + if (existsSync(lockPath)) { + unlinkSync(lockPath); + debugLogger.info('Cleaned up stale lock file'); + } + } catch (e) { + debugLogger.error('Error cleaning up lock file:', e); + } + } + + private delay(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); + } + + /** + * Wrap a promise with a timeout + * Prevents file operations from hanging indefinitely + * Matches official client's withTimeout() pattern + */ + private withTimeout( + promise: Promise, + timeoutMs: number, + operationType = 'File operation', + ): Promise { + let timeoutId: NodeJS.Timeout | undefined; + + return Promise.race([ + promise.finally(() => { + // Clear timeout when main promise completes (success or failure) + if (timeoutId) { + clearTimeout(timeoutId); + } + }), + new Promise((_, reject) => { + timeoutId = setTimeout( + () => + reject( + new Error(`${operationType} timed out after ${timeoutMs}ms`), + ), + timeoutMs, + ); + }), + ]); + } +} diff --git a/src/utils/retry.ts b/src/utils/retry.ts new file mode 100644 index 0000000..4032692 --- /dev/null +++ b/src/utils/retry.ts @@ -0,0 +1,197 @@ +/** + * Retry utilities inspired by qwen-code-0.12.0 + * Based on: packages/core/src/utils/retry.ts + */ + +import { createDebugLogger } from './debug-logger.js'; + +const debugLogger = createDebugLogger('RETRY'); + +export interface HttpError extends Error { + status?: number; +} + +export interface RetryOptions { + maxAttempts: number; + initialDelayMs: number; + maxDelayMs: number; + shouldRetryOnError: (error: Error) => boolean; + authType?: string; +} + +const DEFAULT_RETRY_OPTIONS: RetryOptions = { + maxAttempts: 7, + initialDelayMs: 1500, + maxDelayMs: 30000, // 30 seconds + shouldRetryOnError: defaultShouldRetry, +}; + +/** + * Default predicate function to determine if a retry should be attempted. + * Retries on 429 (Too Many Requests) and 5xx server errors. + */ +function defaultShouldRetry(error: Error | unknown): boolean { + const status = getErrorStatus(error); + return ( + status === 429 || (status !== undefined && status >= 500 && status < 600) + ); +} + +/** + * Delays execution for a specified number of milliseconds. + */ +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * Retries a function with exponential backoff and jitter. + * + * @param fn The asynchronous function to retry. + * @param options Optional retry configuration. + * @returns A promise that resolves with the result of the function if successful. + * @throws The last error encountered if all attempts fail. + */ +export async function retryWithBackoff( + fn: () => Promise, + options?: Partial, +): Promise { + if (options?.maxAttempts !== undefined && options.maxAttempts <= 0) { + throw new Error('maxAttempts must be a positive number.'); + } + + const cleanOptions = options + ? Object.fromEntries(Object.entries(options).filter(([_, v]) => v != null)) + : {}; + + const { + maxAttempts, + initialDelayMs, + maxDelayMs, + authType, + shouldRetryOnError, + } = { + ...DEFAULT_RETRY_OPTIONS, + ...cleanOptions, + }; + + let attempt = 0; + let currentDelay = initialDelayMs; + + while (attempt < maxAttempts) { + attempt++; + try { + return await fn(); + } catch (error) { + const errorStatus = getErrorStatus(error); + + // Check if we've exhausted retries or shouldn't retry + if (attempt >= maxAttempts || !shouldRetryOnError(error as Error)) { + throw error; + } + + const retryAfterMs = + errorStatus === 429 ? getRetryAfterDelayMs(error) : 0; + + if (retryAfterMs > 0) { + // Respect Retry-After header if present and parsed + debugLogger.warn( + `Attempt ${attempt} failed with status ${errorStatus ?? 'unknown'}. Retrying after explicit delay of ${retryAfterMs}ms...`, + error, + ); + await delay(retryAfterMs); + // Reset currentDelay for next potential non-429 error + currentDelay = initialDelayMs; + } else { + // Fallback to exponential backoff with jitter + logRetryAttempt(attempt, error, errorStatus); + // Add jitter: +/- 30% of currentDelay + const jitter = currentDelay * 0.3 * (Math.random() * 2 - 1); + const delayWithJitter = Math.max(0, currentDelay + jitter); + await delay(delayWithJitter); + currentDelay = Math.min(maxDelayMs, currentDelay * 2); + } + } + } + + throw new Error('Retry attempts exhausted'); +} + +/** + * Extracts the HTTP status code from an error object. + */ +export function getErrorStatus(error: unknown): number | undefined { + if (typeof error !== 'object' || error === null) { + return undefined; + } + + const err = error as { + status?: unknown; + statusCode?: unknown; + response?: { status?: unknown }; + error?: { code?: unknown }; + }; + + const value = + err.status ?? err.statusCode ?? err.response?.status ?? err.error?.code; + + return typeof value === 'number' && value >= 100 && value <= 599 + ? value + : undefined; +} + +/** + * Extracts the Retry-After delay from an error object's headers. + */ +function getRetryAfterDelayMs(error: unknown): number { + if (typeof error === 'object' && error !== null) { + if ( + 'response' in error && + typeof (error as { response?: unknown }).response === 'object' && + (error as { response?: unknown }).response !== null + ) { + const response = (error as { response: { headers?: unknown } }).response; + if ( + 'headers' in response && + typeof response.headers === 'object' && + response.headers !== null + ) { + const headers = response.headers as { 'retry-after'?: unknown }; + const retryAfterHeader = headers['retry-after']; + if (typeof retryAfterHeader === 'string') { + const retryAfterSeconds = parseInt(retryAfterHeader, 10); + if (!isNaN(retryAfterSeconds)) { + return retryAfterSeconds * 1000; + } + // It might be an HTTP date + const retryAfterDate = new Date(retryAfterHeader); + if (!isNaN(retryAfterDate.getTime())) { + return Math.max(0, retryAfterDate.getTime() - Date.now()); + } + } + } + } + } + return 0; +} + +/** + * Logs a message for a retry attempt when using exponential backoff. + */ +function logRetryAttempt( + attempt: number, + error: unknown, + errorStatus?: number, +): void { + const message = errorStatus + ? `Attempt ${attempt} failed with status ${errorStatus}. Retrying with backoff...` + : `Attempt ${attempt} failed. Retrying with backoff...`; + + if (errorStatus === 429) { + debugLogger.warn(message, error); + } else if (errorStatus && errorStatus >= 500 && errorStatus < 600) { + debugLogger.error(message, error); + } else { + debugLogger.warn(message, error); + } +} diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..8800ae9 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,159 @@ +# Testes - opencode-qwencode-auth + +Este diretório contém todos os testes do plugin, organizados por categoria. + +## 📁 Estrutura + +``` +tests/ +├── unit/ # Testes unitários formais (bun test) +│ ├── auth-integration.test.ts +│ ├── errors.test.ts +│ ├── file-lock.test.ts +│ ├── oauth.test.ts +│ ├── request-queue.test.ts +│ └── token-manager.test.ts +│ +├── integration/ # Testes de integração manuais +│ ├── debug.ts # End-to-end com API Qwen real +│ └── race-condition.ts # Concorrência entre processos +│ +└── robust/ # Stress tests + ├── runner.ts # Orquestrador de testes robustos + └── worker.ts # Worker para testes multi-processo +``` + +## 🧪 Testes Unitários + +**Execução:** +```bash +bun test # Todos os testes +bun test --watch # Watch mode +bun test unit/ # Apenas testes unitários +bun test # Teste específico +``` + +**Cobertura:** +- `errors.test.ts` - Sistema de erros e classificação (30+ testes) +- `oauth.test.ts` - PKCE, OAuth helpers, constants (20+ testes) +- `request-queue.test.ts` - Throttling e rate limiting (15+ testes) +- `token-manager.test.ts` - Gerenciamento de tokens (10+ testes) +- `file-lock.test.ts` - File locking mechanism (20+ testes) +- `auth-integration.test.ts` - Integração de componentes (15+ testes) + +**Total:** 100+ testes automatizados + +## 🔬 Testes de Integração (Manuais) + +### Debug (End-to-End) + +Testa o sistema completo com a API Qwen real. + +**Pré-requisitos:** +- Login realizado (`opencode auth login`) +- Credenciais válidas + +**Execução:** +```bash +bun run test:integration +# OU +bun run tests/integration/debug.ts full +``` + +**Testes incluídos:** +- PKCE generation +- Base URL resolution +- Credentials persistence +- Token expiry check +- Token refresh +- Retry mechanism +- Throttling +- TokenManager +- 401 recovery +- **Real Chat API call** (requer login) + +### Race Condition + +Testa concorrência entre múltiplos processos do plugin. + +**Execução:** +```bash +bun run test:race +# OU +bun run tests/integration/race-condition.ts +``` + +**O que testa:** +- Dois processos tentando refresh simultâneo +- File locking previne race conditions +- Recuperação de locks stale + +## 💪 Stress Tests (Robust) + +Testes de alta concorrência e cenários extremos. + +**Execução:** +```bash +bun run test:robust +# OU +bun run tests/robust/runner.ts +``` + +**Testes incluídos:** +1. **Race Condition (2 processos)** - Concorrência básica +2. **Stress Concurrency (10 processos)** - Alta concorrência +3. **Stale Lock Recovery** - Recuperação de locks abandonados +4. **Corrupted File Recovery** - Arquivo de credenciais corrompido + +**Duração:** ~30-60 segundos + +## 📊 Scripts package.json + +```json +{ + "scripts": { + "test": "bun test", + "test:watch": "bun test --watch", + "test:integration": "bun run tests/integration/debug.ts full", + "test:race": "bun run tests/integration/race-condition.ts", + "test:robust": "bun run tests/robust/runner.ts" + } +} +``` + +## 🎯 Quando usar cada tipo + +| Tipo | Quando usar | Requer login? | Automatizado? | +|------|-------------|---------------|---------------| +| **Unitários** | CI/CD, desenvolvimento diário | ❌ Não | ✅ Sim | +| **Integration (debug)** | Validação manual, troubleshooting | ✅ Sim | ❌ Não | +| **Race Condition** | Desenvolvimento de features novas | ❌ Não | ❌ Não | +| **Robust** | Validação pré-release | ❌ Não | ❌ Não | + +## 🔍 Debug de Testes + +**Habilitar logs detalhados:** +```bash +OPENCODE_QWEN_DEBUG=1 bun test +``` + +**Verbose mode no debug.ts:** +```bash +OPENCODE_QWEN_DEBUG=1 bun run tests/integration/debug.ts full +``` + +## 📝 Adicionando Novos Testes + +1. **Testes unitários:** Crie `tests/unit/.test.ts` +2. **Testes de integração:** Crie `tests/integration/.ts` +3. **Use `bun:test`:** + ```typescript + import { describe, it, expect, mock } from 'bun:test'; + ``` + +## ⚠️ Notas Importantes + +1. **Testes unitários** não modificam credenciais reais +2. **Testes de integração** podem modificar credenciais (usam cópias de teste) +3. **Stress tests** criam locks temporários e os limpam automaticamente +4. **Sempre rode** `bun test` antes de commitar diff --git a/tests/integration/debug.ts b/tests/integration/debug.ts new file mode 100644 index 0000000..4415e88 --- /dev/null +++ b/tests/integration/debug.ts @@ -0,0 +1,314 @@ +/** + * Debug & Test File - NÃO modifica comportamento do plugin + */ + +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import { existsSync, readFileSync } from 'node:fs'; + +// Importa funções do código existente (sem modificar) +import { + generatePKCE, + requestDeviceAuthorization, + pollDeviceToken, + tokenResponseToCredentials, + refreshAccessToken, + isCredentialsExpired, + SlowDownError, +} from '../../src/qwen/oauth.js'; +import { + loadCredentials, + saveCredentials, + resolveBaseUrl, + getCredentialsPath, +} from '../../src/plugin/auth.js'; +import { QWEN_API_CONFIG, QWEN_OAUTH_CONFIG, QWEN_OFFICIAL_HEADERS } from '../../src/constants.js'; +import { retryWithBackoff, getErrorStatus } from '../../src/utils/retry.js'; +import { RequestQueue } from '../../src/plugin/request-queue.js'; +import { tokenManager } from '../../src/plugin/token-manager.js'; +import type { QwenCredentials } from '../../src/types.js'; + +// ============================================ +// Logging Utilities +// ============================================ + +const LOG_PREFIX = { + TEST: '[TEST]', + INFO: '[INFO]', + OK: '[✓]', + FAIL: '[✗]', + WARN: '[!]', + DEBUG: '[→]', +}; + +function log(prefix: keyof typeof LOG_PREFIX, message: string, data?: unknown) { + const timestamp = new Date().toISOString().split('T')[1].replace('Z', ''); + const prefixStr = LOG_PREFIX[prefix]; + + if (data !== undefined) { + console.log(`${timestamp} ${prefixStr} ${message}`, data); + } else { + console.log(`${timestamp} ${prefixStr} ${message}`); + } +} + +function logTest(name: string, message: string) { + log('TEST', `${name}: ${message}`); +} + +function logOk(name: string, message: string) { + log('OK', `${name}: ${message}`); +} + +function logFail(name: string, message: string, error?: unknown) { + log('FAIL', `${name}: ${message}`); + if (error) { + console.error(' Error:', error instanceof Error ? error.message : error); + } +} + +function truncate(str: string, length: number): string { + if (str.length <= length) return str; + return str.substring(0, length) + '...'; +} + +// ============================================ +// Test Functions +// ============================================ + +async function testPKCE(): Promise { + logTest('PKCE', 'Iniciando teste de geração PKCE...'); + try { + const { verifier, challenge } = generatePKCE(); + logOk('PKCE', `Verifier gerado: ${truncate(verifier, 20)}`); + logOk('PKCE', `Challenge gerado: ${truncate(challenge, 20)}`); + return true; + } catch (error) { + logFail('PKCE', 'Falha na geração', error); + return false; + } +} + +async function testBaseUrlResolution(): Promise { + logTest('BaseUrl', 'Iniciando teste de resolução de baseURL...'); + const testCases = [ + { input: undefined, expected: QWEN_API_CONFIG.portalBaseUrl, desc: 'undefined' }, + { input: 'portal.qwen.ai', expected: QWEN_API_CONFIG.portalBaseUrl, desc: 'portal.qwen.ai' }, + { input: 'dashscope', expected: QWEN_API_CONFIG.defaultBaseUrl, desc: 'dashscope' }, + ]; + for (const tc of testCases) { + const res = resolveBaseUrl(tc.input); + if (res !== tc.expected) { + logFail('BaseUrl', `${tc.desc}: esperado ${tc.expected}, got ${res}`); + return false; + } + logOk('BaseUrl', `${tc.desc}: ${res} ✓`); + } + return true; +} + +async function testCredentialsPersistence(): Promise { + logTest('Credentials', 'Iniciando teste de persistência (usando arquivo temporário)...'); + + const originalPath = getCredentialsPath(); + const testPath = originalPath + '.test'; + + const testCreds: QwenCredentials = { + accessToken: 'test_accessToken_' + Date.now(), + tokenType: 'Bearer', + refreshToken: 'test_refreshToken_' + Date.now(), + resourceUrl: 'portal.qwen.ai', + expiryDate: Date.now() + 3600000, + }; + + try { + const fs = await import('node:fs'); + fs.writeFileSync(testPath, JSON.stringify({ + access_token: testCreds.accessToken, + token_type: testCreds.tokenType, + refresh_token: testCreds.refreshToken, + resource_url: testCreds.resourceUrl, + expiry_date: testCreds.expiryDate, + }, null, 2)); + + const content = fs.readFileSync(testPath, 'utf8'); + const data = JSON.parse(content); + const loaded = { + accessToken: data.access_token, + refreshToken: data.refresh_token, + }; + + fs.unlinkSync(testPath); + + if (loaded.accessToken !== testCreds.accessToken) { + logFail('Credentials', 'Access token não confere'); + return false; + } + logOk('Credentials', 'Persistência OK ✓'); + return true; + } catch (e) { + logFail('Credentials', 'Erro no teste de persistência', e); + return false; + } +} + +async function testIsCredentialsExpired(): Promise { + logTest('Expiry', 'Iniciando teste de expiração...'); + const creds = loadCredentials(); + if (!creds) { + log('WARN', 'Expiry', 'Nenhuma credential encontrada'); + return true; + } + const isExp = isCredentialsExpired(creds); + logOk('Expiry', `Is expired: ${isExp} ✓`); + return true; +} + +async function testTokenRefresh(): Promise { + logTest('Refresh', 'Iniciando teste de refresh...'); + const creds = loadCredentials(); + if (!creds || creds.accessToken?.startsWith('test_')) { + log('WARN', 'Refresh', 'Tokens de teste detectados - refresh EXPECTADO para falhar'); + return true; + } + try { + const refreshed = await refreshAccessToken(creds.refreshToken!); + logOk('Refresh', `Novo token: ${truncate(refreshed.accessToken, 20)} ✓`); + return true; + } catch (error) { + logFail('Refresh', 'Falha no refresh', error); + return false; + } +} + +async function testRetryMechanism(): Promise { + logTest('Retry', 'Iniciando teste de retry...'); + let attempts = 0; + await retryWithBackoff(async () => { + attempts++; + if (attempts < 3) throw { status: 429 }; + return 'ok'; + }, { maxAttempts: 5, initialDelayMs: 100 }); + logOk('Retry', `Sucesso após ${attempts} tentativas ✓`); + return attempts === 3; +} + +async function testThrottling(): Promise { + logTest('Throttling', 'Iniciando teste de throttling...'); + const queue = new RequestQueue(); + const start = Date.now(); + await queue.enqueue(async () => {}); + await queue.enqueue(async () => {}); + const elapsed = Date.now() - start; + logOk('Throttling', `Intervalo: ${elapsed}ms ✓`); + return elapsed >= 1000; +} + +async function testTokenManager(): Promise { + logTest('TokenManager', 'Iniciando teste do TokenManager...'); + tokenManager.clearCache(); + const creds = await tokenManager.getValidCredentials(); + if (creds) { + logOk('TokenManager', 'Busca de credentials OK ✓'); + return true; + } + logFail('TokenManager', 'Falha ao buscar credentials'); + return false; +} + +async function test401Recovery(): Promise { + logTest('401Recovery', 'Iniciando teste de recuperação 401...'); + let attempts = 0; + await retryWithBackoff(async () => { + attempts++; + if (attempts === 1) throw { status: 401 }; + return 'ok'; + }, { maxAttempts: 3, initialDelayMs: 100, shouldRetryOnError: (e: any) => e.status === 401 }); + logOk('401Recovery', `Recuperação OK em ${attempts} tentativas ✓`); + return attempts === 2; +} + +async function testRealChat(): Promise { + logTest('RealChat', 'Iniciando teste de chat real com a API...'); + + const creds = await tokenManager.getValidCredentials(); + if (!creds?.accessToken) { + logFail('RealChat', 'Nenhuma credential válida encontrada'); + return false; + } + + const baseUrl = resolveBaseUrl(creds.resourceUrl); + const url = `${baseUrl}/chat/completions`; + + log('DEBUG', 'RealChat', `URL: ${url}`); + log('DEBUG', 'RealChat', `Token: ${creds.accessToken.substring(0, 10)}...`); + + const headers = { + ...QWEN_OFFICIAL_HEADERS, + 'Authorization': `Bearer ${creds.accessToken}`, + 'Content-Type': 'application/json', + }; + + try { + const response = await fetch(url, { + method: 'POST', + headers, + body: JSON.stringify({ + model: 'coder-model', + messages: [{ role: 'user', content: 'hi' }], + max_tokens: 5 + }) + }); + + log('INFO', 'RealChat', `Status: ${response.status} ${response.statusText}`); + const data: any = await response.json(); + + if (response.ok) { + logOk('RealChat', `API respondeu com sucesso: "${data.choices?.[0]?.message?.content}" ✓`); + return true; + } else { + logFail('RealChat', `API retornou erro: ${JSON.stringify(data)}`); + return false; + } + } catch (error) { + logFail('RealChat', 'Erro na requisição fetch', error); + return false; + } +} + +// ============================================ +// Main +// ============================================ + +async function runTest(name: string, testFn: () => Promise): Promise { + console.log(`\nTEST: ${name}`); + return await testFn(); +} + +async function main() { + const command = process.argv[2] || 'full'; + const results: Record = {}; + + if (command === 'full') { + results.pkce = await runTest('PKCE', testPKCE); + results.baseurl = await runTest('BaseUrl', testBaseUrlResolution); + results.persistence = await runTest('Persistence', testCredentialsPersistence); + results.expiry = await runTest('Expiry', testIsCredentialsExpired); + results.refresh = await runTest('Refresh', testTokenRefresh); + results.retry = await runTest('Retry', testRetryMechanism); + results.throttling = await runTest('Throttling', testThrottling); + results.tm = await runTest('TokenManager', testTokenManager); + results.r401 = await runTest('401Recovery', test401Recovery); + results.chat = await runTest('RealChat', testRealChat); + + console.log('\nSUMMARY:'); + for (const [k, v] of Object.entries(results)) { + console.log(`${k}: ${v ? 'PASS' : 'FAIL'}`); + } + } else if (command === 'status') { + const creds = loadCredentials(); + console.log('Status:', creds); + } +} + +main().catch(console.error); diff --git a/tests/integration/race-condition.ts b/tests/integration/race-condition.ts new file mode 100644 index 0000000..e681630 --- /dev/null +++ b/tests/integration/race-condition.ts @@ -0,0 +1,255 @@ +/** + * Race Condition Test + * + * Simulates 2 processes trying to refresh token simultaneously + * Tests if file locking prevents concurrent refreshes + * + * Usage: + * bun run tests/test-race-condition.ts + */ + +import { spawn } from 'node:child_process'; +import { join } from 'node:path'; +import { existsSync, writeFileSync, unlinkSync, readFileSync, mkdirSync } from 'node:fs'; +import { homedir } from 'node:os'; + +const TEST_DIR = join(homedir(), '.qwen-test-race'); +const CREDENTIALS_PATH = join(TEST_DIR, 'oauth_creds.json'); +const LOG_PATH = join(TEST_DIR, 'refresh-log.json'); + +/** + * Helper script that performs token refresh using TokenManager (with file locking) + */ +function createRefreshScript(): string { + const scriptPath = join(TEST_DIR, 'do-refresh.ts'); + + const script = `import { writeFileSync, existsSync, readFileSync } from 'node:fs'; +import { tokenManager } from '/home/fallen33/opencode-qwencode-auth-PR/src/plugin/token-manager.js'; +import { getCredentialsPath } from '/home/fallen33/opencode-qwencode-auth-PR/src/plugin/auth.js'; + +const LOG_PATH = '${LOG_PATH}'; +const CREDS_PATH = '${CREDENTIALS_PATH}'; + +async function logRefresh(token: string) { + const logEntry = { + processId: process.pid, + timestamp: Date.now(), + token: token.substring(0, 20) + '...' + }; + + let log: any = { attempts: [] }; + if (existsSync(LOG_PATH)) { + log = JSON.parse(readFileSync(LOG_PATH, 'utf8')); + } + + log.attempts.push(logEntry); + writeFileSync(LOG_PATH, JSON.stringify(log, null, 2)); + console.log('[Refresh]', logEntry); +} + +async function main() { + writeFileSync(CREDS_PATH, JSON.stringify({ + access_token: 'old_token_' + Date.now(), + refresh_token: 'test_refresh_token', + token_type: 'Bearer', + resource_url: 'portal.qwen.ai', + expiry_date: Date.now() - 1000, + scope: 'openid' + }, null, 2)); + + const creds = await tokenManager.getValidCredentials(true); + if (creds) { + logRefresh(creds.accessToken); + } else { + logRefresh('FAILED'); + } +} + +main() + .then(() => process.exit(0)) + .catch(e => { console.error(e); process.exit(1); }); +`; + + writeFileSync(scriptPath, script); + return scriptPath; +} + +/** + * Setup test environment + */ +function setup(): void { + if (!existsSync(TEST_DIR)) { + mkdirSync(TEST_DIR, { recursive: true }); + } + if (existsSync(LOG_PATH)) unlinkSync(LOG_PATH); + const lockPath = CREDENTIALS_PATH + '.lock'; + if (existsSync(lockPath)) unlinkSync(lockPath); +} + +/** + * Cleanup test environment + */ +function cleanup(): void { + try { + if (existsSync(LOG_PATH)) unlinkSync(LOG_PATH); + if (existsSync(CREDENTIALS_PATH)) unlinkSync(CREDENTIALS_PATH); + const scriptPath = join(TEST_DIR, 'do-refresh.ts'); + if (existsSync(scriptPath)) unlinkSync(scriptPath); + const lockPath = CREDENTIALS_PATH + '.lock'; + if (existsSync(lockPath)) unlinkSync(lockPath); + } catch (e) { + console.warn('Cleanup warning:', e); + } +} + +/** + * Run 2 processes simultaneously + * Uses polling to check log file instead of relying on 'close' event + */ +async function runConcurrentRefreshes(): Promise { + const scriptPath = createRefreshScript(); + + return new Promise((resolve, reject) => { + const procs: any[] = []; + let errors = 0; + + // Start both processes + for (let i = 0; i < 2; i++) { + const proc = spawn('bun', [scriptPath], { + cwd: process.cwd(), + stdio: ['pipe', 'pipe', 'pipe'], + detached: false + }); + + procs.push(proc); + + proc.stdout.on('data', (data) => { + console.log(`[Proc ${i}]`, data.toString().trim()); + }); + + proc.stderr.on('data', (data) => { + console.error(`[Proc ${i} ERR]`, data.toString().trim()); + errors++; + }); + + // Don't wait for close event, just let processes finish + proc.unref(); + } + + // Poll log file for results + const startTime = Date.now(); + const timeout = 30000; + + const checkLog = setInterval(() => { + try { + if (existsSync(LOG_PATH)) { + const logContent = readFileSync(LOG_PATH, 'utf8').trim(); + if (logContent) { + const log = JSON.parse(logContent); + if (log.attempts && log.attempts.length >= 2) { + clearInterval(checkLog); + resolve(); + return; + } + } + } + + // Timeout check + if (Date.now() - startTime > timeout) { + clearInterval(checkLog); + reject(new Error('Test timeout - log file not populated')); + } + } catch (e) { + // Ignore parse errors, keep polling + } + }, 100); + }); +} + +/** + * Analyze results + * Note: This test verifies that file locking serializes access + * Even if both processes complete, they should not refresh simultaneously + */ +function analyzeResults(): boolean { + if (!existsSync(LOG_PATH)) { + console.error('❌ Log file not created'); + return false; + } + + const log = JSON.parse(readFileSync(LOG_PATH, 'utf8')); + const attempts = log.attempts || []; + + console.log('\n=== RESULTS ==='); + console.log(`Total refresh attempts: ${attempts.length}`); + + if (attempts.length === 0) { + console.error('❌ No refresh attempts recorded'); + return false; + } + + // Check if both processes got the SAME token (indicates locking worked) + const tokens = attempts.map((a: any) => a.token); + const uniqueTokens = new Set(tokens); + + console.log(`Unique tokens received: ${uniqueTokens.size}`); + + if (uniqueTokens.size === 1) { + console.log('✅ PASS: Both processes received the SAME token'); + console.log(' (File locking serialized the refresh operation)'); + return true; + } + + // If different tokens, check timing + if (attempts.length >= 2) { + const timeDiff = Math.abs(attempts[1].timestamp - attempts[0].timestamp); + + if (timeDiff < 100) { + console.log(`❌ FAIL: Concurrent refreshes detected (race condition!)`); + console.log(` Time difference: ${timeDiff}ms`); + console.log(` Tokens: ${tokens.join(', ')}`); + return false; + } + + console.log(`⚠️ ${attempts.length} refreshes, spaced ${timeDiff}ms apart`); + console.log(' (Locking worked - refreshes were serialized)'); + return true; + } + + console.log('✅ PASS: Single refresh completed'); + return true; +} + +/** + * Main test runner + */ +async function main(): Promise { + console.log('╔════════════════════════════════════════════╗'); + console.log('║ Race Condition Test - File Locking ║'); + console.log('╚════════════════════════════════════════════╝\n'); + + try { + console.log('Setting up test environment...'); + setup(); + + console.log('Running 2 concurrent refresh processes...\n'); + await runConcurrentRefreshes(); + + const passed = analyzeResults(); + + if (passed) { + console.log('\n✅ TEST PASSED: File locking prevents race condition\n'); + process.exit(0); + } else { + console.log('\n❌ TEST FAILED: Race condition detected\n'); + process.exit(1); + } + } catch (error) { + console.error('\n❌ TEST ERROR:', error); + process.exit(1); + } finally { + cleanup(); + } +} + +main(); diff --git a/tests/robust/runner.ts b/tests/robust/runner.ts new file mode 100644 index 0000000..304b838 --- /dev/null +++ b/tests/robust/runner.ts @@ -0,0 +1,267 @@ +/** + * Robust Test Runner + * + * Orchestrates multi-process tests for TokenManager and FileLock. + * Uses isolated temporary files to avoid modifying user credentials. + */ + +import { spawn } from 'node:child_process'; +import { join } from 'node:path'; +import { existsSync, writeFileSync, unlinkSync, readFileSync, mkdirSync, copyFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { FileLock } from '../../src/utils/file-lock.js'; +import { getCredentialsPath } from '../../src/plugin/auth.js'; + +// Isolated test directory (NOT user's ~/.qwen) +const TEST_TMP_DIR = join(tmpdir(), 'qwen-robust-tests'); +const TEST_CREDS_PATH = join(TEST_TMP_DIR, 'oauth_creds.json'); +const TEST_LOCK_PATH = TEST_CREDS_PATH + '.lock'; +const SHARED_LOG = join(TEST_TMP_DIR, 'results.log'); +const WORKER_SCRIPT = join(process.cwd(), 'tests/robust/worker.ts'); + +// Configurable timeout (default 90s for all tests) +const TEST_TIMEOUT = parseInt(process.env.TEST_TIMEOUT || '90000'); + +/** + * Setup test environment with isolated credentials + */ +function setup() { + if (!existsSync(TEST_TMP_DIR)) mkdirSync(TEST_TMP_DIR, { recursive: true }); + if (existsSync(SHARED_LOG)) unlinkSync(SHARED_LOG); + + // Copy real credentials to test location (read-only copy for testing) + const realCredsPath = getCredentialsPath(); + if (existsSync(realCredsPath)) { + copyFileSync(realCredsPath, TEST_CREDS_PATH); + } else { + // Create mock credentials if user has no login + writeFileSync(TEST_CREDS_PATH, JSON.stringify({ + access_token: 'mock_test_token_' + Date.now(), + token_type: 'Bearer', + refresh_token: 'mock_refresh_token', + resource_url: 'portal.qwen.ai', + expiry_date: Date.now() + 3600000, + scope: 'openid' + }, null, 2)); + } + + // Clean up stale locks from test directory only + if (existsSync(TEST_LOCK_PATH)) unlinkSync(TEST_LOCK_PATH); +} + +/** + * Cleanup test environment (only temp files, never user credentials) + */ +function cleanup() { + try { + if (existsSync(SHARED_LOG)) unlinkSync(SHARED_LOG); + if (existsSync(TEST_CREDS_PATH)) unlinkSync(TEST_CREDS_PATH); + if (existsSync(TEST_LOCK_PATH)) unlinkSync(TEST_LOCK_PATH); + } catch (e) { + console.warn('Cleanup warning:', e); + } +} + +/** + * Run worker process with isolated test environment + */ +async function runWorker(id: string, type: string): Promise { + return new Promise((resolve) => { + const child = spawn('bun', [WORKER_SCRIPT, id, type, SHARED_LOG], { + stdio: 'inherit', + env: { + ...process.env, + OPENCODE_QWEN_DEBUG: '1', + QWEN_TEST_TMP_DIR: TEST_TMP_DIR, + QWEN_TEST_CREDS_PATH: TEST_CREDS_PATH + } + }); + child.on('close', resolve); + }); +} + +async function testRaceCondition() { + console.log('\n--- 🏁 TEST: Concurrent Race Condition (2 Processes) ---'); + setup(); + + // Start 2 workers that both try to force refresh + const p1 = runWorker('W1', 'race'); + const p2 = runWorker('W2', 'race'); + + await Promise.all([p1, p2]); + + if (!existsSync(SHARED_LOG)) { + console.error('❌ FAIL: No log file created'); + return; + } + + const logContent = readFileSync(SHARED_LOG, 'utf8').trim(); + if (!logContent) { + console.error('❌ FAIL: No results in log'); + return; + } + const results = logContent.split('\n').map(l => JSON.parse(l)); + console.log(`Results collected: ${results.length}`); + + const tokens = results.map(r => r.token); + const uniqueTokens = new Set(tokens); + + console.log(`Unique tokens: ${uniqueTokens.size}`); + + if (uniqueTokens.size === 1 && results.every(r => r.status === 'success')) { + console.log('✅ PASS: Both processes ended up with the SAME token. Locking worked!'); + } else { + console.error('❌ FAIL: Processes have different tokens or failed.'); + console.error('Tokens:', tokens); + } + + cleanup(); +} + +async function testStressConcurrency() { + console.log('\n--- 🔥 TEST: Stress Concurrency (10 Processes) ---'); + setup(); + + const workers = []; + for (let i = 0; i < 10; i++) { + workers.push(runWorker(`STRESS_${i}`, 'stress')); + } + + const start = Date.now(); + await Promise.all(workers); + const elapsed = Date.now() - start; + + if (!existsSync(SHARED_LOG)) { + console.error('❌ FAIL: No log file created'); + return; + } + + const logContent = readFileSync(SHARED_LOG, 'utf8').trim(); + if (!logContent) { + console.error('❌ FAIL: No results in log'); + return; + } + const results = logContent.split('\n').map(l => JSON.parse(l)); + const successCount = results.filter(r => r.status === 'completed_stress').length; + + console.log(`Successes: ${successCount}/10 in ${elapsed}ms`); + + if (successCount === 10) { + console.log('✅ PASS: High concurrency handled successfully.'); + } else { + console.error('❌ FAIL: Some workers failed during stress test.'); + } + + cleanup(); +} + +async function testStaleLockRecovery() { + console.log('\n--- 🛡️ TEST: Stale Lock Recovery (Wait for timeout) ---'); + setup(); + + // Use TEST lock file, NEVER user's lock file + writeFileSync(TEST_LOCK_PATH, 'stale-lock-data'); + console.log('Created stale lock file manually...'); + console.log(`Test file: ${TEST_LOCK_PATH}`); + + const start = Date.now(); + console.log('Starting worker that must force refresh and hit the lock...'); + console.log('Expected wait time: ~5-6 seconds (lock timeout)'); + + // Force refresh ('race' type) to ensure it tries to acquire the lock + await runWorker('RECOVERY_W1', 'race'); + + const elapsed = Date.now() - start; + console.log(`Worker finished in ${elapsed}ms`); + + if (!existsSync(SHARED_LOG)) { + console.error('❌ FAIL: No log file created'); + return; + } + + const logContent = readFileSync(SHARED_LOG, 'utf8').trim(); + const results = logContent ? logContent.split('\n').map(l => JSON.parse(l)) : []; + + // Check if worker succeeded and took appropriate time (5-10 seconds) + const success = results.length > 0 && results[0].status === 'success'; + const timingOk = elapsed >= 4000 && elapsed <= 15000; // 4-15s window + + if (success && timingOk) { + console.log('✅ PASS: Worker recovered from stale lock after timeout.'); + console.log(` Elapsed: ${elapsed}ms (expected: 5-10s)`); + } else { + console.error(`❌ FAIL: Recovery failed.`); + console.error(` Status: ${success ? 'OK' : 'FAILED'}`); + console.error(` Timing: ${elapsed}ms ${timingOk ? 'OK' : '(expected 4-15s)'}`); + if (results.length > 0) console.error('Worker result:', results[0]); + } + + cleanup(); +} + +async function testCorruptedFileRecovery() { + console.log('\n--- ☣️ TEST: Corrupted File Recovery ---'); + setup(); + + // Use TEST credentials file, NEVER user's file + writeFileSync(TEST_CREDS_PATH, 'NOT_JSON_DATA_CORRUPTED_{{{'); + console.log('Corrupted credentials file manually...'); + console.log(`Test file: ${TEST_CREDS_PATH}`); + console.log('⚠️ This is a TEMPORARY test file (NOT user credentials)'); + + // Worker should handle JSON parse error and ideally trigger re-auth or return null safely + await runWorker('CORRUPT_W1', 'corrupt'); + + if (!existsSync(SHARED_LOG)) { + console.error('❌ FAIL: No log file created'); + return; + } + + const logContent = readFileSync(SHARED_LOG, 'utf8').trim(); + const results = logContent ? logContent.split('\n').map(l => JSON.parse(l)) : []; + + if (results.length > 0) { + console.log('Worker finished. Status:', results[0].status); + console.log('✅ PASS: Worker handled corrupted file without crashing.'); + } else { + console.error('❌ FAIL: Worker crashed or produced no log.'); + } + + cleanup(); +} + +async function main() { + const overallStart = Date.now(); + + console.log('╔════════════════════════════════════════════╗'); + console.log('║ Robust Tests - Multi-Process Safety ║'); + console.log('╚════════════════════════════════════════════╝'); + console.log(`Configuration: ${TEST_TIMEOUT}ms total timeout`); + console.log(`Test directory: ${TEST_TMP_DIR}`); + console.log('⚠️ Using isolated temp files (NOT user credentials)'); + console.log('⚠️ User credentials at ~/.qwen/ are SAFE'); + + try { + console.log('\n[Test 1/4] Race Condition...'); + await testRaceCondition(); + + console.log('\n[Test 2/4] Stress Concurrency...'); + await testStressConcurrency(); + + console.log('\n[Test 3/4] Stale Lock Recovery...'); + await testStaleLockRecovery(); + + console.log('\n[Test 4/4] Corrupted File Recovery...'); + await testCorruptedFileRecovery(); + + const totalElapsed = Date.now() - overallStart; + console.log(`\n🌟 ALL ROBUST TESTS COMPLETED 🌟`); + console.log(`Total time: ${(totalElapsed / 1000).toFixed(1)}s`); + } catch (error) { + console.error('\n❌ Test Runner Error:', error); + cleanup(); + process.exit(1); + } +} + +main(); diff --git a/tests/robust/worker.ts b/tests/robust/worker.ts new file mode 100644 index 0000000..86f24c7 --- /dev/null +++ b/tests/robust/worker.ts @@ -0,0 +1,79 @@ +/** + * Robust Test Worker + * + * Executed as a separate process to simulate concurrent plugin instances. + * Uses isolated temporary credentials via environment variables. + */ + +import { tokenManager } from '../../src/plugin/token-manager.js'; +import { appendFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +const workerId = process.argv[2] || 'unknown'; +const testType = process.argv[3] || 'standard'; +const sharedLogPath = process.argv[4]; + +// Use isolated test directory from environment variable +const TEST_TMP_DIR = process.env.QWEN_TEST_TMP_DIR || join(tmpdir(), 'qwen-robust-tests'); +const TEST_CREDS_PATH = process.env.QWEN_TEST_CREDS_PATH || join(TEST_TMP_DIR, 'oauth_creds.json'); + +// Set environment variable BEFORE tokenManager is used +process.env.QWEN_TEST_CREDS_PATH = TEST_CREDS_PATH; + +async function logResult(data: any) { + if (!sharedLogPath) { + console.log(JSON.stringify(data)); + return; + } + + const result = { + workerId, + timestamp: Date.now(), + pid: process.pid, + ...data + }; + + appendFileSync(sharedLogPath, JSON.stringify(result) + '\n'); +} + +async function runTest() { + try { + switch (testType) { + case 'race': + const creds = await tokenManager.getValidCredentials(true); + await logResult({ + status: 'success', + token: creds?.accessToken + }); + break; + + case 'corrupt': + const c3 = await tokenManager.getValidCredentials(); + await logResult({ status: 'success', token: c3?.accessToken?.substring(0, 10) }); + break; + + case 'stress': + for (let i = 0; i < 5; i++) { + await tokenManager.getValidCredentials(i === 0); + await new Promise(r => setTimeout(r, Math.random() * 200)); + } + await logResult({ status: 'completed_stress' }); + break; + + default: + const c2 = await tokenManager.getValidCredentials(); + await logResult({ status: 'success', token: c2?.accessToken?.substring(0, 10) }); + } + } catch (error: any) { + await logResult({ status: 'error', error: error.message }); + process.exit(1); + } + + process.exit(0); +} + +runTest().catch(async (e) => { + await logResult({ status: 'fatal', error: e.message }); + process.exit(1); +}); diff --git a/tests/unit/auth-integration.test.ts b/tests/unit/auth-integration.test.ts new file mode 100644 index 0000000..59044dc --- /dev/null +++ b/tests/unit/auth-integration.test.ts @@ -0,0 +1,200 @@ +/** + * Integration tests for authentication utilities + * Tests components that work together but don't require real API calls + */ + +import { describe, it, expect, mock } from 'bun:test'; +import { + generatePKCE, + isCredentialsExpired, + SlowDownError, +} from '../../src/qwen/oauth.js'; +import { + resolveBaseUrl, + getCredentialsPath, +} from '../../src/plugin/auth.js'; +import { QWEN_API_CONFIG } from '../../src/constants.js'; +import { retryWithBackoff, getErrorStatus } from '../../src/utils/retry.js'; +import type { QwenCredentials } from '../../src/types.js'; + +describe('resolveBaseUrl', () => { + it('should return portal URL for undefined', () => { + const result = resolveBaseUrl(undefined); + expect(result).toBe(QWEN_API_CONFIG.portalBaseUrl); + }); + + it('should return portal URL for portal.qwen.ai', () => { + const result = resolveBaseUrl('portal.qwen.ai'); + expect(result).toBe(QWEN_API_CONFIG.portalBaseUrl); + }); + + it('should return dashscope URL for dashscope', () => { + const result = resolveBaseUrl('dashscope'); + expect(result).toBe(QWEN_API_CONFIG.defaultBaseUrl); + }); + + it('should return dashscope URL for dashscope.aliyuncs.com', () => { + const result = resolveBaseUrl('dashscope.aliyuncs.com'); + expect(result).toBe(QWEN_API_CONFIG.defaultBaseUrl); + }); + + it('should return portal URL for unknown URLs', () => { + const customUrl = 'https://custom.api.example.com'; + const result = resolveBaseUrl(customUrl); + expect(result).toBe(QWEN_API_CONFIG.portalBaseUrl); + }); +}); + +describe('isCredentialsExpired', () => { + const createCredentials = (expiryOffset: number): QwenCredentials => ({ + accessToken: 'test_token', + tokenType: 'Bearer', + refreshToken: 'test_refresh', + resourceUrl: 'portal.qwen.ai', + expiryDate: Date.now() + expiryOffset, + scope: 'openid', + }); + + it('should return false for valid credentials (not expired)', () => { + const creds = createCredentials(3600000); + expect(isCredentialsExpired(creds)).toBe(false); + }); + + it('should return true for expired credentials', () => { + const creds = createCredentials(-3600000); + expect(isCredentialsExpired(creds)).toBe(true); + }); + + it('should return true for credentials expiring within buffer', () => { + const creds = createCredentials(20000); + expect(isCredentialsExpired(creds)).toBe(true); + }); +}); + +describe('generatePKCE', () => { + it('should generate verifier with correct length', () => { + const { verifier } = generatePKCE(); + expect(verifier.length).toBeGreaterThanOrEqual(43); + expect(verifier.length).toBeLessThanOrEqual(128); + }); + + it('should generate verifier with base64url characters only', () => { + const { verifier } = generatePKCE(); + expect(verifier).toMatch(/^[a-zA-Z0-9_-]+$/); + }); + + it('should generate challenge from verifier', () => { + const { verifier, challenge } = generatePKCE(); + expect(challenge).toBeDefined(); + expect(challenge.length).toBeGreaterThan(0); + expect(challenge).not.toBe(verifier); + }); + + it('should generate different pairs on each call', () => { + const pkce1 = generatePKCE(); + const pkce2 = generatePKCE(); + + expect(pkce1.verifier).not.toBe(pkce2.verifier); + expect(pkce1.challenge).not.toBe(pkce2.challenge); + }); +}); + +describe('retryWithBackoff', () => { + it('should succeed on first attempt', async () => { + const mockFn = mock(() => 'success'); + const result = await retryWithBackoff(mockFn, { maxAttempts: 3 }); + expect(result).toBe('success'); + expect(mockFn).toHaveBeenCalledTimes(1); + }); + + it('should retry on transient errors', async () => { + let attempts = 0; + const result = await retryWithBackoff(async () => { + attempts++; + if (attempts < 3) throw { status: 429 }; + return 'success'; + }, { maxAttempts: 5, initialDelayMs: 50 }); + + expect(result).toBe('success'); + expect(attempts).toBe(3); + }); + + it('should not retry on permanent errors', async () => { + let attempts = 0; + await expect( + retryWithBackoff(async () => { + attempts++; + throw { status: 400 }; + }, { maxAttempts: 3, initialDelayMs: 50 }) + ).rejects.toThrow(); + + expect(attempts).toBe(1); + }); + + it('should respect maxAttempts', async () => { + let attempts = 0; + await expect( + retryWithBackoff(async () => { + attempts++; + throw { status: 429 }; + }, { maxAttempts: 3, initialDelayMs: 50 }) + ).rejects.toThrow(); + + expect(attempts).toBe(3); + }); + + it('should handle 401 errors with custom retry logic', async () => { + let attempts = 0; + const result = await retryWithBackoff(async () => { + attempts++; + if (attempts === 1) throw { status: 401 }; + return 'success'; + }, { + maxAttempts: 3, + initialDelayMs: 50, + shouldRetryOnError: (error: any) => error.status === 401 + }); + + expect(result).toBe('success'); + expect(attempts).toBe(2); + }); +}); + +describe('getErrorStatus', () => { + it('should extract status from error object', () => { + const error = { status: 429, message: 'Too Many Requests' }; + expect(getErrorStatus(error)).toBe(429); + }); + + it('should return undefined for error without status', () => { + const error = { message: 'Something went wrong' }; + expect(getErrorStatus(error)).toBeUndefined(); + }); + + it('should return undefined for null/undefined', () => { + expect(getErrorStatus(null as any)).toBeUndefined(); + expect(getErrorStatus(undefined)).toBeUndefined(); + }); +}); + +describe('SlowDownError', () => { + it('should create error with correct name', () => { + const error = new SlowDownError(); + expect(error.name).toBe('SlowDownError'); + expect(error.message).toContain('slow_down'); + }); +}); + +describe('getCredentialsPath', () => { + it('should return path in home directory', () => { + const path = getCredentialsPath(); + expect(path).toContain('.qwen'); + expect(path).toContain('oauth_creds.json'); + }); + + it('should return consistent path', () => { + const path1 = getCredentialsPath(); + const path2 = getCredentialsPath(); + expect(path1).toBe(path2); + }); +}); diff --git a/tests/unit/errors.test.ts b/tests/unit/errors.test.ts new file mode 100644 index 0000000..873497c --- /dev/null +++ b/tests/unit/errors.test.ts @@ -0,0 +1,238 @@ +/** + * Tests for error handling and classification + */ + +import { describe, it, expect } from 'bun:test'; +import { + QwenAuthError, + QwenApiError, + QwenNetworkError, + CredentialsClearRequiredError, + TokenManagerError, + TokenError, + classifyError, +} from '../../src/errors.js'; + +describe('QwenAuthError', () => { + it('should create token_expired error with correct message', () => { + const error = new QwenAuthError('token_expired'); + expect(error.name).toBe('QwenAuthError'); + expect(error.kind).toBe('token_expired'); + expect(error.message).toContain('Token expirado'); + expect(error.message).toContain('opencode auth login'); + }); + + it('should create refresh_failed error with correct message', () => { + const error = new QwenAuthError('refresh_failed'); + expect(error.kind).toBe('refresh_failed'); + expect(error.message).toContain('Falha ao renovar token'); + }); + + it('should create auth_required error with correct message', () => { + const error = new QwenAuthError('auth_required'); + expect(error.kind).toBe('auth_required'); + expect(error.message).toContain('Autenticacao necessaria'); + }); + + it('should create credentials_clear_required error with correct message', () => { + const error = new QwenAuthError('credentials_clear_required'); + expect(error.kind).toBe('credentials_clear_required'); + expect(error.message).toContain('Credenciais invalidas'); + }); + + it('should store technical detail when provided', () => { + const error = new QwenAuthError('refresh_failed', 'HTTP 400: invalid_grant'); + expect(error.technicalDetail).toBe('HTTP 400: invalid_grant'); + }); +}); + +describe('CredentialsClearRequiredError', () => { + it('should extend QwenAuthError', () => { + const error = new CredentialsClearRequiredError(); + expect(error).toBeInstanceOf(QwenAuthError); + expect(error.name).toBe('CredentialsClearRequiredError'); + expect(error.kind).toBe('credentials_clear_required'); + }); + + it('should store technical detail', () => { + const error = new CredentialsClearRequiredError('Refresh token revoked'); + expect(error.technicalDetail).toBe('Refresh token revoked'); + }); +}); + +describe('QwenApiError', () => { + it('should classify 401 as unauthorized', () => { + const error = new QwenApiError(401); + expect(error.kind).toBe('unauthorized'); + expect(error.message).toContain('Token invalido ou expirado'); + }); + + it('should classify 403 as unauthorized', () => { + const error = new QwenApiError(403); + expect(error.kind).toBe('unauthorized'); + }); + + it('should classify 429 as rate_limit', () => { + const error = new QwenApiError(429); + expect(error.kind).toBe('rate_limit'); + expect(error.message).toContain('Limite de requisicoes atingido'); + }); + + it('should classify 500 as server_error', () => { + const error = new QwenApiError(500); + expect(error.kind).toBe('server_error'); + expect(error.message).toContain('Servidor Qwen indisponivel'); + }); + + it('should classify 503 as server_error', () => { + const error = new QwenApiError(503); + expect(error.kind).toBe('server_error'); + }); + + it('should classify unknown errors correctly', () => { + const error = new QwenApiError(400); + expect(error.kind).toBe('unknown'); + }); + + it('should store status code', () => { + const error = new QwenApiError(429); + expect(error.statusCode).toBe(429); + }); +}); + +describe('QwenNetworkError', () => { + it('should create network error with correct message', () => { + const error = new QwenNetworkError('fetch failed'); + expect(error.name).toBe('QwenNetworkError'); + expect(error.message).toContain('Erro de rede'); + expect(error.message).toContain('fetch failed'); + }); + + it('should store technical detail', () => { + const error = new QwenNetworkError('timeout', 'ETIMEDOUT'); + expect(error.technicalDetail).toBe('ETIMEDOUT'); + }); +}); + +describe('TokenManagerError', () => { + it('should create error with REFRESH_FAILED type', () => { + const error = new TokenManagerError(TokenError.REFRESH_FAILED, 'Refresh failed'); + expect(error.name).toBe('TokenManagerError'); + expect(error.type).toBe(TokenError.REFRESH_FAILED); + expect(error.message).toBe('Refresh failed'); + }); + + it('should create error with NO_REFRESH_TOKEN type', () => { + const error = new TokenManagerError(TokenError.NO_REFRESH_TOKEN, 'No refresh token'); + expect(error.type).toBe(TokenError.NO_REFRESH_TOKEN); + }); + + it('should create error with LOCK_TIMEOUT type', () => { + const error = new TokenManagerError(TokenError.LOCK_TIMEOUT, 'Lock timeout'); + expect(error.type).toBe(TokenError.LOCK_TIMEOUT); + }); + + it('should create error with FILE_ACCESS_ERROR type', () => { + const error = new TokenManagerError(TokenError.FILE_ACCESS_ERROR, 'File access error'); + expect(error.type).toBe(TokenError.FILE_ACCESS_ERROR); + }); + + it('should create error with NETWORK_ERROR type', () => { + const error = new TokenManagerError(TokenError.NETWORK_ERROR, 'Network error'); + expect(error.type).toBe(TokenError.NETWORK_ERROR); + }); + + it('should create error with CREDENTIALS_CLEAR_REQUIRED type', () => { + const error = new TokenManagerError(TokenError.CREDENTIALS_CLEAR_REQUIRED, 'Clear required'); + expect(error.type).toBe(TokenError.CREDENTIALS_CLEAR_REQUIRED); + }); +}); + +describe('classifyError', () => { + it('should classify CredentialsClearRequiredError correctly', () => { + const error = new CredentialsClearRequiredError(); + const result = classifyError(error); + expect(result.kind).toBe('auth'); + expect(result.isRetryable).toBe(false); + expect(result.shouldClearCache).toBe(true); + }); + + it('should classify QwenAuthError token_expired correctly', () => { + const error = new QwenAuthError('token_expired'); + const result = classifyError(error); + expect(result.kind).toBe('auth'); + expect(result.isRetryable).toBe(false); + expect(result.shouldClearCache).toBe(false); + }); + + it('should classify QwenAuthError refresh_failed as retryable', () => { + const error = new QwenAuthError('refresh_failed'); + const result = classifyError(error); + expect(result.kind).toBe('auth'); + expect(result.isRetryable).toBe(true); + expect(result.shouldClearCache).toBe(false); + }); + + it('should classify QwenApiError rate_limit as retryable', () => { + const error = new QwenApiError(429); + const result = classifyError(error); + expect(result.kind).toBe('api'); + expect(result.isRetryable).toBe(true); + expect(result.shouldClearCache).toBe(false); + }); + + it('should classify QwenApiError unauthorized as not retryable', () => { + const error = new QwenApiError(401); + const result = classifyError(error); + expect(result.kind).toBe('api'); + expect(result.isRetryable).toBe(false); + expect(result.shouldClearCache).toBe(false); + }); + + it('should classify QwenApiError server_error as retryable', () => { + const error = new QwenApiError(503); + const result = classifyError(error); + expect(result.kind).toBe('api'); + expect(result.isRetryable).toBe(true); + expect(result.shouldClearCache).toBe(false); + }); + + it('should classify QwenNetworkError as retryable', () => { + const error = new QwenNetworkError('fetch failed'); + const result = classifyError(error); + expect(result.kind).toBe('network'); + expect(result.isRetryable).toBe(true); + expect(result.shouldClearCache).toBe(false); + }); + + it('should classify AbortError as timeout', () => { + const error = new Error('timeout'); + error.name = 'AbortError'; + const result = classifyError(error); + expect(result.kind).toBe('timeout'); + expect(result.isRetryable).toBe(true); + expect(result.shouldClearCache).toBe(false); + }); + + it('should classify network errors by message', () => { + const error = new Error('fetch failed: network error'); + const result = classifyError(error); + expect(result.kind).toBe('network'); + expect(result.isRetryable).toBe(true); + }); + + it('should classify timeout errors by message', () => { + const error = new Error('request timeout'); + const result = classifyError(error); + expect(result.kind).toBe('network'); + expect(result.isRetryable).toBe(true); + }); + + it('should classify unknown errors as not retryable', () => { + const error = new Error('unknown error'); + const result = classifyError(error); + expect(result.kind).toBe('unknown'); + expect(result.isRetryable).toBe(false); + expect(result.shouldClearCache).toBe(false); + }); +}); diff --git a/tests/unit/file-lock.test.ts b/tests/unit/file-lock.test.ts new file mode 100644 index 0000000..a019385 --- /dev/null +++ b/tests/unit/file-lock.test.ts @@ -0,0 +1,219 @@ +/** + * Tests for FileLock mechanism + */ + +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import { FileLock } from '../../src/utils/file-lock.js'; +import { join } from 'node:path'; +import { homedir } from 'node:os'; +import { existsSync, unlinkSync } from 'node:fs'; + +const TEST_FILE = join(homedir(), '.qwen-test-lock.txt'); +const LOCK_FILE = TEST_FILE + '.lock'; + +describe('FileLock', () => { + beforeEach(() => { + // Clean up any stale lock files + if (existsSync(LOCK_FILE)) { + unlinkSync(LOCK_FILE); + } + if (existsSync(TEST_FILE)) { + unlinkSync(TEST_FILE); + } + }); + + afterEach(() => { + // Clean up after tests + if (existsSync(LOCK_FILE)) { + unlinkSync(LOCK_FILE); + } + if (existsSync(TEST_FILE)) { + unlinkSync(TEST_FILE); + } + }); + + describe('acquire', () => { + it('should acquire lock successfully', async () => { + const lock = new FileLock(TEST_FILE); + const acquired = await lock.acquire(1000); + expect(acquired).toBe(true); + lock.release(); + }); + + it('should create lock file', async () => { + const lock = new FileLock(TEST_FILE); + await lock.acquire(1000); + + expect(existsSync(LOCK_FILE)).toBe(true); + lock.release(); + }); + + it('should fail to acquire when lock is held', async () => { + const lock1 = new FileLock(TEST_FILE); + const lock2 = new FileLock(TEST_FILE); + + const acquired1 = await lock1.acquire(1000); + expect(acquired1).toBe(true); + + // Try to acquire with short timeout (should fail) + const acquired2 = await lock2.acquire(200, 50); + expect(acquired2).toBe(false); + + lock1.release(); + }); + + it('should succeed after lock is released', async () => { + const lock1 = new FileLock(TEST_FILE); + const lock2 = new FileLock(TEST_FILE); + + await lock1.acquire(1000); + lock1.release(); + + const acquired2 = await lock2.acquire(1000); + expect(acquired2).toBe(true); + + lock2.release(); + }); + + it('should wait and acquire when lock is released by another holder', async () => { + const lock1 = new FileLock(TEST_FILE); + const lock2 = new FileLock(TEST_FILE); + + await lock1.acquire(1000); + + // Start acquiring lock2 in background + const lock2Promise = lock2.acquire(2000, 100); + + // Release lock1 after a short delay + setTimeout(() => lock1.release(), 300); + + const acquired2 = await lock2Promise; + expect(acquired2).toBe(true); + + lock2.release(); + }); + }); + + describe('release', () => { + it('should remove lock file', async () => { + const lock = new FileLock(TEST_FILE); + await lock.acquire(1000); + expect(existsSync(LOCK_FILE)).toBe(true); + + lock.release(); + expect(existsSync(LOCK_FILE)).toBe(false); + }); + + it('should not throw if called without acquire', () => { + const lock = new FileLock(TEST_FILE); + expect(() => lock.release()).not.toThrow(); + }); + + it('should be idempotent', async () => { + const lock = new FileLock(TEST_FILE); + await lock.acquire(1000); + lock.release(); + + expect(() => lock.release()).not.toThrow(); + }); + }); + + describe('timeout', () => { + it('should timeout after specified time', async () => { + const lock1 = new FileLock(TEST_FILE); + const lock2 = new FileLock(TEST_FILE); + + await lock1.acquire(1000); + + const start = Date.now(); + const acquired = await lock2.acquire(500, 100); + const elapsed = Date.now() - start; + + expect(acquired).toBe(false); + expect(elapsed).toBeGreaterThanOrEqual(400); + expect(elapsed).toBeLessThanOrEqual(700); + + lock1.release(); + }); + + it('should handle very short timeouts', async () => { + const lock1 = new FileLock(TEST_FILE); + const lock2 = new FileLock(TEST_FILE); + + await lock1.acquire(1000); + + const start = Date.now(); + const acquired = await lock2.acquire(100, 50); + const elapsed = Date.now() - start; + + expect(acquired).toBe(false); + expect(elapsed).toBeGreaterThanOrEqual(50); + + lock1.release(); + }); + }); + + describe('concurrent access', () => { + it('should handle multiple acquire attempts', async () => { + const locks = Array.from({ length: 5 }, () => new FileLock(TEST_FILE)); + + // First lock acquires + const acquired1 = await locks[0].acquire(1000); + expect(acquired1).toBe(true); + + // Others try to acquire with short timeout + const results = await Promise.all( + locks.slice(1).map(lock => lock.acquire(200, 50)) + ); + + // All should fail + expect(results.every(r => r === false)).toBe(true); + + locks[0].release(); + }); + + it('should serialize access when waiting', async () => { + const lock1 = new FileLock(TEST_FILE); + const lock2 = new FileLock(TEST_FILE); + const lock3 = new FileLock(TEST_FILE); + + await lock1.acquire(1000); + + const results: boolean[] = []; + const timestamps: number[] = []; + + // Start lock2 and lock3 waiting + const p2 = (async () => { + const r = await lock2.acquire(3000, 100); + timestamps.push(Date.now()); + results.push(r); + if (r) lock2.release(); + })(); + + const p3 = (async () => { + const r = await lock3.acquire(3000, 100); + timestamps.push(Date.now()); + results.push(r); + if (r) lock3.release(); + })(); + + // Release lock1 after short delay + setTimeout(() => lock1.release(), 200); + + await Promise.all([p2, p3]); + + // Both should eventually succeed + expect(results.filter(r => r).length).toBe(2); + }); + }); + + describe('edge cases', () => { + it('should handle multiple release calls', () => { + const lock = new FileLock(TEST_FILE); + expect(() => { + lock.release(); + lock.release(); + }).not.toThrow(); + }); + }); +}); diff --git a/tests/unit/oauth.test.ts b/tests/unit/oauth.test.ts new file mode 100644 index 0000000..c951d8c --- /dev/null +++ b/tests/unit/oauth.test.ts @@ -0,0 +1,142 @@ +/** + * Tests for OAuth Device Flow + */ + +import { describe, it, expect, mock } from 'bun:test'; +import { createHash } from 'node:crypto'; +import { + generatePKCE, + objectToUrlEncoded, + tokenResponseToCredentials, +} from '../../src/qwen/oauth.js'; +import type { TokenResponse } from '../../src/qwen/oauth.js'; + +describe('PKCE Generation', () => { + it('should generate PKCE with verifier and challenge', () => { + const pkce = generatePKCE(); + expect(pkce.verifier).toBeDefined(); + expect(pkce.challenge).toBeDefined(); + expect(pkce.verifier.length).toBeGreaterThanOrEqual(43); + expect(pkce.verifier.length).toBeLessThanOrEqual(128); + }); + + it('should generate verifier with base64url characters only', () => { + const { verifier } = generatePKCE(); + expect(verifier).toMatch(/^[a-zA-Z0-9_-]+$/); + }); + + it('should generate different PKCE pairs on each call', () => { + const pkce1 = generatePKCE(); + const pkce2 = generatePKCE(); + expect(pkce1.verifier).not.toBe(pkce2.verifier); + expect(pkce1.challenge).not.toBe(pkce2.challenge); + }); + + it('should generate code challenge from verifier', () => { + const { verifier, challenge } = generatePKCE(); + + // Verify code challenge is base64url encoded SHA256 + const hash = createHash('sha256') + .update(verifier) + .digest('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=/g, ''); + + expect(challenge).toBe(hash); + }); +}); + +describe('objectToUrlEncoded', () => { + it('should encode simple object', () => { + const obj = { key1: 'value1', key2: 'value2' }; + const result = objectToUrlEncoded(obj); + expect(result).toBe('key1=value1&key2=value2'); + }); + + it('should encode special characters', () => { + const obj = { key: 'value with spaces', special: 'a&b=c' }; + const result = objectToUrlEncoded(obj); + expect(result).toBe('key=value%20with%20spaces&special=a%26b%3Dc'); + }); + + it('should handle empty strings', () => { + const obj = { key: '' }; + const result = objectToUrlEncoded(obj); + expect(result).toBe('key='); + }); + + it('should handle multiple keys with same name (last one wins)', () => { + // Note: JavaScript objects don't support duplicate keys + // This test documents the behavior + const obj = { key: 'first', key: 'second' }; + const result = objectToUrlEncoded(obj); + expect(result).toBe('key=second'); + }); +}); + +describe('tokenResponseToCredentials', () => { + const mockTokenResponse: TokenResponse = { + access_token: 'test_access_token', + token_type: 'Bearer', + refresh_token: 'test_refresh_token', + resource_url: 'https://dashscope.aliyuncs.com', + expires_in: 7200, + scope: 'openid profile email model.completion', + }; + + it('should convert token response to credentials', () => { + const credentials = tokenResponseToCredentials(mockTokenResponse); + + expect(credentials.accessToken).toBe('test_access_token'); + expect(credentials.tokenType).toBe('Bearer'); + expect(credentials.refreshToken).toBe('test_refresh_token'); + expect(credentials.resourceUrl).toBe('https://dashscope.aliyuncs.com'); + expect(credentials.scope).toBe('openid profile email model.completion'); + }); + + it('should default token_type to Bearer if not provided', () => { + const response = { ...mockTokenResponse, token_type: undefined as any }; + const credentials = tokenResponseToCredentials(response); + expect(credentials.tokenType).toBe('Bearer'); + }); + + it('should calculate expiryDate correctly', () => { + const before = Date.now(); + const credentials = tokenResponseToCredentials(mockTokenResponse); + const after = Date.now() + 7200000; // 2 hours in ms + + expect(credentials.expiryDate).toBeGreaterThanOrEqual(before + 7200000); + expect(credentials.expiryDate).toBeLessThanOrEqual(after + 1000); // Small buffer + }); + + it('should handle missing refresh_token', () => { + const response = { ...mockTokenResponse, refresh_token: undefined as any }; + const credentials = tokenResponseToCredentials(response); + expect(credentials.refreshToken).toBeUndefined(); + }); + + it('should handle missing resource_url', () => { + const response = { ...mockTokenResponse, resource_url: undefined as any }; + const credentials = tokenResponseToCredentials(response); + expect(credentials.resourceUrl).toBeUndefined(); + }); +}); + +describe('OAuth Constants', () => { + it('should have correct grant type', () => { + const { QWEN_OAUTH_CONFIG } = require('../../src/constants.js'); + expect(QWEN_OAUTH_CONFIG.grantType).toBe('urn:ietf:params:oauth:grant-type:device_code'); + }); + + it('should have scope including model.completion', () => { + const { QWEN_OAUTH_CONFIG } = require('../../src/constants.js'); + expect(QWEN_OAUTH_CONFIG.scope).toContain('model.completion'); + }); + + it('should have non-empty client_id', () => { + const { QWEN_OAUTH_CONFIG } = require('../../src/constants.js'); + expect(QWEN_OAUTH_CONFIG.clientId).toBeTruthy(); + expect(QWEN_OAUTH_CONFIG.clientId.length).toBeGreaterThan(0); + }); +}); diff --git a/tests/unit/request-queue.test.ts b/tests/unit/request-queue.test.ts new file mode 100644 index 0000000..b0885ab --- /dev/null +++ b/tests/unit/request-queue.test.ts @@ -0,0 +1,191 @@ +/** + * Tests for Request Queue (Throttling) + */ + +import { describe, it, expect, beforeEach, mock } from 'bun:test'; +import { RequestQueue } from '../../src/plugin/request-queue.js'; + +describe('RequestQueue', () => { + let queue: RequestQueue; + + beforeEach(() => { + queue = new RequestQueue(); + }); + + describe('constructor', () => { + it('should create instance with default interval', () => { + expect(queue).toBeInstanceOf(RequestQueue); + }); + }); + + describe('enqueue', () => { + it('should execute function immediately if no recent requests', async () => { + const mockFn = mock(() => 'result'); + const result = await queue.enqueue(mockFn); + + expect(result).toBe('result'); + expect(mockFn).toHaveBeenCalledTimes(1); + }); + + it('should delay subsequent requests to respect MIN_INTERVAL', async () => { + const results: number[] = []; + + const fn1 = async () => { + results.push(Date.now()); + return 'first'; + }; + + const fn2 = async () => { + results.push(Date.now()); + return 'second'; + }; + + // Execute first request + await queue.enqueue(fn1); + + // Execute second request immediately + await queue.enqueue(fn2); + + // Check that there was a delay + expect(results).toHaveLength(2); + const delay = results[1] - results[0]; + expect(delay).toBeGreaterThanOrEqual(900); // ~1 second with some tolerance + }); + + it('should add jitter to delay', async () => { + const delays: number[] = []; + + // Run 3 requests with small delays to detect jitter + for (let i = 0; i < 3; i++) { + const start = Date.now(); + await queue.enqueue(async () => { + await new Promise(resolve => setTimeout(resolve, 10)); + }); + const end = Date.now(); + + if (i > 0) { + delays.push(end - start); + } + } + + // All delays should be at least the minimum interval + delays.forEach(delay => { + expect(delay).toBeGreaterThanOrEqual(900); // ~1s with tolerance + }); + }); + + it('should handle async functions', async () => { + const mockFn = mock(async () => { + await new Promise(resolve => setTimeout(resolve, 10)); + return 'async result'; + }); + + const result = await queue.enqueue(mockFn); + expect(result).toBe('async result'); + expect(mockFn).toHaveBeenCalledTimes(1); + }); + + it('should propagate errors', async () => { + const error = new Error('test error'); + const mockFn = mock(async () => { + throw error; + }); + + await expect(queue.enqueue(mockFn)).rejects.toThrow('test error'); + expect(mockFn).toHaveBeenCalledTimes(1); + }); + + it('should track last request time', async () => { + const before = Date.now(); + await queue.enqueue(async () => {}); + const after = Date.now(); + + expect(queue['lastRequestTime']).toBeGreaterThanOrEqual(before); + expect(queue['lastRequestTime']).toBeLessThanOrEqual(after); + }); + }); + + describe('concurrent requests', () => { + it('should handle multiple concurrent enqueue calls', async () => { + const results: string[] = []; + + const promises = [ + queue.enqueue(async () => { results.push('1'); return '1'; }), + queue.enqueue(async () => { results.push('2'); return '2'; }), + queue.enqueue(async () => { results.push('3'); return '3'; }), + ]; + + await Promise.all(promises); + + expect(results).toHaveLength(3); + expect(results).toContain('1'); + expect(results).toContain('2'); + expect(results).toContain('3'); + }); + + it('should maintain order for sequential requests', async () => { + const order: number[] = []; + + await queue.enqueue(async () => order.push(1)); + await queue.enqueue(async () => order.push(2)); + await queue.enqueue(async () => order.push(3)); + + expect(order).toEqual([1, 2, 3]); + }); + }); + + describe('jitter calculation', () => { + it('should calculate jitter within expected range', () => { + // Access private method for testing + const minJitter = 500; + const maxJitter = 1500; + + for (let i = 0; i < 10; i++) { + const jitter = Math.random() * (maxJitter - minJitter) + minJitter; + expect(jitter).toBeGreaterThanOrEqual(minJitter); + expect(jitter).toBeLessThanOrEqual(maxJitter); + } + }); + }); +}); + +describe('RequestQueue - Edge Cases', () => { + it('should handle very fast functions', async () => { + const queue = new RequestQueue(); + + const start = Date.now(); + await queue.enqueue(async () => {}); + await queue.enqueue(async () => {}); + const end = Date.now(); + + // Total time should be at least MIN_INTERVAL + expect(end - start).toBeGreaterThanOrEqual(900); + }); + + it('should handle functions that take longer than MIN_INTERVAL', async () => { + const queue = new RequestQueue(); + + const start = Date.now(); + await queue.enqueue(async () => { + await new Promise(resolve => setTimeout(resolve, 1500)); + }); + await queue.enqueue(async () => {}); + const end = Date.now(); + + // Second request should execute immediately since first took > MIN_INTERVAL + expect(end - start).toBeGreaterThanOrEqual(1500); + }); + + it('should handle errors without breaking queue', async () => { + const queue = new RequestQueue(); + + // First request fails + await expect(queue.enqueue(async () => { + throw new Error('fail'); + })).rejects.toThrow('fail'); + + // Second request should still work + const result = await queue.enqueue(async () => 'success'); + expect(result).toBe('success'); + }); +}); diff --git a/tests/unit/token-manager.test.ts b/tests/unit/token-manager.test.ts new file mode 100644 index 0000000..8e5520f --- /dev/null +++ b/tests/unit/token-manager.test.ts @@ -0,0 +1,85 @@ +/** + * Tests for Token Manager + */ + +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import { TokenManager, tokenManager } from '../../src/plugin/token-manager.js'; +import type { QwenCredentials } from '../../src/types.js'; + +// Mock credentials for testing +const mockCredentials: QwenCredentials = { + accessToken: 'mock_access_token_12345', + tokenType: 'Bearer', + refreshToken: 'mock_refresh_token_67890', + resourceUrl: 'https://dashscope.aliyuncs.com', + expiryDate: Date.now() + 3600000, // 1 hour from now + scope: 'openid profile email model.completion', +}; + +const expiredCredentials: QwenCredentials = { + ...mockCredentials, + expiryDate: Date.now() - 3600000, // 1 hour ago +}; + +describe('TokenManager', () => { + let tokenManagerInstance: TokenManager; + + beforeEach(() => { + tokenManagerInstance = new TokenManager(); + }); + + afterEach(() => { + tokenManagerInstance.clearCache(); + }); + + describe('constructor', () => { + it('should create instance', () => { + expect(tokenManagerInstance).toBeInstanceOf(TokenManager); + }); + }); + + describe('singleton', () => { + it('should export singleton instance', () => { + expect(tokenManager).toBeDefined(); + expect(tokenManager).toBeInstanceOf(TokenManager); + }); + }); + + describe('clearCache', () => { + it('should clear cache without errors', () => { + expect(() => tokenManagerInstance.clearCache()).not.toThrow(); + }); + }); + + describe('clearCache', () => { + it('should clear cache without errors', () => { + expect(() => tokenManagerInstance.clearCache()).not.toThrow(); + }); + + it('should clear credentials from singleton', () => { + tokenManager.clearCache(); + // After clearing, singleton should have empty cache + expect(tokenManager).toBeDefined(); + }); + }); +}); + +describe('TokenManager - Edge Cases', () => { + let tokenManagerInstance: TokenManager; + + beforeEach(() => { + tokenManagerInstance = new TokenManager(); + }); + + afterEach(() => { + tokenManagerInstance.clearCache(); + }); + + it('should handle multiple clearCache calls', () => { + expect(() => { + tokenManagerInstance.clearCache(); + tokenManagerInstance.clearCache(); + tokenManagerInstance.clearCache(); + }).not.toThrow(); + }); +});