refactor: centralize constants, add error classes, and DRY test utilities

- Create src/errors.js with custom error classes (RateLimitError, AuthError, ApiError, etc.)
- Create src/utils/helpers.js with shared utilities (formatDuration, sleep)
- Create tests/helpers/http-client.cjs with shared test utilities (~250 lines deduped)
- Centralize OAuth config and other constants in src/constants.js
- Add JSDoc types to all major exported functions
- Refactor all test files to use shared http-client utilities
- Update CLAUDE.md with new architecture documentation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Badri Narayanan S
2025-12-24 18:11:45 +05:30
parent 712da8f7f2
commit 0edc718672
16 changed files with 641 additions and 626 deletions

View File

@@ -0,0 +1,260 @@
/**
* Shared Test HTTP Client Utilities
*
* Provides common HTTP request functions for integration tests.
* Eliminates code duplication across test files.
*/
const http = require('http');
// Server configuration
const BASE_URL = 'localhost';
const PORT = 8080;
/**
* Make a streaming SSE request to the API
* @param {Object} body - Request body
* @returns {Promise<{content: Array, events: Array, statusCode: number, raw: string}>}
*/
function streamRequest(body) {
return new Promise((resolve, reject) => {
const data = JSON.stringify(body);
const req = http.request({
host: BASE_URL,
port: PORT,
path: '/v1/messages',
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': 'test',
'anthropic-version': '2023-06-01',
'anthropic-beta': 'interleaved-thinking-2025-05-14',
'Content-Length': Buffer.byteLength(data)
}
}, res => {
const events = [];
let fullData = '';
res.on('data', chunk => {
fullData += chunk.toString();
});
res.on('end', () => {
// Parse SSE events
const parts = fullData.split('\n\n').filter(e => e.trim());
for (const part of parts) {
const lines = part.split('\n');
const eventLine = lines.find(l => l.startsWith('event:'));
const dataLine = lines.find(l => l.startsWith('data:'));
if (eventLine && dataLine) {
try {
const eventType = eventLine.replace('event:', '').trim();
const eventData = JSON.parse(dataLine.replace('data:', '').trim());
events.push({ type: eventType, data: eventData });
} catch (e) { }
}
}
// Build content from events
const content = [];
let currentBlock = null;
for (const event of events) {
if (event.type === 'content_block_start') {
currentBlock = { ...event.data.content_block };
if (currentBlock.type === 'thinking') {
currentBlock.thinking = '';
currentBlock.signature = '';
}
if (currentBlock.type === 'text') currentBlock.text = '';
} else if (event.type === 'content_block_delta') {
const delta = event.data.delta;
if (delta.type === 'thinking_delta' && currentBlock) {
currentBlock.thinking += delta.thinking || '';
}
if (delta.type === 'signature_delta' && currentBlock) {
currentBlock.signature += delta.signature || '';
}
if (delta.type === 'text_delta' && currentBlock) {
currentBlock.text += delta.text || '';
}
if (delta.type === 'input_json_delta' && currentBlock) {
currentBlock.partial_json = (currentBlock.partial_json || '') + delta.partial_json;
}
} else if (event.type === 'content_block_stop') {
if (currentBlock?.type === 'tool_use' && currentBlock.partial_json) {
try { currentBlock.input = JSON.parse(currentBlock.partial_json); } catch (e) { }
delete currentBlock.partial_json;
}
if (currentBlock) content.push(currentBlock);
currentBlock = null;
}
}
resolve({ content, events, statusCode: res.statusCode, raw: fullData });
});
});
req.on('error', reject);
req.write(data);
req.end();
});
}
/**
* Make a non-streaming JSON request to the API
* @param {Object} body - Request body
* @returns {Promise<Object>} - Parsed JSON response with statusCode
*/
function makeRequest(body) {
return new Promise((resolve, reject) => {
const data = JSON.stringify(body);
const req = http.request({
host: BASE_URL,
port: PORT,
path: '/v1/messages',
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': 'test',
'anthropic-version': '2023-06-01',
'anthropic-beta': 'interleaved-thinking-2025-05-14',
'Content-Length': Buffer.byteLength(data)
}
}, res => {
let fullData = '';
res.on('data', chunk => fullData += chunk.toString());
res.on('end', () => {
try {
const parsed = JSON.parse(fullData);
resolve({ ...parsed, statusCode: res.statusCode });
} catch (e) {
reject(new Error(`Parse error: ${e.message}\nRaw: ${fullData.substring(0, 500)}`));
}
});
});
req.on('error', reject);
req.write(data);
req.end();
});
}
/**
* Analyze content blocks from a response
* @param {Array} content - Array of content blocks
* @returns {Object} - Analysis results
*/
function analyzeContent(content) {
const thinking = content.filter(b => b.type === 'thinking');
const toolUse = content.filter(b => b.type === 'tool_use');
const text = content.filter(b => b.type === 'text');
return {
thinking,
toolUse,
text,
hasThinking: thinking.length > 0,
hasToolUse: toolUse.length > 0,
hasText: text.length > 0,
thinkingHasSignature: thinking.some(t => t.signature && t.signature.length >= 50)
};
}
/**
* Analyze SSE events from a streaming response
* @param {Array} events - Array of SSE events
* @returns {Object} - Event counts by type
*/
function analyzeEvents(events) {
return {
messageStart: events.filter(e => e.type === 'message_start').length,
blockStart: events.filter(e => e.type === 'content_block_start').length,
blockDelta: events.filter(e => e.type === 'content_block_delta').length,
blockStop: events.filter(e => e.type === 'content_block_stop').length,
messageDelta: events.filter(e => e.type === 'message_delta').length,
messageStop: events.filter(e => e.type === 'message_stop').length,
thinkingDeltas: events.filter(e => e.data?.delta?.type === 'thinking_delta').length,
signatureDeltas: events.filter(e => e.data?.delta?.type === 'signature_delta').length,
textDeltas: events.filter(e => e.data?.delta?.type === 'text_delta').length,
inputJsonDeltas: events.filter(e => e.data?.delta?.type === 'input_json_delta').length
};
}
// Common tool definitions for tests
const commonTools = {
getWeather: {
name: 'get_weather',
description: 'Get the current weather for a location',
input_schema: {
type: 'object',
properties: {
location: { type: 'string', description: 'City name' }
},
required: ['location']
}
},
searchFiles: {
name: 'search_files',
description: 'Search for files matching a pattern',
input_schema: {
type: 'object',
properties: {
pattern: { type: 'string', description: 'Glob pattern to search' },
path: { type: 'string', description: 'Directory to search in' }
},
required: ['pattern']
}
},
readFile: {
name: 'read_file',
description: 'Read contents of a file',
input_schema: {
type: 'object',
properties: {
path: { type: 'string', description: 'Path to file' }
},
required: ['path']
}
},
executeCommand: {
name: 'execute_command',
description: 'Execute a shell command',
input_schema: {
type: 'object',
properties: {
command: { type: 'string', description: 'Command to execute' },
cwd: { type: 'string', description: 'Working directory' }
},
required: ['command']
}
},
writeFile: {
name: 'write_file',
description: 'Write to a file',
input_schema: {
type: 'object',
properties: {
path: { type: 'string' },
content: { type: 'string' }
},
required: ['path', 'content']
}
},
runTests: {
name: 'run_tests',
description: 'Run test suite',
input_schema: {
type: 'object',
properties: { pattern: { type: 'string' } },
required: ['pattern']
}
}
};
module.exports = {
BASE_URL,
PORT,
streamRequest,
makeRequest,
analyzeContent,
analyzeEvents,
commonTools
};

