server.test.js 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496
  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.cjs');
  17. const TEST_PORT = 3334;
  18. const TEST_DIR = '/tmp/brainstorm-test';
  19. const CONTENT_DIR = path.join(TEST_DIR, 'content');
  20. const STATE_DIR = path.join(TEST_DIR, 'state');
  21. // Fixed session key so the test client can authenticate (see auth.test.js for
  22. // the security behavior itself; here we just need authorized requests).
  23. const TOKEN = 'testtoken-server-0123456789abcdef';
  24. function cleanup() {
  25. if (fs.existsSync(TEST_DIR)) {
  26. fs.rmSync(TEST_DIR, { recursive: true });
  27. }
  28. }
  29. async function sleep(ms) {
  30. return new Promise(resolve => setTimeout(resolve, ms));
  31. }
  32. async function fetch(url) {
  33. return new Promise((resolve, reject) => {
  34. const headers = { Cookie: `brainstorm-key-${TEST_PORT}=${TOKEN}` };
  35. http.get(url, { headers }, (res) => {
  36. let data = '';
  37. res.on('data', chunk => data += chunk);
  38. res.on('end', () => resolve({
  39. status: res.statusCode,
  40. headers: res.headers,
  41. body: data
  42. }));
  43. }).on('error', reject);
  44. });
  45. }
  46. function startServer() {
  47. return spawn('node', [SERVER_PATH], {
  48. env: { ...process.env, BRAINSTORM_PORT: TEST_PORT, BRAINSTORM_DIR: TEST_DIR, BRAINSTORM_TOKEN: TOKEN }
  49. });
  50. }
  51. async function waitForServer(server) {
  52. let stdout = '';
  53. let stderr = '';
  54. return new Promise((resolve, reject) => {
  55. server.stdout.on('data', (data) => {
  56. stdout += data.toString();
  57. if (stdout.includes('server-started')) {
  58. resolve({ stdout, stderr, getStdout: () => stdout });
  59. }
  60. });
  61. server.stderr.on('data', (data) => { stderr += data.toString(); });
  62. server.on('error', reject);
  63. setTimeout(() => reject(new Error(`Server didn't start. stderr: ${stderr}`)), 5000);
  64. });
  65. }
  66. async function runTests() {
  67. cleanup();
  68. const server = startServer();
  69. let stdoutAccum = '';
  70. server.stdout.on('data', (data) => { stdoutAccum += data.toString(); });
  71. const { stdout: initialStdout } = await waitForServer(server);
  72. let passed = 0;
  73. let failed = 0;
  74. function test(name, fn) {
  75. return fn().then(() => {
  76. console.log(` PASS: ${name}`);
  77. passed++;
  78. }).catch(e => {
  79. console.log(` FAIL: ${name}`);
  80. console.log(` ${e.message}`);
  81. failed++;
  82. });
  83. }
  84. try {
  85. // ========== Server Startup ==========
  86. console.log('\n--- Server Startup ---');
  87. await test('outputs server-started JSON on startup', () => {
  88. const msg = JSON.parse(initialStdout.trim());
  89. assert.strictEqual(msg.type, 'server-started');
  90. assert.strictEqual(msg.port, TEST_PORT);
  91. assert(msg.url, 'Should include URL');
  92. assert(msg.screen_dir, 'Should include screen_dir');
  93. return Promise.resolve();
  94. });
  95. await test('writes server-info to state/', () => {
  96. const infoPath = path.join(STATE_DIR, 'server-info');
  97. assert(fs.existsSync(infoPath), 'state/server-info should exist');
  98. const info = JSON.parse(fs.readFileSync(infoPath, 'utf-8').trim());
  99. assert.strictEqual(info.type, 'server-started');
  100. assert.strictEqual(info.port, TEST_PORT);
  101. assert.strictEqual(info.screen_dir, CONTENT_DIR, 'screen_dir should point to content/');
  102. assert.strictEqual(info.state_dir, STATE_DIR, 'state_dir should point to state/');
  103. return Promise.resolve();
  104. });
  105. // ========== HTTP Serving ==========
  106. console.log('\n--- HTTP Serving ---');
  107. await test('serves waiting page when no screens exist', async () => {
  108. const res = await fetch(`http://localhost:${TEST_PORT}/`);
  109. assert.strictEqual(res.status, 200);
  110. assert(res.body.includes('Waiting for the agent'), 'Should show waiting message');
  111. });
  112. await test('injects helper.js into waiting page', async () => {
  113. const res = await fetch(`http://localhost:${TEST_PORT}/`);
  114. assert(res.body.includes('WebSocket'), 'Should have helper.js injected');
  115. assert(res.body.includes('toggleSelect'), 'Should have toggleSelect from helper');
  116. assert(res.body.includes('brainstorm'), 'Should have brainstorm API from helper');
  117. });
  118. await test('returns Content-Type text/html', async () => {
  119. const res = await fetch(`http://localhost:${TEST_PORT}/`);
  120. assert(res.headers['content-type'].includes('text/html'), 'Should be text/html');
  121. });
  122. await test('serves full HTML documents as-is (not wrapped)', async () => {
  123. const fullDoc = '<!DOCTYPE html>\n<html><head><title>Custom</title></head><body><h1>Custom Page</h1></body></html>';
  124. fs.writeFileSync(path.join(CONTENT_DIR, 'full-doc.html'), fullDoc);
  125. await sleep(300);
  126. const res = await fetch(`http://localhost:${TEST_PORT}/`);
  127. assert(res.body.includes('<h1>Custom Page</h1>'), 'Should contain original content');
  128. assert(res.body.includes('WebSocket'), 'Should still inject helper.js');
  129. assert(!res.body.includes('indicator-bar'), 'Should NOT wrap in frame template');
  130. });
  131. await test('wraps content fragments in frame template', async () => {
  132. const fragment = '<h2>Pick a layout</h2>\n<div class="options"><div class="option" data-choice="a"><div class="letter">A</div></div></div>';
  133. fs.writeFileSync(path.join(CONTENT_DIR, 'fragment.html'), fragment);
  134. await sleep(300);
  135. const res = await fetch(`http://localhost:${TEST_PORT}/`);
  136. assert(res.body.includes('indicator-bar'), 'Fragment should get indicator bar');
  137. assert(!res.body.includes('<!-- CONTENT -->'), 'Placeholder should be replaced');
  138. assert(res.body.includes('Pick a layout'), 'Fragment content should be present');
  139. assert(res.body.includes('data-choice="a"'), 'Fragment interactive elements intact');
  140. });
  141. await test('serves newest file by mtime', async () => {
  142. fs.writeFileSync(path.join(CONTENT_DIR, 'older.html'), '<h2>Older</h2>');
  143. await sleep(100);
  144. fs.writeFileSync(path.join(CONTENT_DIR, 'newer.html'), '<h2>Newer</h2>');
  145. await sleep(300);
  146. const res = await fetch(`http://localhost:${TEST_PORT}/`);
  147. assert(res.body.includes('Newer'), 'Should serve newest file');
  148. });
  149. await test('ignores non-html files for serving', async () => {
  150. // Write a newer non-HTML file — should still serve newest .html
  151. fs.writeFileSync(path.join(CONTENT_DIR, 'data.json'), '{"not": "html"}');
  152. await sleep(300);
  153. const res = await fetch(`http://localhost:${TEST_PORT}/`);
  154. assert(res.body.includes('Newer'), 'Should still serve newest HTML');
  155. assert(!res.body.includes('"not"'), 'Should not serve JSON');
  156. });
  157. await test('ignores macOS resource-fork dotfiles (._*.html) when serving', async () => {
  158. // On macOS/ExFAT/SMB, the OS writes ._name.html sidecar files holding
  159. // binary metadata. They end with .html but must never be served as a screen.
  160. fs.writeFileSync(path.join(CONTENT_DIR, 'real-screen.html'), '<h2>Real Screen Content</h2>');
  161. await sleep(100);
  162. fs.writeFileSync(path.join(CONTENT_DIR, '._real-screen.html'), 'Mac OS X resource fork garbage');
  163. await sleep(300);
  164. const res = await fetch(`http://localhost:${TEST_PORT}/`);
  165. assert(res.body.includes('Real Screen Content'), 'should serve the real screen, not the newer ._ sidecar');
  166. assert(!res.body.includes('resource fork garbage'), 'must not serve ._*.html dotfile content');
  167. });
  168. await test('does not serve dotfiles via /files/', async () => {
  169. fs.writeFileSync(path.join(CONTENT_DIR, '._secret.html'), 'dotfile body should not be served');
  170. const res = await fetch(`http://localhost:${TEST_PORT}/files/._secret.html`);
  171. assert.strictEqual(res.status, 404, '/files/ must 404 on dotfiles');
  172. });
  173. await test('GET /files/ (empty name) returns 404 and does not crash the server', async () => {
  174. const res = await fetch(`http://localhost:${TEST_PORT}/files/`);
  175. assert.strictEqual(res.status, 404, '/files/ (the content dir) must 404, not EISDIR-crash');
  176. // The server must still be alive afterward.
  177. const alive = await fetch(`http://localhost:${TEST_PORT}/`);
  178. assert.strictEqual(alive.status, 200, 'server must survive a /files/ request');
  179. });
  180. await test('does not serve symlinks that escape content dir via /files/', async () => {
  181. const target = path.join(STATE_DIR, 'server-info');
  182. const link = path.join(CONTENT_DIR, 'linked-server-info.txt');
  183. try { fs.unlinkSync(link); } catch (e) {}
  184. fs.symlinkSync(target, link);
  185. const res = await fetch(`http://localhost:${TEST_PORT}/files/linked-server-info.txt`);
  186. assert.strictEqual(res.status, 404, 'symlink to state/server-info must not be served');
  187. assert(!res.body.includes('server-started'), 'response must not include server-info body');
  188. });
  189. await test('does not serve hard links to files outside content dir via /files/', async () => {
  190. const target = path.join(STATE_DIR, 'server-info');
  191. const link = path.join(CONTENT_DIR, 'hard-linked-server-info.txt');
  192. try { fs.unlinkSync(link); } catch (e) {}
  193. fs.linkSync(target, link);
  194. const res = await fetch(`http://localhost:${TEST_PORT}/files/hard-linked-server-info.txt`);
  195. assert.strictEqual(res.status, 404, 'hard link to state/server-info must not be served');
  196. assert(!res.body.includes('server-started'), 'response must not include server-info body');
  197. });
  198. await test('returns 404 for non-root paths', async () => {
  199. const res = await fetch(`http://localhost:${TEST_PORT}/other`);
  200. assert.strictEqual(res.status, 404);
  201. });
  202. // ========== WebSocket Communication ==========
  203. console.log('\n--- WebSocket Communication ---');
  204. await test('accepts WebSocket upgrade on /', async () => {
  205. const ws = new WebSocket(`ws://localhost:${TEST_PORT}/?key=${TOKEN}`);
  206. await new Promise((resolve, reject) => {
  207. ws.on('open', resolve);
  208. ws.on('error', reject);
  209. });
  210. ws.close();
  211. });
  212. await test('relays user events to stdout with source field', async () => {
  213. stdoutAccum = '';
  214. const ws = new WebSocket(`ws://localhost:${TEST_PORT}/?key=${TOKEN}`);
  215. await new Promise(resolve => ws.on('open', resolve));
  216. ws.send(JSON.stringify({ type: 'click', text: 'Test Button' }));
  217. await sleep(300);
  218. assert(stdoutAccum.includes('"source":"user-event"'), 'Should tag with source');
  219. assert(stdoutAccum.includes('Test Button'), 'Should include event data');
  220. ws.close();
  221. });
  222. await test('writes choice events to state/events', async () => {
  223. // Clean up events from prior tests
  224. const eventsFile = path.join(STATE_DIR, 'events');
  225. if (fs.existsSync(eventsFile)) fs.unlinkSync(eventsFile);
  226. const ws = new WebSocket(`ws://localhost:${TEST_PORT}/?key=${TOKEN}`);
  227. await new Promise(resolve => ws.on('open', resolve));
  228. ws.send(JSON.stringify({ type: 'click', choice: 'b', text: 'Option B' }));
  229. await sleep(300);
  230. assert(fs.existsSync(eventsFile), '.events should exist');
  231. const lines = fs.readFileSync(eventsFile, 'utf-8').trim().split('\n');
  232. const event = JSON.parse(lines[lines.length - 1]);
  233. assert.strictEqual(event.choice, 'b');
  234. assert.strictEqual(event.text, 'Option B');
  235. ws.close();
  236. });
  237. await test('does NOT write non-choice events to state/events', async () => {
  238. const eventsFile = path.join(STATE_DIR, 'events');
  239. if (fs.existsSync(eventsFile)) fs.unlinkSync(eventsFile);
  240. const ws = new WebSocket(`ws://localhost:${TEST_PORT}/?key=${TOKEN}`);
  241. await new Promise(resolve => ws.on('open', resolve));
  242. ws.send(JSON.stringify({ type: 'hover', text: 'Something' }));
  243. await sleep(300);
  244. // Non-choice events should not create .events file
  245. assert(!fs.existsSync(eventsFile), '.events should not exist for non-choice events');
  246. ws.close();
  247. });
  248. await test('handles multiple concurrent WebSocket clients', async () => {
  249. const ws1 = new WebSocket(`ws://localhost:${TEST_PORT}/?key=${TOKEN}`);
  250. const ws2 = new WebSocket(`ws://localhost:${TEST_PORT}/?key=${TOKEN}`);
  251. await Promise.all([
  252. new Promise(resolve => ws1.on('open', resolve)),
  253. new Promise(resolve => ws2.on('open', resolve))
  254. ]);
  255. let ws1Reload = false;
  256. let ws2Reload = false;
  257. ws1.on('message', (data) => {
  258. if (JSON.parse(data.toString()).type === 'reload') ws1Reload = true;
  259. });
  260. ws2.on('message', (data) => {
  261. if (JSON.parse(data.toString()).type === 'reload') ws2Reload = true;
  262. });
  263. fs.writeFileSync(path.join(CONTENT_DIR, 'multi-client.html'), '<h2>Multi</h2>');
  264. await sleep(500);
  265. assert(ws1Reload, 'Client 1 should receive reload');
  266. assert(ws2Reload, 'Client 2 should receive reload');
  267. ws1.close();
  268. ws2.close();
  269. });
  270. await test('cleans up closed clients from broadcast list', async () => {
  271. const ws1 = new WebSocket(`ws://localhost:${TEST_PORT}/?key=${TOKEN}`);
  272. await new Promise(resolve => ws1.on('open', resolve));
  273. ws1.close();
  274. await sleep(100);
  275. // This should not throw even though ws1 is closed
  276. fs.writeFileSync(path.join(CONTENT_DIR, 'after-close.html'), '<h2>After</h2>');
  277. await sleep(300);
  278. // If we got here without error, the test passes
  279. });
  280. await test('handles malformed JSON from client gracefully', async () => {
  281. const ws = new WebSocket(`ws://localhost:${TEST_PORT}/?key=${TOKEN}`);
  282. await new Promise(resolve => ws.on('open', resolve));
  283. // Send invalid JSON — server should not crash
  284. ws.send('not json at all {{{');
  285. await sleep(300);
  286. // Verify server is still responsive
  287. const res = await fetch(`http://localhost:${TEST_PORT}/`);
  288. assert.strictEqual(res.status, 200, 'Server should still be running');
  289. ws.close();
  290. });
  291. // ========== File Watching ==========
  292. console.log('\n--- File Watching ---');
  293. await test('sends reload on new .html file', async () => {
  294. const ws = new WebSocket(`ws://localhost:${TEST_PORT}/?key=${TOKEN}`);
  295. await new Promise(resolve => ws.on('open', resolve));
  296. let gotReload = false;
  297. ws.on('message', (data) => {
  298. if (JSON.parse(data.toString()).type === 'reload') gotReload = true;
  299. });
  300. fs.writeFileSync(path.join(CONTENT_DIR, 'watch-new.html'), '<h2>New</h2>');
  301. await sleep(500);
  302. assert(gotReload, 'Should send reload on new file');
  303. ws.close();
  304. });
  305. await test('sends reload on .html file change', async () => {
  306. const filePath = path.join(CONTENT_DIR, 'watch-change.html');
  307. fs.writeFileSync(filePath, '<h2>Original</h2>');
  308. await sleep(500);
  309. const ws = new WebSocket(`ws://localhost:${TEST_PORT}/?key=${TOKEN}`);
  310. await new Promise(resolve => ws.on('open', resolve));
  311. let gotReload = false;
  312. ws.on('message', (data) => {
  313. if (JSON.parse(data.toString()).type === 'reload') gotReload = true;
  314. });
  315. fs.writeFileSync(filePath, '<h2>Modified</h2>');
  316. await sleep(500);
  317. assert(gotReload, 'Should send reload on file change');
  318. ws.close();
  319. });
  320. await test('does NOT send reload for non-.html files', async () => {
  321. const ws = new WebSocket(`ws://localhost:${TEST_PORT}/?key=${TOKEN}`);
  322. await new Promise(resolve => ws.on('open', resolve));
  323. let gotReload = false;
  324. ws.on('message', (data) => {
  325. if (JSON.parse(data.toString()).type === 'reload') gotReload = true;
  326. });
  327. fs.writeFileSync(path.join(CONTENT_DIR, 'data.txt'), 'not html');
  328. await sleep(500);
  329. assert(!gotReload, 'Should NOT reload for non-HTML files');
  330. ws.close();
  331. });
  332. await test('does NOT send reload for ._*.html resource-fork dotfiles', async () => {
  333. const ws = new WebSocket(`ws://localhost:${TEST_PORT}/?key=${TOKEN}`);
  334. await new Promise(resolve => ws.on('open', resolve));
  335. let gotReload = false;
  336. ws.on('message', (data) => {
  337. if (JSON.parse(data.toString()).type === 'reload') gotReload = true;
  338. });
  339. fs.writeFileSync(path.join(CONTENT_DIR, '._sidecar.html'), 'resource fork');
  340. await sleep(500);
  341. assert(!gotReload, 'a ._ dotfile appearing must not trigger a reload');
  342. ws.close();
  343. });
  344. await test('clears state/events on new screen', async () => {
  345. // Create an events file
  346. const eventsFile = path.join(STATE_DIR, 'events');
  347. fs.writeFileSync(eventsFile, '{"choice":"a"}\n');
  348. assert(fs.existsSync(eventsFile));
  349. fs.writeFileSync(path.join(CONTENT_DIR, 'clear-events.html'), '<h2>New screen</h2>');
  350. await sleep(500);
  351. assert(!fs.existsSync(eventsFile), 'state/events should be cleared on new screen');
  352. });
  353. await test('logs screen-added on new file', async () => {
  354. stdoutAccum = '';
  355. fs.writeFileSync(path.join(CONTENT_DIR, 'log-test.html'), '<h2>Log</h2>');
  356. await sleep(500);
  357. assert(stdoutAccum.includes('screen-added'), 'Should log screen-added');
  358. });
  359. await test('logs screen-updated on file change', async () => {
  360. const filePath = path.join(CONTENT_DIR, 'log-update.html');
  361. fs.writeFileSync(filePath, '<h2>V1</h2>');
  362. await sleep(500);
  363. stdoutAccum = '';
  364. fs.writeFileSync(filePath, '<h2>V2</h2>');
  365. await sleep(500);
  366. assert(stdoutAccum.includes('screen-updated'), 'Should log screen-updated');
  367. });
  368. // ========== Helper.js Content ==========
  369. console.log('\n--- Helper.js Verification ---');
  370. await test('helper.js defines required APIs', () => {
  371. const helperContent = fs.readFileSync(
  372. path.join(__dirname, '../../skills/brainstorming/scripts/helper.js'), 'utf-8'
  373. );
  374. assert(helperContent.includes('toggleSelect'), 'Should define toggleSelect');
  375. assert(helperContent.includes('sendEvent'), 'Should define sendEvent');
  376. assert(helperContent.includes('selectedChoice'), 'Should track selectedChoice');
  377. assert(helperContent.includes('brainstorm'), 'Should expose brainstorm API');
  378. return Promise.resolve();
  379. });
  380. // ========== Frame Template ==========
  381. console.log('\n--- Frame Template Verification ---');
  382. await test('frame template has required structure', () => {
  383. const template = fs.readFileSync(
  384. path.join(__dirname, '../../skills/brainstorming/scripts/frame-template.html'), 'utf-8'
  385. );
  386. assert(template.includes('indicator-bar'), 'Should have indicator bar');
  387. assert(template.includes('indicator-text'), 'Should have indicator text');
  388. assert(template.includes('<!-- CONTENT -->'), 'Should have content placeholder');
  389. assert(template.includes('frame-content'), 'Should have content container');
  390. return Promise.resolve();
  391. });
  392. // ========== Summary ==========
  393. console.log(`\n--- Results: ${passed} passed, ${failed} failed ---`);
  394. if (failed > 0) process.exit(1);
  395. } finally {
  396. server.kill();
  397. await sleep(100);
  398. cleanup();
  399. }
  400. }
  401. runTests().catch(err => {
  402. console.error('Test failed:', err);
  403. process.exit(1);
  404. });