Files
kuannnn 0fcf66fc22 feat: ClawTap v0.2.0
Interactive Prompts:
- Unified InteractivePrompt type across all 3 adapters (Claude/Codex/Gemini)
- InteractivePromptOverlay component with options, text input, countdown
- Gemini + Codex pane monitors detect tool confirmation, ask user, plan approval
- respondInteractivePrompt routing: permission → respondPermission, options → _selectOption
- Claude AskUserQuestion nested questions[0] structure parsing

Cross-AI Review:
- Client-generated reviewId, removed pendingReview state
- FloatingReviewPanel uses CSS display:none instead of unmount (keeps hooks alive)
- Child review sessions default to YOLO/bypass permission mode
- Send back to parent, send to existing/new review, tab switching, end review
- Collapsed review cards with read-only panel for ended reviews
- Full reconnect support: active + ended reviews restore correctly

AskUserQuestion Tool Card UI:
- Dedicated renderer replaces raw JSON display
- Options shown with selected (green) / unselected (gray) indicators
- Free text answers shown in quoted format with green border
- Collapsed summary: question → answer
- Shared parseAskQuestionInput utility (client + server)
- Historical tool results attached via _result on tool_use blocks

Adapter Fixes:
- Session→adapter mapping persisted in SQLite (survives server restart)
- SESSION_CREATED deferred for pendingRekey adapters (Codex/Gemini)
- session-rekeyed handler sends complete SESSION_CREATED with adapter + cwd
- Gemini: auto-accept folder trust, privacy notice, IDE nudge, YOLO * prompt
- Claude: auto-accept bypass permissions confirmation (v2.1.85+)
- Port fallback (EADDRINUSE → try +1), statusLine shell script wrapper

Other:
- Desktop Enter sends / Shift+Enter newline; Mobile Enter newline
- Strip CLAWTAP_REF marker from session list
- Active sessions tab shows adapter badge
- Rename CLAUDE_UI_PASSWORD → CLAWTAP_PASSWORD

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 14:46:00 +08:00

219 lines
8.1 KiB
JavaScript