View File

@@ -4,97 +4,14 @@
* Tests that images can be sent to the API with thinking models.
* Simulates Claude Code sending screenshots or images for analysis.
*/
const http = require('http');
const fs = require('fs');
const path = require('path');
const BASE_URL = 'localhost';
const PORT = 8080;
const { streamRequest } = require('./helpers/http-client.cjs');
// Load test image from disk
const TEST_IMAGE_PATH = path.join(__dirname, 'utils', 'test_image.jpeg');
const TEST_IMAGE_BASE64 = fs.readFileSync(TEST_IMAGE_PATH).toString('base64');
function streamRequest(body) {
return new Promise((resolve, reject) => {
const data = JSON.stringify(body);
const req = http.request({
host: BASE_URL,
port: PORT,
path: '/v1/messages',
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': 'test',
'anthropic-version': '2023-06-01',
'anthropic-beta': 'interleaved-thinking-2025-05-14',
'Content-Length': Buffer.byteLength(data)
}
}, res => {
const events = [];
let fullData = '';
res.on('data', chunk => {
fullData += chunk.toString();
});
res.on('end', () => {
const parts = fullData.split('\n\n').filter(e => e.trim());
for (const part of parts) {
const lines = part.split('\n');
const eventLine = lines.find(l => l.startsWith('event:'));
const dataLine = lines.find(l => l.startsWith('data:'));
if (eventLine && dataLine) {
try {
const eventType = eventLine.replace('event:', '').trim();
const eventData = JSON.parse(dataLine.replace('data:', '').trim());
events.push({ type: eventType, data: eventData });
} catch (e) { }
}
}
const content = [];
let currentBlock = null;
for (const event of events) {
if (event.type === 'content_block_start') {
currentBlock = { ...event.data.content_block };
if (currentBlock.type === 'thinking') {
currentBlock.thinking = '';
currentBlock.signature = '';
}
if (currentBlock.type === 'text') currentBlock.text = '';
} else if (event.type === 'content_block_delta') {
const delta = event.data.delta;
if (delta.type === 'thinking_delta' && currentBlock) {
currentBlock.thinking += delta.thinking || '';
}
if (delta.type === 'signature_delta' && currentBlock) {
currentBlock.signature += delta.signature || '';
}
if (delta.type === 'text_delta' && currentBlock) {
currentBlock.text += delta.text || '';
}
} else if (event.type === 'content_block_stop') {
if (currentBlock) content.push(currentBlock);
currentBlock = null;
}
}
const errorEvent = events.find(e => e.type === 'error');
if (errorEvent) {
resolve({ content, events, error: errorEvent.data.error, statusCode: res.statusCode });
} else {
resolve({ content, events, statusCode: res.statusCode });
}
});
});
req.on('error', reject);
req.write(data);
req.end();
});
}
async function runTests() {
console.log('='.repeat(60));
console.log('IMAGE SUPPORT TEST');

View File

@@ -9,127 +9,10 @@
* This simulates complex Claude Code scenarios where the model
* thinks multiple times during a single turn.
*/
const http = require('http');
const BASE_URL = 'localhost';
const PORT = 8080;
function streamRequest(body) {
return new Promise((resolve, reject) => {
const data = JSON.stringify(body);
const req = http.request({
host: BASE_URL,
port: PORT,
path: '/v1/messages',
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': 'test',
'anthropic-version': '2023-06-01',
'anthropic-beta': 'interleaved-thinking-2025-05-14',
'Content-Length': Buffer.byteLength(data)
}
}, res => {
const events = [];
let fullData = '';
res.on('data', chunk => {
fullData += chunk.toString();
});
res.on('end', () => {
const parts = fullData.split('\n\n').filter(e => e.trim());
for (const part of parts) {
const lines = part.split('\n');
const eventLine = lines.find(l => l.startsWith('event:'));
const dataLine = lines.find(l => l.startsWith('data:'));
if (eventLine && dataLine) {
try {
const eventType = eventLine.replace('event:', '').trim();
const eventData = JSON.parse(dataLine.replace('data:', '').trim());
events.push({ type: eventType, data: eventData });
} catch (e) { }
}
}
const content = [];
let currentBlock = null;
for (const event of events) {
if (event.type === 'content_block_start') {
currentBlock = { ...event.data.content_block };
if (currentBlock.type === 'thinking') {
currentBlock.thinking = '';
currentBlock.signature = '';
}
if (currentBlock.type === 'text') currentBlock.text = '';
} else if (event.type === 'content_block_delta') {
const delta = event.data.delta;
if (delta.type === 'thinking_delta' && currentBlock) {
currentBlock.thinking += delta.thinking || '';
}
if (delta.type === 'signature_delta' && currentBlock) {
currentBlock.signature += delta.signature || '';
}
if (delta.type === 'text_delta' && currentBlock) {
currentBlock.text += delta.text || '';
}
if (delta.type === 'input_json_delta' && currentBlock) {
currentBlock.partial_json = (currentBlock.partial_json || '') + delta.partial_json;
}
} else if (event.type === 'content_block_stop') {
if (currentBlock?.type === 'tool_use' && currentBlock.partial_json) {
try { currentBlock.input = JSON.parse(currentBlock.partial_json); } catch (e) { }
delete currentBlock.partial_json;
}
if (currentBlock) content.push(currentBlock);
currentBlock = null;
}
}
const errorEvent = events.find(e => e.type === 'error');
if (errorEvent) {
resolve({ content, events, error: errorEvent.data.error, statusCode: res.statusCode });
} else {
resolve({ content, events, statusCode: res.statusCode });
}
});
});
req.on('error', reject);
req.write(data);
req.end();
});
}
const { streamRequest, commonTools } = require('./helpers/http-client.cjs');
// Multiple tools to encourage interleaved thinking
const tools = [{
name: 'read_file',
description: 'Read a file',
input_schema: {
type: 'object',
properties: { path: { type: 'string' } },
required: ['path']
}
}, {
name: 'write_file',
description: 'Write to a file',
input_schema: {
type: 'object',
properties: {
path: { type: 'string' },
content: { type: 'string' }
},
required: ['path', 'content']
}
}, {
name: 'run_tests',
description: 'Run test suite',
input_schema: {
type: 'object',
properties: { pattern: { type: 'string' } },
required: ['pattern']
}
}];
const tools = [commonTools.readFile, commonTools.writeFile, commonTools.runTests];
async function runTests() {
console.log('='.repeat(60));

View File

@@ -7,150 +7,9 @@
* - signature_delta events are present
* - Thinking blocks accumulate correctly across deltas
*/
const http = require('http');
const { streamRequest, analyzeContent, analyzeEvents, commonTools } = require('./helpers/http-client.cjs');
const BASE_URL = 'localhost';
const PORT = 8080;
function streamRequest(body) {
return new Promise((resolve, reject) => {
const data = JSON.stringify(body);
const req = http.request({
host: BASE_URL,
port: PORT,
path: '/v1/messages',
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': 'test',
'anthropic-version': '2023-06-01',
'anthropic-beta': 'interleaved-thinking-2025-05-14',
'Content-Length': Buffer.byteLength(data)
}
}, res => {
const events = [];
let fullData = '';
res.on('data', chunk => {
fullData += chunk.toString();
});
res.on('end', () => {
// Parse SSE events
const parts = fullData.split('\n\n').filter(e => e.trim());
for (const part of parts) {
const lines = part.split('\n');
const eventLine = lines.find(l => l.startsWith('event:'));
const dataLine = lines.find(l => l.startsWith('data:'));
if (eventLine && dataLine) {
try {
const eventType = eventLine.replace('event:', '').trim();
const eventData = JSON.parse(dataLine.replace('data:', '').trim());
events.push({ type: eventType, data: eventData });
} catch (e) { }
}
}
// Build content from events
const content = [];
let currentBlock = null;
for (const event of events) {
if (event.type === 'content_block_start') {
currentBlock = { ...event.data.content_block };
if (currentBlock.type === 'thinking') {
currentBlock.thinking = '';
currentBlock.signature = '';
}
if (currentBlock.type === 'text') currentBlock.text = '';
} else if (event.type === 'content_block_delta') {
const delta = event.data.delta;
if (delta.type === 'thinking_delta' && currentBlock) {
currentBlock.thinking += delta.thinking || '';
}
if (delta.type === 'signature_delta' && currentBlock) {
currentBlock.signature += delta.signature || '';
}
if (delta.type === 'text_delta' && currentBlock) {
currentBlock.text += delta.text || '';
}
if (delta.type === 'input_json_delta' && currentBlock) {
currentBlock.partial_json = (currentBlock.partial_json || '') + delta.partial_json;
}
} else if (event.type === 'content_block_stop') {
if (currentBlock?.type === 'tool_use' && currentBlock.partial_json) {
try { currentBlock.input = JSON.parse(currentBlock.partial_json); } catch (e) { }
delete currentBlock.partial_json;
}
if (currentBlock) content.push(currentBlock);
currentBlock = null;
}
}
// Check for errors
const errorEvent = events.find(e => e.type === 'error');
if (errorEvent) {
resolve({
content,
events,
error: errorEvent.data.error,
statusCode: res.statusCode,
raw: fullData
});
} else {
resolve({ content, events, statusCode: res.statusCode, raw: fullData });
}
});
});
req.on('error', reject);
req.write(data);
req.end();
});
}
const tools = [{
name: 'execute_command',
description: 'Execute a shell command',
input_schema: {
type: 'object',
properties: {
command: { type: 'string', description: 'Command to execute' },
cwd: { type: 'string', description: 'Working directory' }
},
required: ['command']
}
}];
function analyzeContent(content) {
const thinking = content.filter(b => b.type === 'thinking');
const toolUse = content.filter(b => b.type === 'tool_use');
const text = content.filter(b => b.type === 'text');
return {
thinking,
toolUse,
text,
hasThinking: thinking.length > 0,
hasToolUse: toolUse.length > 0,
hasText: text.length > 0,
thinkingHasSignature: thinking.some(t => t.signature && t.signature.length >= 50)
};
}
function analyzeEvents(events) {
return {
messageStart: events.filter(e => e.type === 'message_start').length,
blockStart: events.filter(e => e.type === 'content_block_start').length,
blockDelta: events.filter(e => e.type === 'content_block_delta').length,
blockStop: events.filter(e => e.type === 'content_block_stop').length,
messageDelta: events.filter(e => e.type === 'message_delta').length,
messageStop: events.filter(e => e.type === 'message_stop').length,
thinkingDeltas: events.filter(e => e.data?.delta?.type === 'thinking_delta').length,
signatureDeltas: events.filter(e => e.data?.delta?.type === 'signature_delta').length,
textDeltas: events.filter(e => e.data?.delta?.type === 'text_delta').length,
inputJsonDeltas: events.filter(e => e.data?.delta?.type === 'input_json_delta').length
};
}
const tools = [commonTools.executeCommand];
async function runTests() {
console.log('='.repeat(60));

View File

@@ -12,82 +12,9 @@
* - Tool use/result flow works correctly
* - Interleaved thinking with tools
*/
const http = require('http');
const { makeRequest, analyzeContent, commonTools } = require('./helpers/http-client.cjs');
const BASE_URL = 'localhost';
const PORT = 8080;
function makeRequest(body) {
return new Promise((resolve, reject) => {
const data = JSON.stringify(body);
const req = http.request({
host: BASE_URL,
port: PORT,
path: '/v1/messages',
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': 'test',
'anthropic-version': '2023-06-01',
'anthropic-beta': 'interleaved-thinking-2025-05-14',
'Content-Length': Buffer.byteLength(data)
}
}, res => {
let fullData = '';
res.on('data', chunk => fullData += chunk.toString());
res.on('end', () => {
try {
const parsed = JSON.parse(fullData);
resolve({ ...parsed, statusCode: res.statusCode });
} catch (e) {
reject(new Error(`Parse error: ${e.message}\nRaw: ${fullData.substring(0, 500)}`));
}
});
});
req.on('error', reject);
req.write(data);
req.end();
});
}
const tools = [{
name: 'search_files',
description: 'Search for files matching a pattern',
input_schema: {
type: 'object',
properties: {
pattern: { type: 'string', description: 'Glob pattern to search' },
path: { type: 'string', description: 'Directory to search in' }
},
required: ['pattern']
}
}, {
name: 'read_file',
description: 'Read contents of a file',
input_schema: {
type: 'object',
properties: {
path: { type: 'string', description: 'Path to file' }
},
required: ['path']
}
}];
function analyzeContent(content) {
const thinking = content.filter(b => b.type === 'thinking');
const toolUse = content.filter(b => b.type === 'tool_use');
const text = content.filter(b => b.type === 'text');
return {
thinking,
toolUse,
text,
hasThinking: thinking.length > 0,
hasToolUse: toolUse.length > 0,
hasText: text.length > 0,
thinkingHasSignature: thinking.some(t => t.signature && t.signature.length >= 50)
};
}
const tools = [commonTools.searchFiles, commonTools.readFile];
async function runTests() {
console.log('='.repeat(60));

View File

@@ -7,106 +7,9 @@
* Claude Code sends assistant messages with thinking blocks that include signatures.
* These signatures must be preserved and sent back to the API.
*/
const http = require('http');
const { streamRequest, commonTools } = require('./helpers/http-client.cjs');
const BASE_URL = 'localhost';
const PORT = 8080;
function streamRequest(body) {
return new Promise((resolve, reject) => {
const data = JSON.stringify(body);
const req = http.request({
host: BASE_URL,
port: PORT,
path: '/v1/messages',
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': 'test',
'anthropic-version': '2023-06-01',
'anthropic-beta': 'interleaved-thinking-2025-05-14',
'Content-Length': Buffer.byteLength(data)
}
}, res => {
const events = [];
let fullData = '';
res.on('data', chunk => {
fullData += chunk.toString();
});
res.on('end', () => {
// Parse SSE events
const parts = fullData.split('\n\n').filter(e => e.trim());
for (const part of parts) {
const lines = part.split('\n');
const eventLine = lines.find(l => l.startsWith('event:'));
const dataLine = lines.find(l => l.startsWith('data:'));
if (eventLine && dataLine) {
try {
const eventType = eventLine.replace('event:', '').trim();
const eventData = JSON.parse(dataLine.replace('data:', '').trim());
events.push({ type: eventType, data: eventData });
} catch (e) { }
}
}
// Build content from events
const content = [];
let currentBlock = null;
for (const event of events) {
if (event.type === 'content_block_start') {
currentBlock = { ...event.data.content_block };
if (currentBlock.type === 'thinking') {
currentBlock.thinking = '';
currentBlock.signature = '';
}
if (currentBlock.type === 'text') currentBlock.text = '';
} else if (event.type === 'content_block_delta') {
const delta = event.data.delta;
if (delta.type === 'thinking_delta' && currentBlock) {
currentBlock.thinking += delta.thinking || '';
}
if (delta.type === 'signature_delta' && currentBlock) {
currentBlock.signature += delta.signature || '';
}
if (delta.type === 'text_delta' && currentBlock) {
currentBlock.text += delta.text || '';
}
if (delta.type === 'input_json_delta' && currentBlock) {
currentBlock.partial_json = (currentBlock.partial_json || '') + delta.partial_json;
}
} else if (event.type === 'content_block_stop') {
if (currentBlock?.type === 'tool_use' && currentBlock.partial_json) {
try { currentBlock.input = JSON.parse(currentBlock.partial_json); } catch (e) { }
delete currentBlock.partial_json;
}
if (currentBlock) content.push(currentBlock);
currentBlock = null;
}
}
resolve({ content, events, statusCode: res.statusCode, raw: fullData });
});
});
req.on('error', reject);
req.write(data);
req.end();
});
}
const tools = [{
name: 'get_weather',
description: 'Get the current weather for a location',
input_schema: {
type: 'object',
properties: {
location: { type: 'string', description: 'City name' }
},
required: ['location']
}
}];
const tools = [commonTools.getWeather];
async function runTests() {
console.log('='.repeat(60));