1
0

server.test.js 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558
  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 crypto = require('crypto');
  12. const http = require('http');
  13. const net = require('net');
  14. const WebSocket = require('ws');
  15. const fs = require('fs');
  16. const path = require('path');
  17. const assert = require('assert');
  18. const SERVER_PATH = path.join(__dirname, '../../skills/brainstorming/scripts/server.cjs');
  19. const ALPINE_PATH = path.join(__dirname, '../../skills/brainstorming/scripts/vendor/alpine.js');
  20. const ALPINE_PROVENANCE_PATH = path.join(__dirname, '../../skills/brainstorming/scripts/vendor/alpine.provenance.json');
  21. const ALPINE_NOTICES_PATH = path.join(__dirname, '../../skills/brainstorming/scripts/vendor/THIRD_PARTY_NOTICES.md');
  22. const TEST_PORT = 3334;
  23. const TEST_DIR = '/tmp/brainstorm-test';
  24. const CONTENT_DIR = path.join(TEST_DIR, 'content');
  25. const STATE_DIR = path.join(TEST_DIR, 'state');
  26. function cleanup() {
  27. if (fs.existsSync(TEST_DIR)) {
  28. fs.rmSync(TEST_DIR, { recursive: true });
  29. }
  30. }
  31. async function sleep(ms) {
  32. return new Promise(resolve => setTimeout(resolve, ms));
  33. }
  34. async function fetch(url) {
  35. return new Promise((resolve, reject) => {
  36. http.get(url, (res) => {
  37. let data = '';
  38. res.on('data', chunk => data += chunk);
  39. res.on('end', () => resolve({
  40. status: res.statusCode,
  41. headers: res.headers,
  42. body: data
  43. }));
  44. }).on('error', reject);
  45. });
  46. }
  47. function sha256File(filePath) {
  48. return crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex');
  49. }
  50. function rawHttpRequest(requestTarget) {
  51. return new Promise((resolve, reject) => {
  52. const socket = net.createConnection({ host: 'localhost', port: TEST_PORT }, () => {
  53. socket.write(`GET ${requestTarget} HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n`);
  54. });
  55. let data = '';
  56. socket.on('data', chunk => data += chunk.toString());
  57. socket.on('end', () => {
  58. const statusLine = data.split('\r\n')[0];
  59. const match = statusLine.match(/^HTTP\/1\.1 (\d{3})/);
  60. resolve({
  61. status: match ? Number(match[1]) : 0
  62. });
  63. });
  64. socket.on('error', reject);
  65. });
  66. }
  67. function startServer() {
  68. return spawn('node', [SERVER_PATH], {
  69. env: { ...process.env, BRAINSTORM_PORT: TEST_PORT, BRAINSTORM_DIR: TEST_DIR }
  70. });
  71. }
  72. async function waitForServer(server) {
  73. let stdout = '';
  74. let stderr = '';
  75. return new Promise((resolve, reject) => {
  76. server.stdout.on('data', (data) => {
  77. stdout += data.toString();
  78. if (stdout.includes('server-started')) {
  79. resolve({ stdout, stderr, getStdout: () => stdout });
  80. }
  81. });
  82. server.stderr.on('data', (data) => { stderr += data.toString(); });
  83. server.on('error', reject);
  84. setTimeout(() => reject(new Error(`Server didn't start. stderr: ${stderr}`)), 5000);
  85. });
  86. }
  87. async function runTests() {
  88. cleanup();
  89. const server = startServer();
  90. let stdoutAccum = '';
  91. server.stdout.on('data', (data) => { stdoutAccum += data.toString(); });
  92. const { stdout: initialStdout } = await waitForServer(server);
  93. let passed = 0;
  94. let failed = 0;
  95. function test(name, fn) {
  96. return fn().then(() => {
  97. console.log(` PASS: ${name}`);
  98. passed++;
  99. }).catch(e => {
  100. console.log(` FAIL: ${name}`);
  101. console.log(` ${e.message}`);
  102. failed++;
  103. });
  104. }
  105. try {
  106. // ========== Vendored Alpine ==========
  107. console.log('\n--- Vendored Alpine ---');
  108. await test('vendored Alpine provenance is complete and matches artifact hash', () => {
  109. assert(fs.existsSync(ALPINE_PATH), 'alpine.js should exist');
  110. assert(fs.existsSync(ALPINE_PROVENANCE_PATH), 'alpine.provenance.json should exist');
  111. assert(fs.existsSync(ALPINE_NOTICES_PATH), 'THIRD_PARTY_NOTICES.md should exist');
  112. const provenance = JSON.parse(fs.readFileSync(ALPINE_PROVENANCE_PATH, 'utf-8'));
  113. assert.strictEqual(provenance.name, 'alpinejs');
  114. assert.strictEqual(provenance.version, '3.15.12');
  115. assert.strictEqual(provenance.license, 'MIT');
  116. assert.strictEqual(provenance.sourceUrl, 'https://registry.npmjs.org/alpinejs/-/alpinejs-3.15.12.tgz');
  117. assert.strictEqual(provenance.sourcePackagePath, 'package/dist/cdn.min.js');
  118. assert.strictEqual(provenance.localPath, 'skills/brainstorming/scripts/vendor/alpine.js');
  119. assert.strictEqual(provenance.sha256, '57b37d7cae9a27d965fdae4adcc844245dfdc407e655aee85dcfff3a08036a3f');
  120. assert.strictEqual(provenance.approvalArtifact, 'SUP-215');
  121. assert.strictEqual(sha256File(ALPINE_PATH), provenance.sha256);
  122. const notices = fs.readFileSync(ALPINE_NOTICES_PATH, 'utf-8');
  123. assert(notices.includes('Alpine.js'), 'Notice should name Alpine.js');
  124. assert(notices.includes('MIT License'), 'Notice should include MIT license text');
  125. assert(notices.includes('curl -fsSL https://registry.npmjs.org/alpinejs/-/alpinejs-3.15.12.tgz'), 'Notice should include refresh command');
  126. return Promise.resolve();
  127. });
  128. // ========== Server Startup ==========
  129. console.log('\n--- Server Startup ---');
  130. await test('outputs server-started JSON on startup', () => {
  131. const msg = JSON.parse(initialStdout.trim());
  132. assert.strictEqual(msg.type, 'server-started');
  133. assert.strictEqual(msg.port, TEST_PORT);
  134. assert(msg.url, 'Should include URL');
  135. assert(msg.screen_dir, 'Should include screen_dir');
  136. return Promise.resolve();
  137. });
  138. await test('writes server-info to state/', () => {
  139. const infoPath = path.join(STATE_DIR, 'server-info');
  140. assert(fs.existsSync(infoPath), 'state/server-info should exist');
  141. const info = JSON.parse(fs.readFileSync(infoPath, 'utf-8').trim());
  142. assert.strictEqual(info.type, 'server-started');
  143. assert.strictEqual(info.port, TEST_PORT);
  144. assert.strictEqual(info.screen_dir, CONTENT_DIR, 'screen_dir should point to content/');
  145. assert.strictEqual(info.state_dir, STATE_DIR, 'state_dir should point to state/');
  146. return Promise.resolve();
  147. });
  148. // ========== HTTP Serving ==========
  149. console.log('\n--- HTTP Serving ---');
  150. await test('serves waiting page when no screens exist', async () => {
  151. const res = await fetch(`http://localhost:${TEST_PORT}/`);
  152. assert.strictEqual(res.status, 200);
  153. assert(res.body.includes('Waiting for the agent'), 'Should show waiting message');
  154. });
  155. await test('injects helper.js into waiting page', async () => {
  156. const res = await fetch(`http://localhost:${TEST_PORT}/`);
  157. assert(res.body.includes('WebSocket'), 'Should have helper.js injected');
  158. assert(res.body.includes('toggleSelect'), 'Should have toggleSelect from helper');
  159. assert(res.body.includes('brainstorm'), 'Should have brainstorm API from helper');
  160. });
  161. await test('returns Content-Type text/html', async () => {
  162. const res = await fetch(`http://localhost:${TEST_PORT}/`);
  163. assert(res.headers['content-type'].includes('text/html'), 'Should be text/html');
  164. });
  165. await test('waiting page does not inject Alpine', async () => {
  166. const res = await fetch(`http://localhost:${TEST_PORT}/`);
  167. assert(!res.body.includes('/vendor/alpine.js'), 'Waiting page should not inject Alpine');
  168. });
  169. await test('serves root path when query string is present', async () => {
  170. const res = await fetch(`http://localhost:${TEST_PORT}/?from=browser`);
  171. assert.strictEqual(res.status, 200);
  172. assert(res.body.includes('Brainstorm Companion'), 'Should serve the root page by pathname');
  173. });
  174. await test('serves full HTML documents as-is (not wrapped)', async () => {
  175. const fullDoc = '<!DOCTYPE html>\n<html><head><title>Custom</title></head><body><h1>Custom Page</h1></body></html>';
  176. fs.writeFileSync(path.join(CONTENT_DIR, 'full-doc.html'), fullDoc);
  177. await sleep(300);
  178. const res = await fetch(`http://localhost:${TEST_PORT}/`);
  179. assert(res.body.includes('<h1>Custom Page</h1>'), 'Should contain original content');
  180. assert(res.body.includes('WebSocket'), 'Should still inject helper.js');
  181. assert(!res.body.includes('/vendor/alpine.js'), 'Should NOT inject Alpine into full documents');
  182. assert(!res.body.includes('indicator-bar'), 'Should NOT wrap in frame template');
  183. });
  184. await test('wraps content fragments in frame template', async () => {
  185. const fragment = '<h2>Pick a layout</h2>\n<div class="options"><div class="option" data-choice="a"><div class="letter">A</div></div></div>';
  186. fs.writeFileSync(path.join(CONTENT_DIR, 'fragment.html'), fragment);
  187. await sleep(300);
  188. const res = await fetch(`http://localhost:${TEST_PORT}/`);
  189. assert(res.body.includes('indicator-bar'), 'Fragment should get indicator bar');
  190. assert(!res.body.includes('<!-- CONTENT -->'), 'Placeholder should be replaced');
  191. assert(res.body.includes('Pick a layout'), 'Fragment content should be present');
  192. assert(res.body.includes('data-choice="a"'), 'Fragment interactive elements intact');
  193. assert(res.body.includes('<script defer src="/vendor/alpine.js"></script>'), 'Fragment should load Alpine');
  194. assert(res.body.includes('Interact with the mockup, then return to the terminal'), 'Frame copy should be neutral');
  195. });
  196. await test('preserves Alpine attributes in frame-wrapped fragments', async () => {
  197. const fragment = '<div x-data="{ open: false }"><button @click="open = !open">Toggle</button><div x-show="open">Details</div></div>';
  198. fs.writeFileSync(path.join(CONTENT_DIR, 'alpine-fragment.html'), fragment);
  199. await sleep(300);
  200. const res = await fetch(`http://localhost:${TEST_PORT}/`);
  201. assert(res.body.includes('x-data="{ open: false }"'), 'Should preserve x-data');
  202. assert(res.body.includes('@click="open = !open"'), 'Should preserve @click');
  203. assert(res.body.includes('x-show="open"'), 'Should preserve x-show');
  204. assert(res.body.includes('/vendor/alpine.js'), 'Should include Alpine script');
  205. });
  206. await test('serves newest file by mtime', async () => {
  207. fs.writeFileSync(path.join(CONTENT_DIR, 'older.html'), '<h2>Older</h2>');
  208. await sleep(100);
  209. fs.writeFileSync(path.join(CONTENT_DIR, 'newer.html'), '<h2>Newer</h2>');
  210. await sleep(300);
  211. const res = await fetch(`http://localhost:${TEST_PORT}/`);
  212. assert(res.body.includes('Newer'), 'Should serve newest file');
  213. });
  214. await test('ignores non-html files for serving', async () => {
  215. // Write a newer non-HTML file — should still serve newest .html
  216. fs.writeFileSync(path.join(CONTENT_DIR, 'data.json'), '{"not": "html"}');
  217. await sleep(300);
  218. const res = await fetch(`http://localhost:${TEST_PORT}/`);
  219. assert(res.body.includes('Newer'), 'Should still serve newest HTML');
  220. assert(!res.body.includes('"not"'), 'Should not serve JSON');
  221. });
  222. await test('returns 404 for non-root paths', async () => {
  223. const res = await fetch(`http://localhost:${TEST_PORT}/other`);
  224. assert.strictEqual(res.status, 404);
  225. });
  226. await test('serves files by pathname when query string is present', async () => {
  227. fs.writeFileSync(path.join(CONTENT_DIR, 'asset.png'), 'image-bytes');
  228. const res = await fetch(`http://localhost:${TEST_PORT}/files/asset.png?v=1`);
  229. assert.strictEqual(res.status, 200);
  230. assert.strictEqual(res.body, 'image-bytes');
  231. });
  232. await test('serves vendored Alpine from exact vendor route', async () => {
  233. const res = await fetch(`http://localhost:${TEST_PORT}/vendor/alpine.js`);
  234. const provenance = JSON.parse(fs.readFileSync(ALPINE_PROVENANCE_PATH, 'utf-8'));
  235. assert.strictEqual(res.status, 200);
  236. assert(res.headers['content-type'].includes('application/javascript'), 'Should be JavaScript');
  237. assert.strictEqual(
  238. crypto.createHash('sha256').update(res.body).digest('hex'),
  239. provenance.sha256,
  240. 'Should serve the pinned Alpine artifact'
  241. );
  242. });
  243. await test('serves vendored Alpine when query string is present', async () => {
  244. const res = await fetch(`http://localhost:${TEST_PORT}/vendor/alpine.js?v=3.15.12`);
  245. assert.strictEqual(res.status, 200);
  246. assert(res.body.includes('Alpine'), 'Should ignore query string for exact vendor pathname');
  247. });
  248. await test('exact-match vendor route rejects non-allowlisted pathnames', async () => {
  249. const paths = [
  250. '/vendor/unknown.js',
  251. '/vendor/alpine.js/extra',
  252. '/vendor/%2e%2e/alpine.js',
  253. '/vendor/%2E%2E/alpine.js'
  254. ];
  255. for (const requestPath of paths) {
  256. const res = await fetch(`http://localhost:${TEST_PORT}${requestPath}`);
  257. assert.strictEqual(res.status, 404, `${requestPath} should 404`);
  258. }
  259. const dotSegmentRes = await rawHttpRequest('/vendor/../alpine.js');
  260. assert.strictEqual(dotSegmentRes.status, 404, 'raw dot-segment vendor path should 404');
  261. });
  262. // ========== WebSocket Communication ==========
  263. console.log('\n--- WebSocket Communication ---');
  264. await test('accepts WebSocket upgrade on /', async () => {
  265. const ws = new WebSocket(`ws://localhost:${TEST_PORT}`);
  266. await new Promise((resolve, reject) => {
  267. ws.on('open', resolve);
  268. ws.on('error', reject);
  269. });
  270. ws.close();
  271. });
  272. await test('relays user events to stdout with source field', async () => {
  273. stdoutAccum = '';
  274. const ws = new WebSocket(`ws://localhost:${TEST_PORT}`);
  275. await new Promise(resolve => ws.on('open', resolve));
  276. ws.send(JSON.stringify({ type: 'click', text: 'Test Button' }));
  277. await sleep(300);
  278. assert(stdoutAccum.includes('"source":"user-event"'), 'Should tag with source');
  279. assert(stdoutAccum.includes('Test Button'), 'Should include event data');
  280. ws.close();
  281. });
  282. await test('writes choice events to state/events', async () => {
  283. // Clean up events from prior tests
  284. const eventsFile = path.join(STATE_DIR, 'events');
  285. if (fs.existsSync(eventsFile)) fs.unlinkSync(eventsFile);
  286. const ws = new WebSocket(`ws://localhost:${TEST_PORT}`);
  287. await new Promise(resolve => ws.on('open', resolve));
  288. ws.send(JSON.stringify({ type: 'click', choice: 'b', text: 'Option B' }));
  289. await sleep(300);
  290. assert(fs.existsSync(eventsFile), '.events should exist');
  291. const lines = fs.readFileSync(eventsFile, 'utf-8').trim().split('\n');
  292. const event = JSON.parse(lines[lines.length - 1]);
  293. assert.strictEqual(event.choice, 'b');
  294. assert.strictEqual(event.text, 'Option B');
  295. ws.close();
  296. });
  297. await test('does NOT write non-choice events to state/events', async () => {
  298. const eventsFile = path.join(STATE_DIR, 'events');
  299. if (fs.existsSync(eventsFile)) fs.unlinkSync(eventsFile);
  300. const ws = new WebSocket(`ws://localhost:${TEST_PORT}`);
  301. await new Promise(resolve => ws.on('open', resolve));
  302. ws.send(JSON.stringify({ type: 'hover', text: 'Something' }));
  303. await sleep(300);
  304. // Non-choice events should not create .events file
  305. assert(!fs.existsSync(eventsFile), '.events should not exist for non-choice events');
  306. ws.close();
  307. });
  308. await test('handles multiple concurrent WebSocket clients', async () => {
  309. const ws1 = new WebSocket(`ws://localhost:${TEST_PORT}`);
  310. const ws2 = new WebSocket(`ws://localhost:${TEST_PORT}`);
  311. await Promise.all([
  312. new Promise(resolve => ws1.on('open', resolve)),
  313. new Promise(resolve => ws2.on('open', resolve))
  314. ]);
  315. let ws1Reload = false;
  316. let ws2Reload = false;
  317. ws1.on('message', (data) => {
  318. if (JSON.parse(data.toString()).type === 'reload') ws1Reload = true;
  319. });
  320. ws2.on('message', (data) => {
  321. if (JSON.parse(data.toString()).type === 'reload') ws2Reload = true;
  322. });
  323. fs.writeFileSync(path.join(CONTENT_DIR, 'multi-client.html'), '<h2>Multi</h2>');
  324. await sleep(500);
  325. assert(ws1Reload, 'Client 1 should receive reload');
  326. assert(ws2Reload, 'Client 2 should receive reload');
  327. ws1.close();
  328. ws2.close();
  329. });
  330. await test('cleans up closed clients from broadcast list', async () => {
  331. const ws1 = new WebSocket(`ws://localhost:${TEST_PORT}`);
  332. await new Promise(resolve => ws1.on('open', resolve));
  333. ws1.close();
  334. await sleep(100);
  335. // This should not throw even though ws1 is closed
  336. fs.writeFileSync(path.join(CONTENT_DIR, 'after-close.html'), '<h2>After</h2>');
  337. await sleep(300);
  338. // If we got here without error, the test passes
  339. });
  340. await test('handles malformed JSON from client gracefully', async () => {
  341. const ws = new WebSocket(`ws://localhost:${TEST_PORT}`);
  342. await new Promise(resolve => ws.on('open', resolve));
  343. // Send invalid JSON — server should not crash
  344. ws.send('not json at all {{{');
  345. await sleep(300);
  346. // Verify server is still responsive
  347. const res = await fetch(`http://localhost:${TEST_PORT}/`);
  348. assert.strictEqual(res.status, 200, 'Server should still be running');
  349. ws.close();
  350. });
  351. // ========== File Watching ==========
  352. console.log('\n--- File Watching ---');
  353. await test('sends reload on new .html file', async () => {
  354. const ws = new WebSocket(`ws://localhost:${TEST_PORT}`);
  355. await new Promise(resolve => ws.on('open', resolve));
  356. let gotReload = false;
  357. ws.on('message', (data) => {
  358. if (JSON.parse(data.toString()).type === 'reload') gotReload = true;
  359. });
  360. fs.writeFileSync(path.join(CONTENT_DIR, 'watch-new.html'), '<h2>New</h2>');
  361. await sleep(500);
  362. assert(gotReload, 'Should send reload on new file');
  363. ws.close();
  364. });
  365. await test('sends reload on .html file change', async () => {
  366. const filePath = path.join(CONTENT_DIR, 'watch-change.html');
  367. fs.writeFileSync(filePath, '<h2>Original</h2>');
  368. await sleep(500);
  369. const ws = new WebSocket(`ws://localhost:${TEST_PORT}`);
  370. await new Promise(resolve => ws.on('open', resolve));
  371. let gotReload = false;
  372. ws.on('message', (data) => {
  373. if (JSON.parse(data.toString()).type === 'reload') gotReload = true;
  374. });
  375. fs.writeFileSync(filePath, '<h2>Modified</h2>');
  376. await sleep(500);
  377. assert(gotReload, 'Should send reload on file change');
  378. ws.close();
  379. });
  380. await test('does NOT send reload for non-.html files', async () => {
  381. const ws = new WebSocket(`ws://localhost:${TEST_PORT}`);
  382. await new Promise(resolve => ws.on('open', resolve));
  383. let gotReload = false;
  384. ws.on('message', (data) => {
  385. if (JSON.parse(data.toString()).type === 'reload') gotReload = true;
  386. });
  387. fs.writeFileSync(path.join(CONTENT_DIR, 'data.txt'), 'not html');
  388. await sleep(500);
  389. assert(!gotReload, 'Should NOT reload for non-HTML files');
  390. ws.close();
  391. });
  392. await test('clears state/events on new screen', async () => {
  393. // Create an events file
  394. const eventsFile = path.join(STATE_DIR, 'events');
  395. fs.writeFileSync(eventsFile, '{"choice":"a"}\n');
  396. assert(fs.existsSync(eventsFile));
  397. fs.writeFileSync(path.join(CONTENT_DIR, 'clear-events.html'), '<h2>New screen</h2>');
  398. await sleep(500);
  399. assert(!fs.existsSync(eventsFile), 'state/events should be cleared on new screen');
  400. });
  401. await test('logs screen-added on new file', async () => {
  402. stdoutAccum = '';
  403. fs.writeFileSync(path.join(CONTENT_DIR, 'log-test.html'), '<h2>Log</h2>');
  404. await sleep(500);
  405. assert(stdoutAccum.includes('screen-added'), 'Should log screen-added');
  406. });
  407. await test('logs screen-updated on file change', async () => {
  408. const filePath = path.join(CONTENT_DIR, 'log-update.html');
  409. fs.writeFileSync(filePath, '<h2>V1</h2>');
  410. await sleep(500);
  411. stdoutAccum = '';
  412. fs.writeFileSync(filePath, '<h2>V2</h2>');
  413. await sleep(500);
  414. assert(stdoutAccum.includes('screen-updated'), 'Should log screen-updated');
  415. });
  416. // ========== Helper.js Content ==========
  417. console.log('\n--- Helper.js Verification ---');
  418. await test('helper.js defines required APIs', () => {
  419. const helperContent = fs.readFileSync(
  420. path.join(__dirname, '../../skills/brainstorming/scripts/helper.js'), 'utf-8'
  421. );
  422. assert(helperContent.includes('toggleSelect'), 'Should define toggleSelect');
  423. assert(helperContent.includes('sendEvent'), 'Should define sendEvent');
  424. assert(helperContent.includes('selectedChoice'), 'Should track selectedChoice');
  425. assert(helperContent.includes('brainstorm'), 'Should expose brainstorm API');
  426. return Promise.resolve();
  427. });
  428. await test('helper.js keeps indicator fallback copy neutral', () => {
  429. const helperContent = fs.readFileSync(
  430. path.join(__dirname, '../../skills/brainstorming/scripts/helper.js'), 'utf-8'
  431. );
  432. assert(helperContent.includes('Interact with the mockup, then return to the terminal'), 'Should use neutral fallback copy');
  433. assert(!helperContent.includes('Click an option above, then return to the terminal'), 'Should not reset to selection-first copy');
  434. return Promise.resolve();
  435. });
  436. // ========== Frame Template ==========
  437. console.log('\n--- Frame Template Verification ---');
  438. await test('frame template has required structure', () => {
  439. const template = fs.readFileSync(
  440. path.join(__dirname, '../../skills/brainstorming/scripts/frame-template.html'), 'utf-8'
  441. );
  442. assert(template.includes('indicator-bar'), 'Should have indicator bar');
  443. assert(template.includes('indicator-text'), 'Should have indicator text');
  444. assert(template.includes('<!-- CONTENT -->'), 'Should have content placeholder');
  445. assert(template.includes('frame-content'), 'Should have content container');
  446. return Promise.resolve();
  447. });
  448. // ========== Summary ==========
  449. console.log(`\n--- Results: ${passed} passed, ${failed} failed ---`);
  450. if (failed > 0) process.exit(1);
  451. } finally {
  452. server.kill();
  453. await sleep(100);
  454. cleanup();
  455. }
  456. }
  457. runTests().catch(err => {
  458. console.error('Test failed:', err);
  459. process.exit(1);
  460. });