merge: integrate upstream/main (v1.2.15) into feature/webui
- Resolved conflict in src/constants.js: kept config-driven approach - Adopted upstream 10-second cooldown default - Added MAX_EMPTY_RESPONSE_RETRIES constant from upstream - Incorporated new test files and GitHub issue templates
This commit is contained in:
@@ -14,7 +14,10 @@ const tests = [
|
||||
{ name: 'Multi-turn Tools (Streaming)', file: 'test-multiturn-thinking-tools-streaming.cjs' },
|
||||
{ name: 'Interleaved Thinking', file: 'test-interleaved-thinking.cjs' },
|
||||
{ name: 'Image Support', file: 'test-images.cjs' },
|
||||
{ name: 'Prompt Caching', file: 'test-caching-streaming.cjs' }
|
||||
{ name: 'Prompt Caching', file: 'test-caching-streaming.cjs' },
|
||||
{ name: 'Cross-Model Thinking', file: 'test-cross-model-thinking.cjs' },
|
||||
{ name: 'OAuth No-Browser Mode', file: 'test-oauth-no-browser.cjs' },
|
||||
{ name: 'Empty Response Retry', file: 'test-empty-response-retry.cjs' }
|
||||
];
|
||||
|
||||
async function runTest(test) {
|
||||
|
||||
114
tests/test-empty-response-retry.cjs
Normal file
114
tests/test-empty-response-retry.cjs
Normal file
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* Test for Empty Response Retry Mechanism
|
||||
*
|
||||
* Tests the retry logic when API returns empty responses
|
||||
* Note: This is a manual/integration test that requires a real proxy server
|
||||
*/
|
||||
|
||||
const { streamRequest } = require('./helpers/http-client.cjs');
|
||||
const { TEST_MODELS } = require('./helpers/test-models.cjs');
|
||||
|
||||
async function testEmptyResponseRetry() {
|
||||
console.log('\n============================================================');
|
||||
console.log('EMPTY RESPONSE RETRY TEST');
|
||||
console.log('Tests retry mechanism for empty API responses');
|
||||
console.log('============================================================\n');
|
||||
|
||||
console.log('Note: This test validates the retry mechanism exists in code');
|
||||
console.log(' Real empty response scenarios require specific API conditions\n');
|
||||
|
||||
try {
|
||||
console.log('TEST 1: Verify retry code exists and compiles');
|
||||
console.log('----------------------------------------');
|
||||
|
||||
// Import the modules to ensure they compile
|
||||
const errors = await import('../src/errors.js');
|
||||
const streamer = await import('../src/cloudcode/sse-streamer.js');
|
||||
const handler = await import('../src/cloudcode/streaming-handler.js');
|
||||
const constants = await import('../src/constants.js');
|
||||
|
||||
console.log(' ✓ EmptyResponseError class exists:', typeof errors.EmptyResponseError === 'function');
|
||||
console.log(' ✓ isEmptyResponseError helper exists:', typeof errors.isEmptyResponseError === 'function');
|
||||
console.log(' ✓ MAX_EMPTY_RESPONSE_RETRIES constant:', constants.MAX_EMPTY_RESPONSE_RETRIES);
|
||||
console.log(' ✓ sse-streamer.js imports EmptyResponseError');
|
||||
console.log(' ✓ streaming-handler.js imports isEmptyResponseError');
|
||||
console.log(' Result: PASS\n');
|
||||
|
||||
console.log('TEST 2: Basic request still works (no regression)');
|
||||
console.log('----------------------------------------');
|
||||
|
||||
const response = await streamRequest({
|
||||
model: TEST_MODELS.gemini,
|
||||
messages: [{ role: 'user', content: 'Say hi in 3 words' }],
|
||||
max_tokens: 20,
|
||||
stream: true
|
||||
});
|
||||
|
||||
console.log(` Response received: ${response.content.length > 0 ? 'YES' : 'NO'}`);
|
||||
console.log(` Content blocks: ${response.content.length}`);
|
||||
console.log(` Events count: ${response.events.length}`);
|
||||
|
||||
if (response.content.length > 0) {
|
||||
console.log(' Result: PASS\n');
|
||||
} else {
|
||||
console.log(' Result: FAIL - No content received\n');
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log('TEST 3: Error class behavior');
|
||||
console.log('----------------------------------------');
|
||||
|
||||
const testError = new errors.EmptyResponseError('Test message');
|
||||
console.log(` Error name: ${testError.name}`);
|
||||
console.log(` Error code: ${testError.code}`);
|
||||
console.log(` Error retryable: ${testError.retryable}`);
|
||||
console.log(` isEmptyResponseError recognizes it: ${errors.isEmptyResponseError(testError)}`);
|
||||
|
||||
const genericError = new Error('Generic error');
|
||||
console.log(` isEmptyResponseError rejects generic: ${!errors.isEmptyResponseError(genericError)}`);
|
||||
|
||||
if (testError.name === 'EmptyResponseError' &&
|
||||
testError.code === 'EMPTY_RESPONSE' &&
|
||||
testError.retryable === true &&
|
||||
errors.isEmptyResponseError(testError) &&
|
||||
!errors.isEmptyResponseError(genericError)) {
|
||||
console.log(' Result: PASS\n');
|
||||
} else {
|
||||
console.log(' Result: FAIL\n');
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log('============================================================');
|
||||
console.log('SUMMARY');
|
||||
console.log('============================================================');
|
||||
console.log(' [PASS] Retry code exists and compiles');
|
||||
console.log(' [PASS] Basic requests work (no regression)');
|
||||
console.log(' [PASS] Error class behavior correct');
|
||||
console.log('\n============================================================');
|
||||
console.log('[EMPTY RESPONSE RETRY] ALL TESTS PASSED');
|
||||
console.log('============================================================\n');
|
||||
|
||||
console.log('Notes:');
|
||||
console.log(' - Retry mechanism is in place and ready');
|
||||
console.log(' - Real empty responses will trigger automatic retry');
|
||||
console.log(' - Check logs for "Empty response, retry X/Y" messages');
|
||||
console.log(' - Production testing shows 88% recovery rate\n');
|
||||
|
||||
return true;
|
||||
|
||||
} catch (error) {
|
||||
console.error('\n[ERROR] Test failed:', error.message);
|
||||
console.error(error.stack);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Run tests
|
||||
testEmptyResponseRetry()
|
||||
.then(success => {
|
||||
process.exit(success ? 0 : 1);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Fatal error:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -106,7 +106,7 @@ Please do this step by step, reading each file before modifying.`
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: `Read src/config.js and tell me if debug mode is enabled.`
|
||||
content: `Analyze the src/config.js file structure and explain the security implications of each setting. What are the potential risks if this config were exposed in production?`
|
||||
},
|
||||
{ role: 'assistant', content: result.content },
|
||||
{
|
||||
|
||||
@@ -74,9 +74,10 @@ async function runTestsForModel(family, model) {
|
||||
|
||||
// For Claude: signature is on thinking block and comes via signature_delta events
|
||||
// For Gemini: signature is on tool_use block (no signature_delta events)
|
||||
// Note: Some models may skip thinking on simple first requests - signature + tool use is key
|
||||
const hasSignature = content.hasSignature || events.signatureDeltas > 0;
|
||||
const passed = content.hasThinking && hasSignature && content.hasToolUse;
|
||||
results.push({ name: 'Turn 1: Thinking + Signature + Tool Use', passed });
|
||||
const passed = hasSignature && content.hasToolUse;
|
||||
results.push({ name: 'Turn 1: Signature + Tool Use', passed });
|
||||
if (!passed) allPassed = false;
|
||||
|
||||
if (content.hasToolUse) {
|
||||
@@ -138,8 +139,10 @@ drwxr-xr-x 4 user staff 128 Dec 19 10:00 tests`
|
||||
console.log(` Response: "${content.text[0].text.substring(0, 100)}..."`);
|
||||
}
|
||||
|
||||
const passed = content.hasThinking && content.hasText && events.textDeltas > 0;
|
||||
results.push({ name: 'Turn 2: Thinking + Text response', passed });
|
||||
// Text or tool use response is acceptable
|
||||
// Note: Models may skip thinking on obvious responses - this is valid behavior
|
||||
const passed = (content.hasText && events.textDeltas > 0) || content.hasToolUse;
|
||||
results.push({ name: 'Turn 2: Text or Tool response', passed });
|
||||
if (!passed) allPassed = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,7 +96,7 @@ async function runTestsForModel(family, model) {
|
||||
content: [{
|
||||
type: 'tool_result',
|
||||
tool_use_id: toolUseBlock.id,
|
||||
content: 'Found files:\n- /project/package.json\n- /project/packages/core/package.json'
|
||||
content: 'Found files:\n- /project/package.json (root, 2.3KB, modified 2 days ago)\n- /project/packages/core/package.json (workspace, 1.1KB, modified 1 hour ago)\n- /project/packages/legacy/package.json (deprecated, 0.8KB, modified 1 year ago)\n- /project/node_modules/lodash/package.json (dependency, 3.2KB)\n\nIMPORTANT: Before proceeding, reason through which files are most relevant. Consider: Are node_modules relevant? Should deprecated packages be included? Which workspace packages matter for the user\'s question about dependencies?'
|
||||
}]
|
||||
});
|
||||
|
||||
@@ -128,10 +128,10 @@ async function runTestsForModel(family, model) {
|
||||
}
|
||||
|
||||
// Either tool use (to read file) or text response is acceptable
|
||||
const passed = expectThinking
|
||||
? (analysis.hasThinking && (analysis.hasToolUse || analysis.hasText))
|
||||
: (analysis.hasToolUse || analysis.hasText);
|
||||
results.push({ name: 'Turn 2: Thinking + (Tool or Text)', passed });
|
||||
// Note: Claude may skip thinking on obvious next steps - this is valid behavior
|
||||
// We only require thinking on the first turn to verify signatures work
|
||||
const passed = analysis.hasToolUse || analysis.hasText;
|
||||
results.push({ name: 'Turn 2: Tool or Text response', passed });
|
||||
if (!passed) allPassed = false;
|
||||
|
||||
if (analysis.hasToolUse) {
|
||||
|
||||
217
tests/test-oauth-no-browser.cjs
Normal file
217
tests/test-oauth-no-browser.cjs
Normal file
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* OAuth No-Browser Mode Unit Tests
|
||||
*
|
||||
* Tests the extractCodeFromInput() function which enables OAuth authentication
|
||||
* on headless servers without a desktop environment.
|
||||
*
|
||||
* ============================================================================
|
||||
* FEATURE: --no-browser OAuth Mode
|
||||
* ============================================================================
|
||||
*
|
||||
* PURPOSE:
|
||||
* Allow users to add Google accounts on remote servers (headless Linux,
|
||||
* Docker containers, SSH sessions) where automatic browser opening is
|
||||
* not possible.
|
||||
*
|
||||
* USAGE:
|
||||
* npm run accounts:add -- --no-browser
|
||||
*
|
||||
* USER FLOW:
|
||||
* 1. User runs command on headless server
|
||||
* 2. System displays Google OAuth URL
|
||||
* 3. User opens URL on another device (phone/laptop) with a browser
|
||||
* 4. User signs in to Google and authorizes the app
|
||||
* 5. Browser redirects to localhost (page won't load - this is expected)
|
||||
* 6. User copies the redirect URL or authorization code from address bar
|
||||
* 7. User pastes into server terminal
|
||||
* 8. System extracts code using extractCodeFromInput() (tested here)
|
||||
* 9. Account is added successfully
|
||||
*
|
||||
* FUNCTION UNDER TEST:
|
||||
* extractCodeFromInput(input: string) => { code: string, state: string|null }
|
||||
*
|
||||
* Accepts either:
|
||||
* - Full callback URL: http://localhost:51121/callback?code=xxx&state=yyy
|
||||
* - Raw authorization code: 4/0AQSTgQG...
|
||||
*
|
||||
* Throws on:
|
||||
* - Empty/null input
|
||||
* - Too short input (< 10 chars)
|
||||
* - URL with OAuth error parameter
|
||||
* - URL without code parameter
|
||||
*
|
||||
* ============================================================================
|
||||
*
|
||||
* Run: node tests/test-oauth-no-browser.cjs
|
||||
*/
|
||||
|
||||
// Note: Using dynamic import because oauth.js is ESM
|
||||
async function runTests() {
|
||||
console.log('='.repeat(60));
|
||||
console.log('OAUTH NO-BROWSER MODE UNIT TESTS');
|
||||
console.log('Testing: extractCodeFromInput()');
|
||||
console.log('='.repeat(60));
|
||||
console.log('');
|
||||
|
||||
// Import the ESM module
|
||||
const { extractCodeFromInput } = await import('../src/auth/oauth.js');
|
||||
|
||||
let allPassed = true;
|
||||
const results = [];
|
||||
|
||||
/**
|
||||
* Helper to run a single test case
|
||||
* @param {string} name - Test name
|
||||
* @param {Function} testFn - Test function that returns { passed, message }
|
||||
*/
|
||||
async function test(name, testFn) {
|
||||
try {
|
||||
const { passed, message } = await testFn();
|
||||
results.push({ name, passed, message });
|
||||
const status = passed ? 'PASS' : 'FAIL';
|
||||
console.log(` [${status}] ${name}`);
|
||||
if (message) console.log(` ${message}`);
|
||||
if (!passed) allPassed = false;
|
||||
} catch (error) {
|
||||
results.push({ name, passed: false, message: error.message });
|
||||
console.log(` [FAIL] ${name}`);
|
||||
console.log(` Error: ${error.message}`);
|
||||
allPassed = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Test Group 1: Valid URL Inputs =====
|
||||
console.log('\n--- Valid URL Inputs ---');
|
||||
|
||||
await test('Parse full callback URL with code and state', () => {
|
||||
const input = 'http://localhost:51121/oauth-callback?code=4/0AQSTg123&state=abc123';
|
||||
const result = extractCodeFromInput(input);
|
||||
const passed = result.code === '4/0AQSTg123' && result.state === 'abc123';
|
||||
return { passed, message: `code=${result.code}, state=${result.state}` };
|
||||
});
|
||||
|
||||
await test('Parse URL with only code (no state)', () => {
|
||||
const input = 'http://localhost:51121/oauth-callback?code=4/0AQSTg456';
|
||||
const result = extractCodeFromInput(input);
|
||||
const passed = result.code === '4/0AQSTg456' && result.state === null;
|
||||
return { passed, message: `code=${result.code}, state=${result.state}` };
|
||||
});
|
||||
|
||||
await test('Parse HTTPS URL', () => {
|
||||
const input = 'https://localhost:51121/callback?code=secureCode123&state=xyz';
|
||||
const result = extractCodeFromInput(input);
|
||||
const passed = result.code === 'secureCode123';
|
||||
return { passed, message: `code=${result.code}` };
|
||||
});
|
||||
|
||||
await test('Parse URL with additional query params', () => {
|
||||
const input = 'http://localhost:51121/?code=myCode&state=myState&scope=email';
|
||||
const result = extractCodeFromInput(input);
|
||||
const passed = result.code === 'myCode' && result.state === 'myState';
|
||||
return { passed, message: `code=${result.code}, state=${result.state}` };
|
||||
});
|
||||
|
||||
// ===== Test Group 2: Raw Code Inputs =====
|
||||
console.log('\n--- Raw Authorization Code Inputs ---');
|
||||
|
||||
await test('Parse raw authorization code (Google format)', () => {
|
||||
const input = '4/0AQSTgQGxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx';
|
||||
const result = extractCodeFromInput(input);
|
||||
const passed = result.code === input && result.state === null;
|
||||
return { passed, message: `code length=${result.code.length}` };
|
||||
});
|
||||
|
||||
await test('Parse raw code with whitespace (should trim)', () => {
|
||||
const input = ' 4/0AQSTgQGcode123 \n';
|
||||
const result = extractCodeFromInput(input);
|
||||
const passed = result.code === '4/0AQSTgQGcode123' && result.state === null;
|
||||
return { passed, message: `trimmed code=${result.code}` };
|
||||
});
|
||||
|
||||
// ===== Test Group 3: Error Cases =====
|
||||
console.log('\n--- Error Handling ---');
|
||||
|
||||
await test('Throw on empty input', () => {
|
||||
try {
|
||||
extractCodeFromInput('');
|
||||
return { passed: false, message: 'Should have thrown' };
|
||||
} catch (e) {
|
||||
return { passed: e.message.includes('No input'), message: e.message };
|
||||
}
|
||||
});
|
||||
|
||||
await test('Throw on null input', () => {
|
||||
try {
|
||||
extractCodeFromInput(null);
|
||||
return { passed: false, message: 'Should have thrown' };
|
||||
} catch (e) {
|
||||
return { passed: e.message.includes('No input'), message: e.message };
|
||||
}
|
||||
});
|
||||
|
||||
await test('Throw on too short code', () => {
|
||||
try {
|
||||
extractCodeFromInput('abc');
|
||||
return { passed: false, message: 'Should have thrown' };
|
||||
} catch (e) {
|
||||
return { passed: e.message.includes('too short'), message: e.message };
|
||||
}
|
||||
});
|
||||
|
||||
await test('Throw on OAuth error in URL', () => {
|
||||
try {
|
||||
const input = 'http://localhost:51121/?error=access_denied&error_description=User%20denied';
|
||||
extractCodeFromInput(input);
|
||||
return { passed: false, message: 'Should have thrown' };
|
||||
} catch (e) {
|
||||
return { passed: e.message.includes('OAuth error'), message: e.message };
|
||||
}
|
||||
});
|
||||
|
||||
await test('Throw on URL without code param', () => {
|
||||
try {
|
||||
extractCodeFromInput('http://localhost:51121/callback?state=onlyState');
|
||||
return { passed: false, message: 'Should have thrown' };
|
||||
} catch (e) {
|
||||
return { passed: e.message.includes('No authorization code'), message: e.message };
|
||||
}
|
||||
});
|
||||
|
||||
// ===== Test Group 4: Edge Cases =====
|
||||
console.log('\n--- Edge Cases ---');
|
||||
|
||||
await test('Handle URL-encoded characters in code', () => {
|
||||
const input = 'http://localhost:51121/?code=4%2F0AQSTg%2B%2B&state=test';
|
||||
const result = extractCodeFromInput(input);
|
||||
// URL class automatically decodes
|
||||
const passed = result.code === '4/0AQSTg++';
|
||||
return { passed, message: `decoded code=${result.code}` };
|
||||
});
|
||||
|
||||
await test('Accept minimum valid code length (10 chars)', () => {
|
||||
const input = '1234567890';
|
||||
const result = extractCodeFromInput(input);
|
||||
const passed = result.code === input;
|
||||
return { passed, message: `code=${result.code}` };
|
||||
});
|
||||
|
||||
// ===== Summary =====
|
||||
console.log('\n' + '='.repeat(60));
|
||||
console.log('SUMMARY');
|
||||
console.log('='.repeat(60));
|
||||
|
||||
const passed = results.filter(r => r.passed).length;
|
||||
const failed = results.filter(r => !r.passed).length;
|
||||
console.log(` Total: ${results.length} | Passed: ${passed} | Failed: ${failed}`);
|
||||
|
||||
console.log('\n' + '='.repeat(60));
|
||||
console.log(allPassed ? 'ALL TESTS PASSED' : 'SOME TESTS FAILED');
|
||||
console.log('='.repeat(60));
|
||||
|
||||
process.exit(allPassed ? 0 : 1);
|
||||
}
|
||||
|
||||
runTests().catch(err => {
|
||||
console.error('Test suite failed:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user