#!/usr/bin/env node
/**
* End-to-End Verification Test
*
* Tests the full pipeline: Server → WS → tmux → Claude → hooks → JSONL → WS
*
* Captures a millisecond-precision timeline of ALL events and validates:
* 1. Event ordering (thinking → text-delta → tool events → message-complete → turn-complete)
* 2. Bidirectional sync (Desktop tmux ↔ Mobile WS)
* 3. Hook delivery (tool-start, tool-done from HTTP hooks)
* 4. JSONL-based messages (message-complete)
* 5. Streaming text preview (text-delta from pane capture)
*/
import { spawn } from 'child_process';
import WebSocket from 'ws';
import { readFileSync } from 'fs';
import { join } from 'path';
const PORT = 3458; // Avoid conflict with main server
const PASSWORD = 'test-e2e-' + Date.now();
const PROJECT_DIR = join(import.meta.dirname, '..', '..');
const events = [];
const startTime = Date.now();
function log(source, type, data = '') {
const elapsed = Date.now() - startTime;
const entry = { elapsed, source, type, data: typeof data === 'object' ? JSON.stringify(data).substring(0, 150) : String(data).substring(0, 150) };
events.push(entry);
const dataStr = entry.data ? ` ${entry.data}` : '';
console.log(` T+${String(elapsed).padStart(6)}ms ${source.padEnd(8)} ${type.padEnd(20)}${dataStr}`);
}
function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
async function getToken() {
const res = await fetch(`http://localhost:${PORT}/api/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: PASSWORD }),
});
const data = await res.json();
return data.token;
}
function connectWs(token) {
return new Promise((resolve, reject) => {
const ws = new WebSocket(`ws://localhost:${PORT}/ws?token=${encodeURIComponent(token)}`);
ws.on('open', () => {
log('WS', 'connected');
resolve(ws);
});
ws.on('error', reject);
});
}
async function main() {
console.log('\n=== End-to-End Verification Test ===\n');
// 1. Start server
console.log('Starting server...');
const serverProcess = spawn('node', ['server/index.js'], {
cwd: PROJECT_DIR,
env: { ...process.env, PORT: String(PORT), CLAWTAP_PASSWORD: PASSWORD },
stdio: ['pipe', 'pipe', 'pipe'],
});
let serverReady = false;
serverProcess.stdout.on('data', (d) => {
const text = d.toString();
if (text.includes('running on')) serverReady = true;
});
serverProcess.stderr.on('data', (d) => {
const text = d.toString().trim();
if (text) console.error(` [server stderr] ${text.substring(0, 200)}`);
});
// Wait for server ready
for (let i = 0; i < 30; i++) {
if (serverReady) break;
await sleep(500);
}
if (!serverReady) {
console.error('Server failed to start');
serverProcess.kill();
process.exit(1);
}
log('SERVER', 'ready');
try {
// 2. Authenticate
const token = await getToken();
log('AUTH', 'token_received');
// 3. Connect WebSocket
const ws = await connectWs(token);
const wsMessages = [];
ws.on('message', (raw) => {
const msg = JSON.parse(raw.toString());
wsMessages.push(msg);
log('WS_RECV', msg.type, msg.type === 'text-delta' ? `"${(msg.text || '').substring(0, 80)}..."` :
msg.type === 'message-complete' ? `${msg.messages?.length || 0} messages` :
msg.type === 'tool-start' ? `${msg.toolName}` :
msg.type === 'tool-done' ? `${msg.toolName}` :
msg.type === 'thinking' ? msg.text :
msg.type === 'turn-complete' ? 'ready' :
msg.type === 'session-created' ? msg.sessionId :
msg.type === 'tool-updates' ? `${Object.keys(msg.tools || {}).length} tools` :
'');
});
// 4. Send a query that triggers tool use
const testPrompt = 'Read the file package.json and tell me just the "name" field. Be very brief, one sentence.';
log('SEND', 'query', testPrompt.substring(0, 60));
ws.send(JSON.stringify({
type: 'query',
prompt: testPrompt,
options: { cwd: PROJECT_DIR },
}));
// 5. Wait for response (up to 90 seconds)
let turnComplete = false;
const maxWait = 90000;
const waitStart = Date.now();
while (!turnComplete && Date.now() - waitStart < maxWait) {
await sleep(500);
turnComplete = wsMessages.some(m => m.type === 'turn-complete');
// Also check for message-complete as a fallback signal
if (!turnComplete && Date.now() - waitStart > 60000) {
turnComplete = wsMessages.some(m => m.type === 'message-complete');
}
}
if (!turnComplete) {
log('TIMEOUT', 'no_turn_complete', `waited ${maxWait}ms`);
}
// Wait a bit more for any trailing events
await sleep(2000);
ws.close();
log('WS', 'disconnected');
// 6. Analyze results
console.log('\n\n=== Analysis ===\n');
const sessionCreated = wsMessages.find(m => m.type === 'session-created');
const textDeltas = wsMessages.filter(m => m.type === 'text-delta');
const thinkingMsgs = wsMessages.filter(m => m.type === 'thinking');
const toolStarts = wsMessages.filter(m => m.type === 'tool-start');
const toolDones = wsMessages.filter(m => m.type === 'tool-done');
const messageCompletes = wsMessages.filter(m => m.type === 'message-complete');
const turnCompletes = wsMessages.filter(m => m.type === 'turn-complete');
const toolUpdates = wsMessages.filter(m => m.type === 'tool-updates');
const errors = wsMessages.filter(m => m.type === 'error');
console.log('Event counts:');
console.log(` session-created: ${sessionCreated ? 1 : 0}`);
console.log(` thinking: ${thinkingMsgs.length}`);
console.log(` text-delta: ${textDeltas.length}`);
console.log(` tool-start: ${toolStarts.length} (${toolStarts.map(m => m.toolName).join(', ')})`);
console.log(` tool-done: ${toolDones.length} (${toolDones.map(m => m.toolName).join(', ')})`);
console.log(` tool-updates: ${toolUpdates.length}`);
console.log(` message-complete: ${messageCompletes.length}`);
console.log(` turn-complete: ${turnCompletes.length}`);
console.log(` errors: ${errors.length} ${errors.map(m => m.error).join(', ')}`);
console.log('\n--- Checks ---\n');
const checks = [
{ name: 'Session created', pass: !!sessionCreated },
{ name: 'Got thinking or text-delta (streaming)', pass: thinkingMsgs.length > 0 || textDeltas.length > 0 },
{ name: 'Tool start received (hooks working)', pass: toolStarts.length > 0 },
{ name: 'Tool done received (hooks working)', pass: toolDones.length > 0 },
{ name: 'Tool start before tool done', pass: toolStarts.length > 0 && toolDones.length > 0 &&
events.findIndex(e => e.type === 'tool-start') < events.findIndex(e => e.type === 'tool-done') },
{ name: 'Message complete received (JSONL working)', pass: messageCompletes.length > 0 },
{ name: 'Turn complete received (Stop hook)', pass: turnCompletes.length > 0 },
{ name: 'No errors', pass: errors.length === 0 },
{ name: 'Message complete before turn complete', pass:
messageCompletes.length > 0 && turnCompletes.length > 0 &&
events.findIndex(e => e.type === 'message-complete') < events.findIndex(e => e.type === 'turn-complete') },
];
let allPass = true;
for (const c of checks) {
console.log(` ${c.pass ? '✓' : '✗'} ${c.name}`);
if (!c.pass) allPass = false;
}
// Show message content if available
if (messageCompletes.length > 0) {
const lastMsg = messageCompletes[messageCompletes.length - 1];
const textContent = lastMsg.messages?.flatMap(m =>
(m.content || []).filter(b => b.type === 'text').map(b => b.text)
).join(' ');
if (textContent) {
console.log(`\n Response: "${textContent.substring(0, 200)}"`);
}
}
console.log(`\n${allPass ? '✓ ALL CHECKS PASSED' : '✗ SOME CHECKS FAILED'}\n`);
return allPass;
} finally {
serverProcess.kill();
await sleep(1000);
}
}
main()
.then(pass => process.exit(pass ? 0 : 1))
.catch(err => {
console.error('Test error:', err);
process.exit(1);
});