server.test.js 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424
  1. /**
  2. * Integration tests for the brainstorm server.
  3. *
  4. * Tests the full server behavior: HTTP serving, WebSocket communication,
  5. * file watching, and the brainstorming workflow.
  6. *
  7. * Uses the `ws` npm package as a test client (test-only dependency,
  8. * not shipped to end users).
  9. */
  10. const { spawn } = require('child_process');
  11. const http = require('http');
  12. const WebSocket = require('ws');
  13. const fs = require('fs');
  14. const path = require('path');
  15. const assert = require('assert');
  16. const SERVER_PATH = path.join(__dirname, '../../skills/brainstorming/scripts/server.js');
  17. const TEST_PORT = 3334;
  18. const TEST_DIR = '/tmp/brainstorm-test';
  19. function cleanup() {
  20. if (fs.existsSync(TEST_DIR)) {
  21. fs.rmSync(TEST_DIR, { recursive: true });
  22. }
  23. }
  24. async function sleep(ms) {
  25. return new Promise(resolve => setTimeout(resolve, ms));
  26. }
  27. async function fetch(url) {
  28. return new Promise((resolve, reject) => {
  29. http.get(url, (res) => {
  30. let data = '';
  31. res.on('data', chunk => data += chunk);
  32. res.on('end', () => resolve({
  33. status: res.statusCode,
  34. headers: res.headers,
  35. body: data
  36. }));
  37. }).on('error', reject);
  38. });
  39. }
  40. function startServer() {
  41. return spawn('node', [SERVER_PATH], {
  42. env: { ...process.env, BRAINSTORM_PORT: TEST_PORT, BRAINSTORM_DIR: TEST_DIR }
  43. });
  44. }
  45. async function waitForServer(server) {
  46. let stdout = '';
  47. let stderr = '';
  48. return new Promise((resolve, reject) => {
  49. server.stdout.on('data', (data) => {
  50. stdout += data.toString();
  51. if (stdout.includes('server-started')) {
  52. resolve({ stdout, stderr, getStdout: () => stdout });
  53. }
  54. });
  55. server.stderr.on('data', (data) => { stderr += data.toString(); });
  56. server.on('error', reject);
  57. setTimeout(() => reject(new Error(`Server didn't start. stderr: ${stderr}`)), 5000);
  58. });
  59. }
  60. async function runTests() {
  61. cleanup();
  62. fs.mkdirSync(TEST_DIR, { recursive: true });
  63. const server = startServer();
  64. let stdoutAccum = '';
  65. server.stdout.on('data', (data) => { stdoutAccum += data.toString(); });
  66. const { stdout: initialStdout } = await waitForServer(server);
  67. let passed = 0;
  68. let failed = 0;
  69. function test(name, fn) {
  70. return fn().then(() => {
  71. console.log(` PASS: ${name}`);
  72. passed++;
  73. }).catch(e => {
  74. console.log(` FAIL: ${name}`);
  75. console.log(` ${e.message}`);
  76. failed++;
  77. });
  78. }
  79. try {
  80. // ========== Server Startup ==========
  81. console.log('\n--- Server Startup ---');
  82. await test('outputs server-started JSON on startup', () => {
  83. const msg = JSON.parse(initialStdout.trim());
  84. assert.strictEqual(msg.type, 'server-started');
  85. assert.strictEqual(msg.port, TEST_PORT);
  86. assert(msg.url, 'Should include URL');
  87. assert(msg.screen_dir, 'Should include screen_dir');
  88. return Promise.resolve();
  89. });
  90. await test('writes .server-info file', () => {
  91. const infoPath = path.join(TEST_DIR, '.server-info');
  92. assert(fs.existsSync(infoPath), '.server-info should exist');
  93. const info = JSON.parse(fs.readFileSync(infoPath, 'utf-8').trim());
  94. assert.strictEqual(info.type, 'server-started');
  95. assert.strictEqual(info.port, TEST_PORT);
  96. return Promise.resolve();
  97. });
  98. // ========== HTTP Serving ==========
  99. console.log('\n--- HTTP Serving ---');
  100. await test('serves waiting page when no screens exist', async () => {
  101. const res = await fetch(`http://localhost:${TEST_PORT}/`);
  102. assert.strictEqual(res.status, 200);
  103. assert(res.body.includes('Waiting for Claude'), 'Should show waiting message');
  104. });
  105. await test('injects helper.js into waiting page', async () => {
  106. const res = await fetch(`http://localhost:${TEST_PORT}/`);
  107. assert(res.body.includes('WebSocket'), 'Should have helper.js injected');
  108. assert(res.body.includes('toggleSelect'), 'Should have toggleSelect from helper');
  109. assert(res.body.includes('brainstorm'), 'Should have brainstorm API from helper');
  110. });
  111. await test('returns Content-Type text/html', async () => {
  112. const res = await fetch(`http://localhost:${TEST_PORT}/`);
  113. assert(res.headers['content-type'].includes('text/html'), 'Should be text/html');
  114. });
  115. await test('serves full HTML documents as-is (not wrapped)', async () => {
  116. const fullDoc = '<!DOCTYPE html>\n<html><head><title>Custom</title></head><body><h1>Custom Page</h1></body></html>';
  117. fs.writeFileSync(path.join(TEST_DIR, 'full-doc.html'), fullDoc);
  118. await sleep(300);
  119. const res = await fetch(`http://localhost:${TEST_PORT}/`);
  120. assert(res.body.includes('<h1>Custom Page</h1>'), 'Should contain original content');
  121. assert(res.body.includes('WebSocket'), 'Should still inject helper.js');
  122. assert(!res.body.includes('indicator-bar'), 'Should NOT wrap in frame template');
  123. });
  124. await test('wraps content fragments in frame template', async () => {
  125. const fragment = '<h2>Pick a layout</h2>\n<div class="options"><div class="option" data-choice="a"><div class="letter">A</div></div></div>';
  126. fs.writeFileSync(path.join(TEST_DIR, 'fragment.html'), fragment);
  127. await sleep(300);
  128. const res = await fetch(`http://localhost:${TEST_PORT}/`);
  129. assert(res.body.includes('indicator-bar'), 'Fragment should get indicator bar');
  130. assert(!res.body.includes('<!-- CONTENT -->'), 'Placeholder should be replaced');
  131. assert(res.body.includes('Pick a layout'), 'Fragment content should be present');
  132. assert(res.body.includes('data-choice="a"'), 'Fragment interactive elements intact');
  133. });
  134. await test('serves newest file by mtime', async () => {
  135. fs.writeFileSync(path.join(TEST_DIR, 'older.html'), '<h2>Older</h2>');
  136. await sleep(100);
  137. fs.writeFileSync(path.join(TEST_DIR, 'newer.html'), '<h2>Newer</h2>');
  138. await sleep(300);
  139. const res = await fetch(`http://localhost:${TEST_PORT}/`);
  140. assert(res.body.includes('Newer'), 'Should serve newest file');
  141. });
  142. await test('ignores non-html files for serving', async () => {
  143. // Write a newer non-HTML file — should still serve newest .html
  144. fs.writeFileSync(path.join(TEST_DIR, 'data.json'), '{"not": "html"}');
  145. await sleep(300);
  146. const res = await fetch(`http://localhost:${TEST_PORT}/`);
  147. assert(res.body.includes('Newer'), 'Should still serve newest HTML');
  148. assert(!res.body.includes('"not"'), 'Should not serve JSON');
  149. });
  150. await test('returns 404 for non-root paths', async () => {
  151. const res = await fetch(`http://localhost:${TEST_PORT}/other`);
  152. assert.strictEqual(res.status, 404);
  153. });
  154. // ========== WebSocket Communication ==========
  155. console.log('\n--- WebSocket Communication ---');
  156. await test('accepts WebSocket upgrade on /', async () => {
  157. const ws = new WebSocket(`ws://localhost:${TEST_PORT}`);
  158. await new Promise((resolve, reject) => {
  159. ws.on('open', resolve);
  160. ws.on('error', reject);
  161. });
  162. ws.close();
  163. });
  164. await test('relays user events to stdout with source field', async () => {
  165. stdoutAccum = '';
  166. const ws = new WebSocket(`ws://localhost:${TEST_PORT}`);
  167. await new Promise(resolve => ws.on('open', resolve));
  168. ws.send(JSON.stringify({ type: 'click', text: 'Test Button' }));
  169. await sleep(300);
  170. assert(stdoutAccum.includes('"source":"user-event"'), 'Should tag with source');
  171. assert(stdoutAccum.includes('Test Button'), 'Should include event data');
  172. ws.close();
  173. });
  174. await test('writes choice events to .events file', async () => {
  175. // Clean up events from prior tests
  176. const eventsFile = path.join(TEST_DIR, '.events');
  177. if (fs.existsSync(eventsFile)) fs.unlinkSync(eventsFile);
  178. const ws = new WebSocket(`ws://localhost:${TEST_PORT}`);
  179. await new Promise(resolve => ws.on('open', resolve));
  180. ws.send(JSON.stringify({ type: 'click', choice: 'b', text: 'Option B' }));
  181. await sleep(300);
  182. assert(fs.existsSync(eventsFile), '.events should exist');
  183. const lines = fs.readFileSync(eventsFile, 'utf-8').trim().split('\n');
  184. const event = JSON.parse(lines[lines.length - 1]);
  185. assert.strictEqual(event.choice, 'b');
  186. assert.strictEqual(event.text, 'Option B');
  187. ws.close();
  188. });
  189. await test('does NOT write non-choice events to .events file', async () => {
  190. const eventsFile = path.join(TEST_DIR, '.events');
  191. if (fs.existsSync(eventsFile)) fs.unlinkSync(eventsFile);
  192. const ws = new WebSocket(`ws://localhost:${TEST_PORT}`);
  193. await new Promise(resolve => ws.on('open', resolve));
  194. ws.send(JSON.stringify({ type: 'hover', text: 'Something' }));
  195. await sleep(300);
  196. // Non-choice events should not create .events file
  197. assert(!fs.existsSync(eventsFile), '.events should not exist for non-choice events');
  198. ws.close();
  199. });
  200. await test('handles multiple concurrent WebSocket clients', async () => {
  201. const ws1 = new WebSocket(`ws://localhost:${TEST_PORT}`);
  202. const ws2 = new WebSocket(`ws://localhost:${TEST_PORT}`);
  203. await Promise.all([
  204. new Promise(resolve => ws1.on('open', resolve)),
  205. new Promise(resolve => ws2.on('open', resolve))
  206. ]);
  207. let ws1Reload = false;
  208. let ws2Reload = false;
  209. ws1.on('message', (data) => {
  210. if (JSON.parse(data.toString()).type === 'reload') ws1Reload = true;
  211. });
  212. ws2.on('message', (data) => {
  213. if (JSON.parse(data.toString()).type === 'reload') ws2Reload = true;
  214. });
  215. fs.writeFileSync(path.join(TEST_DIR, 'multi-client.html'), '<h2>Multi</h2>');
  216. await sleep(500);
  217. assert(ws1Reload, 'Client 1 should receive reload');
  218. assert(ws2Reload, 'Client 2 should receive reload');
  219. ws1.close();
  220. ws2.close();
  221. });
  222. await test('cleans up closed clients from broadcast list', async () => {
  223. const ws1 = new WebSocket(`ws://localhost:${TEST_PORT}`);
  224. await new Promise(resolve => ws1.on('open', resolve));
  225. ws1.close();
  226. await sleep(100);
  227. // This should not throw even though ws1 is closed
  228. fs.writeFileSync(path.join(TEST_DIR, 'after-close.html'), '<h2>After</h2>');
  229. await sleep(300);
  230. // If we got here without error, the test passes
  231. });
  232. await test('handles malformed JSON from client gracefully', async () => {
  233. const ws = new WebSocket(`ws://localhost:${TEST_PORT}`);
  234. await new Promise(resolve => ws.on('open', resolve));
  235. // Send invalid JSON — server should not crash
  236. ws.send('not json at all {{{');
  237. await sleep(300);
  238. // Verify server is still responsive
  239. const res = await fetch(`http://localhost:${TEST_PORT}/`);
  240. assert.strictEqual(res.status, 200, 'Server should still be running');
  241. ws.close();
  242. });
  243. // ========== File Watching ==========
  244. console.log('\n--- File Watching ---');
  245. await test('sends reload on new .html file', async () => {
  246. const ws = new WebSocket(`ws://localhost:${TEST_PORT}`);
  247. await new Promise(resolve => ws.on('open', resolve));
  248. let gotReload = false;
  249. ws.on('message', (data) => {
  250. if (JSON.parse(data.toString()).type === 'reload') gotReload = true;
  251. });
  252. fs.writeFileSync(path.join(TEST_DIR, 'watch-new.html'), '<h2>New</h2>');
  253. await sleep(500);
  254. assert(gotReload, 'Should send reload on new file');
  255. ws.close();
  256. });
  257. await test('sends reload on .html file change', async () => {
  258. const filePath = path.join(TEST_DIR, 'watch-change.html');
  259. fs.writeFileSync(filePath, '<h2>Original</h2>');
  260. await sleep(500);
  261. const ws = new WebSocket(`ws://localhost:${TEST_PORT}`);
  262. await new Promise(resolve => ws.on('open', resolve));
  263. let gotReload = false;
  264. ws.on('message', (data) => {
  265. if (JSON.parse(data.toString()).type === 'reload') gotReload = true;
  266. });
  267. fs.writeFileSync(filePath, '<h2>Modified</h2>');
  268. await sleep(500);
  269. assert(gotReload, 'Should send reload on file change');
  270. ws.close();
  271. });
  272. await test('does NOT send reload for non-.html files', async () => {
  273. const ws = new WebSocket(`ws://localhost:${TEST_PORT}`);
  274. await new Promise(resolve => ws.on('open', resolve));
  275. let gotReload = false;
  276. ws.on('message', (data) => {
  277. if (JSON.parse(data.toString()).type === 'reload') gotReload = true;
  278. });
  279. fs.writeFileSync(path.join(TEST_DIR, 'data.txt'), 'not html');
  280. await sleep(500);
  281. assert(!gotReload, 'Should NOT reload for non-HTML files');
  282. ws.close();
  283. });
  284. await test('clears .events on new screen', async () => {
  285. // Create an .events file
  286. const eventsFile = path.join(TEST_DIR, '.events');
  287. fs.writeFileSync(eventsFile, '{"choice":"a"}\n');
  288. assert(fs.existsSync(eventsFile));
  289. fs.writeFileSync(path.join(TEST_DIR, 'clear-events.html'), '<h2>New screen</h2>');
  290. await sleep(500);
  291. assert(!fs.existsSync(eventsFile), '.events should be cleared on new screen');
  292. });
  293. await test('logs screen-added on new file', async () => {
  294. stdoutAccum = '';
  295. fs.writeFileSync(path.join(TEST_DIR, 'log-test.html'), '<h2>Log</h2>');
  296. await sleep(500);
  297. assert(stdoutAccum.includes('screen-added'), 'Should log screen-added');
  298. });
  299. await test('logs screen-updated on file change', async () => {
  300. const filePath = path.join(TEST_DIR, 'log-update.html');
  301. fs.writeFileSync(filePath, '<h2>V1</h2>');
  302. await sleep(500);
  303. stdoutAccum = '';
  304. fs.writeFileSync(filePath, '<h2>V2</h2>');
  305. await sleep(500);
  306. assert(stdoutAccum.includes('screen-updated'), 'Should log screen-updated');
  307. });
  308. // ========== Helper.js Content ==========
  309. console.log('\n--- Helper.js Verification ---');
  310. await test('helper.js defines required APIs', () => {
  311. const helperContent = fs.readFileSync(
  312. path.join(__dirname, '../../skills/brainstorming/scripts/helper.js'), 'utf-8'
  313. );
  314. assert(helperContent.includes('toggleSelect'), 'Should define toggleSelect');
  315. assert(helperContent.includes('sendEvent'), 'Should define sendEvent');
  316. assert(helperContent.includes('selectedChoice'), 'Should track selectedChoice');
  317. assert(helperContent.includes('brainstorm'), 'Should expose brainstorm API');
  318. return Promise.resolve();
  319. });
  320. // ========== Frame Template ==========
  321. console.log('\n--- Frame Template Verification ---');
  322. await test('frame template has required structure', () => {
  323. const template = fs.readFileSync(
  324. path.join(__dirname, '../../skills/brainstorming/scripts/frame-template.html'), 'utf-8'
  325. );
  326. assert(template.includes('indicator-bar'), 'Should have indicator bar');
  327. assert(template.includes('indicator-text'), 'Should have indicator text');
  328. assert(template.includes('<!-- CONTENT -->'), 'Should have content placeholder');
  329. assert(template.includes('claude-content'), 'Should have content container');
  330. return Promise.resolve();
  331. });
  332. // ========== Summary ==========
  333. console.log(`\n--- Results: ${passed} passed, ${failed} failed ---`);
  334. if (failed > 0) process.exit(1);
  335. } finally {
  336. server.kill();
  337. await sleep(100);
  338. cleanup();
  339. }
  340. }
  341. runTests().catch(err => {
  342. console.error('Test failed:', err);
  343. process.exit(1);
  344. });