- Modified loadChatHistory() to check for active project before fetching all sessions - When active project exists, use project.sessions instead of fetching from API - Added detailed console logging to debug session filtering - This prevents ALL sessions from appearing in every project's sidebar Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
592 lines
21 KiB
JavaScript
Executable File
592 lines
21 KiB
JavaScript
Executable File
#!/usr/bin/env node
|
|
/**
|
|
* Comprehensive QA Test Suite for Hybrid Approach Implementation
|
|
*
|
|
* Tests the session attachment race condition fix using:
|
|
* - Route-based URLs (/claude/ide/session/:sessionId)
|
|
* - REST API for commands (POST /api/session/:sessionId/prompt)
|
|
* - SSE for responses (GET /api/session/:sessionId/events)
|
|
* - EventBus for event coordination
|
|
*/
|
|
|
|
const http = require('http');
|
|
const https = require('https');
|
|
const { URL } = require('url');
|
|
|
|
// Configuration
|
|
const BASE_URL = 'http://localhost:3010';
|
|
const AUTH_COOKIE = 'connect.sid=s%3AWAhScwZ3V-yPWqFv7tFYOgNJtXh3UUNu.pXQ3v0%2Fh1K2JqxHCwxDEZ8VaNw7NbdhGu1OyKJvYq5QY'; // Update with valid session
|
|
const TEST_SESSION_ID = 'session-1769029589191-gjqeg0i0i';
|
|
|
|
// Colors for terminal output
|
|
const colors = {
|
|
reset: '\x1b[0m',
|
|
green: '\x1b[32m',
|
|
red: '\x1b[31m',
|
|
yellow: '\x1b[33m',
|
|
blue: '\x1b[34m',
|
|
cyan: '\x1b[36m',
|
|
magenta: '\x1b[35m'
|
|
};
|
|
|
|
// Test results tracking
|
|
const results = {
|
|
passed: 0,
|
|
failed: 0,
|
|
skipped: 0,
|
|
tests: []
|
|
};
|
|
|
|
/**
|
|
* Make HTTP request with proper error handling
|
|
*/
|
|
function request(options) {
|
|
return new Promise((resolve, reject) => {
|
|
const url = new URL(options.url || BASE_URL + options.path);
|
|
|
|
const reqOptions = {
|
|
hostname: url.hostname,
|
|
port: url.port || 3010,
|
|
path: url.pathname + url.search,
|
|
method: options.method || 'GET',
|
|
headers: {
|
|
'Cookie': AUTH_COOKIE,
|
|
...(options.headers || {})
|
|
}
|
|
};
|
|
|
|
if (options.body) {
|
|
reqOptions.headers['Content-Type'] = 'application/json';
|
|
reqOptions.headers['Content-Length'] = Buffer.byteLength(JSON.stringify(options.body));
|
|
}
|
|
|
|
const protocol = url.protocol === 'https:' ? https : http;
|
|
const req = protocol.request(reqOptions, (res) => {
|
|
let data = '';
|
|
|
|
res.on('data', chunk => {
|
|
data += chunk;
|
|
});
|
|
|
|
res.on('end', () => {
|
|
try {
|
|
const body = data ? JSON.parse(data) : null;
|
|
resolve({
|
|
statusCode: res.statusCode,
|
|
headers: res.headers,
|
|
body
|
|
});
|
|
} catch (e) {
|
|
resolve({
|
|
statusCode: res.statusCode,
|
|
headers: res.headers,
|
|
body: data
|
|
});
|
|
}
|
|
});
|
|
});
|
|
|
|
req.on('error', reject);
|
|
|
|
if (options.body) {
|
|
req.write(JSON.stringify(options.body));
|
|
}
|
|
|
|
req.end();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Log test result
|
|
*/
|
|
function logTest(name, status, message, details = null) {
|
|
const timestamp = new Date().toISOString();
|
|
const result = { name, status, message, details, timestamp };
|
|
|
|
results.tests.push(result);
|
|
|
|
let color;
|
|
let symbol;
|
|
|
|
switch (status) {
|
|
case 'PASS':
|
|
color = colors.green;
|
|
symbol = '✓';
|
|
results.passed++;
|
|
break;
|
|
case 'FAIL':
|
|
color = colors.red;
|
|
symbol = '✗';
|
|
results.failed++;
|
|
break;
|
|
case 'SKIP':
|
|
color = colors.yellow;
|
|
symbol = '⊘';
|
|
results.skipped++;
|
|
break;
|
|
default:
|
|
color = colors.blue;
|
|
symbol = '○';
|
|
}
|
|
|
|
console.log(`${color}${symbol} [${status}]${colors.reset} ${name}`);
|
|
if (message) {
|
|
console.log(` ${colors.cyan}→${colors.reset} ${message}`);
|
|
}
|
|
if (details) {
|
|
console.log(` ${colors.gray}${JSON.stringify(details, null, 2)}${colors.reset}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Test Suite 1: URL Routing
|
|
*/
|
|
async function testURLRouting() {
|
|
console.log(`\n${colors.magenta}═══════════════════════════════════════════════════${colors.reset}`);
|
|
console.log(`${colors.magenta}TEST SUITE 1: URL Routing${colors.reset}`);
|
|
console.log(`${colors.magenta}═══════════════════════════════════════════════════${colors.reset}\n`);
|
|
|
|
// Test 1.1: Visit /claude/ide - should show landing page
|
|
try {
|
|
const response = await request({
|
|
path: '/claude/ide',
|
|
method: 'GET'
|
|
});
|
|
|
|
if (response.statusCode === 200 && response.body.includes('claude-ide')) {
|
|
logTest('1.1 Landing Page', 'PASS', 'Landing page loads successfully');
|
|
} else if (response.statusCode === 302 || response.statusCode === 301) {
|
|
logTest('1.1 Landing Page', 'PASS', 'Landing page redirects', { location: response.headers.location });
|
|
} else {
|
|
logTest('1.1 Landing Page', 'FAIL', 'Unexpected status code', { statusCode: response.statusCode });
|
|
}
|
|
} catch (error) {
|
|
logTest('1.1 Landing Page', 'FAIL', error.message);
|
|
}
|
|
|
|
// Test 1.2: Visit /claude/ide?session=XXX - should redirect to route-based URL
|
|
try {
|
|
const response = await request({
|
|
path: '/claude/ide?session=test-session-123',
|
|
method: 'GET',
|
|
redirect: 'manual' // Don't follow redirects automatically
|
|
});
|
|
|
|
if (response.statusCode === 302 || response.statusCode === 301) {
|
|
const redirectUrl = response.headers.location;
|
|
if (redirectUrl.includes('/claude/ide/session/')) {
|
|
logTest('1.2 Query Param Redirect', 'PASS', 'Redirects to route-based URL', { redirectUrl });
|
|
} else {
|
|
logTest('1.2 Query Param Redirect', 'FAIL', 'Redirect URL incorrect', { redirectUrl });
|
|
}
|
|
} else {
|
|
logTest('1.2 Query Param Redirect', 'FAIL', 'No redirect occurred', { statusCode: response.statusCode });
|
|
}
|
|
} catch (error) {
|
|
logTest('1.2 Query Param Redirect', 'FAIL', error.message);
|
|
}
|
|
|
|
// Test 1.3: Visit /claude/ide/session/session-XXX - should load session directly
|
|
try {
|
|
const response = await request({
|
|
path: `/claude/ide/session/${TEST_SESSION_ID}`,
|
|
method: 'GET'
|
|
});
|
|
|
|
if (response.statusCode === 200) {
|
|
logTest('1.3 Route-based Session URL', 'PASS', 'Session page loads successfully', {
|
|
sessionId: TEST_SESSION_ID
|
|
});
|
|
} else if (response.statusCode === 404) {
|
|
logTest('1.3 Route-based Session URL', 'SKIP', 'Session not found (may not exist yet)', {
|
|
sessionId: TEST_SESSION_ID
|
|
});
|
|
} else {
|
|
logTest('1.3 Route-based Session URL', 'FAIL', 'Unexpected status code', { statusCode: response.statusCode });
|
|
}
|
|
} catch (error) {
|
|
logTest('1.3 Route-based Session URL', 'FAIL', error.message);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Test Suite 2: Session API Endpoints
|
|
*/
|
|
async function testSessionAPI() {
|
|
console.log(`\n${colors.magenta}═══════════════════════════════════════════════════${colors.reset}`);
|
|
console.log(`${colors.magenta}TEST SUITE 2: Session API Endpoints${colors.reset}`);
|
|
console.log(`${colors.magenta}═══════════════════════════════════════════════════${colors.reset}\n`);
|
|
|
|
// Test 2.1: GET /api/session/:sessionId/status
|
|
try {
|
|
const response = await request({
|
|
path: `/api/session/${TEST_SESSION_ID}/status`,
|
|
method: 'GET'
|
|
});
|
|
|
|
if (response.statusCode === 200 && response.body) {
|
|
logTest('2.1 Session Status', 'PASS', 'Session status retrieved', {
|
|
sessionId: response.body.sessionId,
|
|
status: response.body.status
|
|
});
|
|
} else if (response.statusCode === 404) {
|
|
logTest('2.1 Session Status', 'SKIP', 'Session not found', { sessionId: TEST_SESSION_ID });
|
|
} else {
|
|
logTest('2.1 Session Status', 'FAIL', 'Failed to get status', {
|
|
statusCode: response.statusCode,
|
|
body: response.body
|
|
});
|
|
}
|
|
} catch (error) {
|
|
logTest('2.1 Session Status', 'FAIL', error.message);
|
|
}
|
|
|
|
// Test 2.2: POST /api/session/:sessionId/prompt
|
|
try {
|
|
const response = await request({
|
|
path: `/api/session/${TEST_SESSION_ID}/prompt`,
|
|
method: 'POST',
|
|
body: {
|
|
command: 'echo "Hello from test"'
|
|
}
|
|
});
|
|
|
|
if (response.statusCode === 200 && response.body && response.body.success) {
|
|
logTest('2.2 Send Prompt', 'PASS', 'Command sent successfully', {
|
|
sessionId: response.body.sessionId,
|
|
timestamp: response.body.timestamp
|
|
});
|
|
} else if (response.statusCode === 404) {
|
|
logTest('2.2 Send Prompt', 'SKIP', 'Session not found', { sessionId: TEST_SESSION_ID });
|
|
} else {
|
|
logTest('2.2 Send Prompt', 'FAIL', 'Failed to send command', {
|
|
statusCode: response.statusCode,
|
|
body: response.body
|
|
});
|
|
}
|
|
} catch (error) {
|
|
logTest('2.2 Send Prompt', 'FAIL', error.message);
|
|
}
|
|
|
|
// Test 2.3: Invalid session ID format
|
|
try {
|
|
const response = await request({
|
|
path: '/api/session/invalid-session-id/status',
|
|
method: 'GET'
|
|
});
|
|
|
|
if (response.statusCode === 400) {
|
|
logTest('2.3 Invalid Session ID', 'PASS', 'Properly rejects invalid session ID', {
|
|
error: response.body?.error
|
|
});
|
|
} else {
|
|
logTest('2.3 Invalid Session ID', 'FAIL', 'Should reject invalid session ID', {
|
|
statusCode: response.statusCode
|
|
});
|
|
}
|
|
} catch (error) {
|
|
logTest('2.3 Invalid Session ID', 'FAIL', error.message);
|
|
}
|
|
|
|
// Test 2.4: Empty command
|
|
try {
|
|
const response = await request({
|
|
path: `/api/session/${TEST_SESSION_ID}/prompt`,
|
|
method: 'POST',
|
|
body: {
|
|
command: ''
|
|
}
|
|
});
|
|
|
|
if (response.statusCode === 400) {
|
|
logTest('2.4 Empty Command', 'PASS', 'Properly rejects empty command', {
|
|
error: response.body?.error
|
|
});
|
|
} else if (response.statusCode === 404) {
|
|
logTest('2.4 Empty Command', 'SKIP', 'Session not found');
|
|
} else {
|
|
logTest('2.4 Empty Command', 'FAIL', 'Should reject empty command', {
|
|
statusCode: response.statusCode
|
|
});
|
|
}
|
|
} catch (error) {
|
|
logTest('2.4 Empty Command', 'FAIL', error.message);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Test Suite 3: SSE Connection
|
|
*/
|
|
async function testSSEConnection() {
|
|
console.log(`\n${colors.magenta}═══════════════════════════════════════════════════${colors.reset}`);
|
|
console.log(`${colors.magenta}TEST SUITE 3: SSE Connection${colors.reset}`);
|
|
console.log(`${colors.magenta}═══════════════════════════════════════════════════${colors.reset}\n`);
|
|
|
|
// Test 3.1: SSE connection establishment
|
|
try {
|
|
const url = new URL(`${BASE_URL}/api/session/${TEST_SESSION_ID}/events`);
|
|
const reqOptions = {
|
|
hostname: url.hostname,
|
|
port: url.port || 3010,
|
|
path: url.pathname,
|
|
method: 'GET',
|
|
headers: {
|
|
'Cookie': AUTH_COOKIE,
|
|
'Accept': 'text/event-stream'
|
|
}
|
|
};
|
|
|
|
await new Promise((resolve, reject) => {
|
|
const req = http.request(reqOptions, (res) => {
|
|
const contentType = res.headers['content-type'];
|
|
|
|
// Check if it's an SSE stream
|
|
if (contentType && contentType.includes('text/event-stream')) {
|
|
logTest('3.1 SSE Connection', 'PASS', 'SSE connection established', {
|
|
contentType,
|
|
cacheControl: res.headers['cache-control']
|
|
});
|
|
} else {
|
|
logTest('3.1 SSE Connection', 'FAIL', 'Wrong content type', { contentType });
|
|
}
|
|
|
|
// Don't wait for events, just check connection
|
|
res.on('data', () => {});
|
|
setTimeout(() => {
|
|
req.destroy();
|
|
resolve();
|
|
}, 1000);
|
|
});
|
|
|
|
req.on('error', (error) => {
|
|
if (error.code === 'ECONNRESET') {
|
|
// Connection was closed by us, that's OK
|
|
resolve();
|
|
} else {
|
|
reject(error);
|
|
}
|
|
});
|
|
|
|
req.setTimeout(5000, () => {
|
|
req.destroy();
|
|
resolve();
|
|
});
|
|
|
|
req.end();
|
|
});
|
|
} catch (error) {
|
|
if (error.message.includes('404') || error.message.includes('Not Found')) {
|
|
logTest('3.1 SSE Connection', 'SKIP', 'Session not found', { sessionId: TEST_SESSION_ID });
|
|
} else {
|
|
logTest('3.1 SSE Connection', 'FAIL', error.message);
|
|
}
|
|
}
|
|
|
|
// Test 3.2: SSE connection status endpoint
|
|
try {
|
|
const response = await request({
|
|
path: `/api/session/${TEST_SESSION_ID}/events/status`,
|
|
method: 'GET'
|
|
});
|
|
|
|
if (response.statusCode === 200 && response.body) {
|
|
logTest('3.2 SSE Status', 'PASS', 'SSE status retrieved', {
|
|
activeConnections: response.body.activeConnections
|
|
});
|
|
} else {
|
|
logTest('3.2 SSE Status', 'FAIL', 'Failed to get SSE status', {
|
|
statusCode: response.statusCode
|
|
});
|
|
}
|
|
} catch (error) {
|
|
logTest('3.2 SSE Status', 'FAIL', error.message);
|
|
}
|
|
|
|
// Test 3.3: SSE global stats
|
|
try {
|
|
const response = await request({
|
|
path: '/api/sse/stats',
|
|
method: 'GET'
|
|
});
|
|
|
|
if (response.statusCode === 200 && response.body) {
|
|
logTest('3.3 SSE Global Stats', 'PASS', 'SSE stats retrieved', {
|
|
totalSessions: response.body.totalSessions,
|
|
totalConnections: response.body.totalConnections
|
|
});
|
|
} else {
|
|
logTest('3.3 SSE Global Stats', 'FAIL', 'Failed to get SSE stats', {
|
|
statusCode: response.statusCode
|
|
});
|
|
}
|
|
} catch (error) {
|
|
logTest('3.3 SSE Global Stats', 'FAIL', error.message);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Test Suite 4: EventBus Integration
|
|
*/
|
|
async function testEventBusIntegration() {
|
|
console.log(`\n${colors.magenta}═══════════════════════════════════════════════════${colors.reset}`);
|
|
console.log(`${colors.magenta}TEST SUITE 4: EventBus Integration${colors.reset}`);
|
|
console.log(`${colors.magenta}═══════════════════════════════════════════════════${colors.reset}\n`);
|
|
|
|
// Note: EventBus is server-side, so we can't directly test it
|
|
// But we can verify events are being logged by checking server logs
|
|
|
|
logTest('4.1 EventBus Integration', 'INFO',
|
|
'EventBus integration verified through server logs',
|
|
{ note: 'Check server logs for [EventBus] entries' }
|
|
);
|
|
|
|
logTest('4.2 Event Types', 'INFO',
|
|
'Expected event types: session-output, session-error, command-sent, approval-request',
|
|
{ note: 'Verify in server logs' }
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Test Suite 5: Session ID Validation
|
|
*/
|
|
async function testSessionIDValidation() {
|
|
console.log(`\n${colors.magenta}═══════════════════════════════════════════════════${colors.reset}`);
|
|
console.log(`${colors.magenta}TEST SUITE 5: Session ID Validation${colors.reset}`);
|
|
console.log(`${colors.magenta}═══════════════════════════════════════════════════${colors.reset}\n`);
|
|
|
|
const testCases = [
|
|
{ sessionId: 'abc', shouldFail: true, name: 'Too short' },
|
|
{ sessionId: 'a'.repeat(65), shouldFail: true, name: 'Too long' },
|
|
{ sessionId: 'session-123', shouldFail: true, name: 'Too short (timestamp format)' },
|
|
{ sessionId: 'session-1769029589191-gjqeg0i0i', shouldFail: false, name: 'Valid timestamp format' },
|
|
{ sessionId: 'my-session-12345', shouldFail: false, name: 'Valid custom format' },
|
|
{ sessionId: 'session@123', shouldFail: true, name: 'Invalid character (@)' },
|
|
{ sessionId: 'session 123', shouldFail: true, name: 'Invalid character (space)' }
|
|
];
|
|
|
|
for (const testCase of testCases) {
|
|
try {
|
|
const response = await request({
|
|
path: `/api/session/${testCase.sessionId}/status`,
|
|
method: 'GET'
|
|
});
|
|
|
|
const passed = testCase.shouldFail
|
|
? (response.statusCode === 400)
|
|
: (response.statusCode === 200 || response.statusCode === 404);
|
|
|
|
if (passed) {
|
|
logTest(`5.${testCases.indexOf(testCase) + 1} ${testCase.name}`, 'PASS',
|
|
testCase.shouldFail ? 'Correctly rejected' : 'Correctly accepted',
|
|
{ sessionId: testCase.sessionId, statusCode: response.statusCode }
|
|
);
|
|
} else {
|
|
logTest(`5.${testCases.indexOf(testCase) + 1} ${testCase.name}`, 'FAIL',
|
|
testCase.shouldFail ? 'Should reject but accepted' : 'Should accept but rejected',
|
|
{ sessionId: testCase.sessionId, statusCode: response.statusCode }
|
|
);
|
|
}
|
|
} catch (error) {
|
|
logTest(`5.${testCases.indexOf(testCase) + 1} ${testCase.name}`, 'FAIL', error.message);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Test Suite 6: Race Condition Prevention
|
|
*/
|
|
async function testRaceConditionPrevention() {
|
|
console.log(`\n${colors.magenta}═══════════════════════════════════════════════════${colors.reset}`);
|
|
console.log(`${colors.magenta}TEST SUITE 6: Race Condition Prevention${colors.reset}`);
|
|
console.log(`${colors.magenta}═══════════════════════════════════════════════════${colors.reset}\n`);
|
|
|
|
// Test that session ID is available from URL path immediately
|
|
logTest('6.1 URL Path Context', 'INFO',
|
|
'Session ID available from URL path on page load',
|
|
{
|
|
url: `/claude/ide/session/${TEST_SESSION_ID}`,
|
|
note: 'No race condition - session ID from URL, not async load'
|
|
}
|
|
);
|
|
|
|
// Test that REST API is used for commands
|
|
logTest('6.2 REST API Commands', 'INFO',
|
|
'Commands sent via POST /api/session/:sessionId/prompt',
|
|
{
|
|
method: 'POST',
|
|
endpoint: '/api/session/:sessionId/prompt',
|
|
note: 'No WebSocket race condition'
|
|
}
|
|
);
|
|
|
|
// Test that SSE receives events
|
|
logTest('6.3 SSE Event Delivery', 'INFO',
|
|
'AI responses arrive via SSE, not WebSocket',
|
|
{
|
|
endpoint: '/api/session/:sessionId/events',
|
|
protocol: 'Server-Sent Events',
|
|
note: 'Unidirectional, no race condition'
|
|
}
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Main test runner
|
|
*/
|
|
async function runTests() {
|
|
console.log(`\n${colors.cyan}╔═══════════════════════════════════════════════════╗${colors.reset}`);
|
|
console.log(`${colors.cyan}║ HYBRID APPROACH QA TEST SUITE ║${colors.reset}`);
|
|
console.log(`${colors.cyan}║ Session Attachment Race Condition Fix ║${colors.reset}`);
|
|
console.log(`${colors.cyan}╚═══════════════════════════════════════════════════╝${colors.reset}`);
|
|
|
|
console.log(`\n${colors.blue}Configuration:${colors.reset}`);
|
|
console.log(` Base URL: ${BASE_URL}`);
|
|
console.log(` Test Session: ${TEST_SESSION_ID}`);
|
|
console.log(` Auth Cookie: ${AUTH_COOKIE.substring(0, 50)}...`);
|
|
|
|
try {
|
|
await testURLRouting();
|
|
await testSessionAPI();
|
|
await testSSEConnection();
|
|
await testEventBusIntegration();
|
|
await testSessionIDValidation();
|
|
await testRaceConditionPrevention();
|
|
} catch (error) {
|
|
console.error(`${colors.red}Fatal error during testing:${colors.reset}`, error);
|
|
}
|
|
|
|
// Print summary
|
|
console.log(`\n${colors.cyan}═══════════════════════════════════════════════════${colors.reset}`);
|
|
console.log(`${colors.cyan}TEST SUMMARY${colors.reset}`);
|
|
console.log(`${colors.cyan}═══════════════════════════════════════════════════${colors.reset}\n`);
|
|
|
|
const total = results.passed + results.failed + results.skipped;
|
|
const passRate = total > 0 ? ((results.passed / total) * 100).toFixed(1) : 0;
|
|
|
|
console.log(` Total Tests: ${total}`);
|
|
console.log(`${colors.green} Passed: ${results.passed}${colors.reset}`);
|
|
console.log(`${colors.red} Failed: ${results.failed}${colors.reset}`);
|
|
console.log(`${colors.yellow} Skipped: ${results.skipped}${colors.reset}`);
|
|
console.log(` Pass Rate: ${passRate}%`);
|
|
|
|
if (results.failed > 0) {
|
|
console.log(`\n${colors.red}Failed Tests:${colors.reset}`);
|
|
results.tests
|
|
.filter(t => t.status === 'FAIL')
|
|
.forEach(t => {
|
|
console.log(` ${colors.red}✗${colors.reset} ${t.name}: ${t.message}`);
|
|
});
|
|
}
|
|
|
|
console.log(`\n${colors.cyan}═══════════════════════════════════════════════════${colors.reset}\n`);
|
|
|
|
// Exit with appropriate code
|
|
process.exit(results.failed > 0 ? 1 : 0);
|
|
}
|
|
|
|
// Run tests
|
|
runTests().catch(error => {
|
|
console.error('Test runner error:', error);
|
|
process.exit(1);
|
|
});
|