Przeglądaj źródła

Add design spec and tests for zero-dep brainstorm server

Replace vendored node_modules (714 files) with a single server.js
using only Node built-ins. Spec covers WebSocket protocol, HTTP
serving, file watching, and static file serving. Tests written
before implementation (TDD).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Jesse Vincent 6 miesięcy temu
rodzic
commit
9c98e01873

+ 118 - 0
docs/superpowers/specs/2026-03-11-zero-dep-brainstorm-server-design.md

@@ -0,0 +1,118 @@
+# Zero-Dependency Brainstorm Server
+
+Replace the brainstorm companion server's vendored node_modules (express, ws, chokidar — 714 tracked files) with a single zero-dependency `server.js` using only Node.js built-ins.
+
+## Motivation
+
+Vendoring node_modules into the git repo creates a supply chain risk: frozen dependencies don't get security patches, 714 files of third-party code are committed without audit, and modifications to vendored code look like normal commits. While the actual risk is low (localhost-only dev server), eliminating it is straightforward.
+
+## Architecture
+
+A single `server.js` file (~250-300 lines) using `http`, `crypto`, `fs`, and `path`. The file serves two roles:
+
+- **When run directly** (`node server.js`): starts the HTTP/WebSocket server
+- **When required** (`require('./server.js')`): exports WebSocket protocol functions for unit testing
+
+### WebSocket Protocol
+
+Implements RFC 6455 for text frames only:
+
+**Handshake:** Compute `Sec-WebSocket-Accept` from client's `Sec-WebSocket-Key` using SHA-1 + the RFC 6455 magic GUID. Return 101 Switching Protocols.
+
+**Frame decoding (client to server):** Handle three masked length encodings:
+- Small: payload < 126 bytes
+- Medium: 126-65535 bytes (16-bit extended)
+- Large: > 65535 bytes (64-bit extended)
+
+XOR-unmask payload using 4-byte mask key. Return `{ opcode, payload, bytesConsumed }` or `null` for incomplete buffers. Reject unmasked frames.
+
+**Frame encoding (server to client):** Unmasked frames with the same three length encodings.
+
+**Opcodes handled:** TEXT (0x01), CLOSE (0x08), PING (0x09), PONG (0x0A). Unrecognized opcodes get a close frame with status 1003 (Unsupported Data).
+
+**Deliberately skipped:** Binary frames, fragmented messages, extensions (permessage-deflate), subprotocols. These are unnecessary for small JSON text messages between localhost clients. Extensions and subprotocols are negotiated in the handshake — by not advertising them, they are never active.
+
+**Buffer accumulation:** Each connection maintains a buffer. On `data`, append and loop `decodeFrame` until it returns null or buffer is empty.
+
+### HTTP Server
+
+Three routes:
+
+1. **`GET /`** — Serve newest `.html` from screen directory by mtime. Detect full documents vs fragments, wrap fragments in frame template, inject helper.js. Return `text/html`. When no `.html` files exist, serve a hardcoded waiting page ("Waiting for Claude to push a screen...") with helper.js injected.
+2. **`GET /files/*`** — Serve static files from screen directory with MIME type lookup from a hardcoded extension map (html, css, js, png, jpg, gif, svg, json). Return 404 if not found.
+3. **Everything else** — 404.
+
+WebSocket upgrade handled via the `'upgrade'` event on the HTTP server, separate from the request handler.
+
+### Configuration
+
+Environment variables (all optional):
+
+- `BRAINSTORM_PORT` — port to bind (default: random high port 49152-65535)
+- `BRAINSTORM_HOST` — interface to bind (default: `127.0.0.1`)
+- `BRAINSTORM_URL_HOST` — hostname for the URL in startup JSON (default: `localhost` when host is `127.0.0.1`, otherwise same as host)
+- `BRAINSTORM_DIR` — screen directory path (default: `/tmp/brainstorm`)
+
+### Startup Sequence
+
+1. Create `SCREEN_DIR` if it doesn't exist (`mkdirSync` recursive)
+2. Load frame template and helper.js from `__dirname`
+3. Start HTTP server on configured host/port
+4. Start `fs.watch` on `SCREEN_DIR`
+5. On successful listen, log `server-started` JSON to stdout: `{ type, port, host, url_host, url, screen_dir }`
+6. Write the same JSON to `SCREEN_DIR/.server-info` so agents can find connection details when stdout is hidden (background execution)
+
+### Application-Level WebSocket Messages
+
+When a TEXT frame arrives from a client:
+
+1. Parse as JSON. If parsing fails, log to stderr and continue.
+2. Log to stdout as `{ source: 'user-event', ...event }`.
+3. If the event contains a `choice` property, append the JSON to `SCREEN_DIR/.events` (one line per event).
+
+### File Watching
+
+`fs.watch(SCREEN_DIR)` replaces chokidar. On HTML file events:
+
+- On new file (`rename` event for a file that exists): delete `.events` file if present (`unlinkSync`), log `screen-added` to stdout as JSON
+- On file change (`change` event): log `screen-updated` to stdout as JSON (do NOT clear `.events`)
+- Both events: send `{ type: 'reload' }` to all connected WebSocket clients
+
+Debounce per-filename with ~100ms timeout to prevent duplicate events (common on macOS and Linux).
+
+### Error Handling
+
+- Malformed JSON from WebSocket clients: log to stderr, continue
+- Unhandled opcodes: close with status 1003
+- Client disconnects: remove from broadcast set
+- `fs.watch` errors: log to stderr, continue
+- No graceful shutdown logic — shell scripts handle process lifecycle via SIGTERM
+
+## What Changes
+
+| Before | After |
+|---|---|
+| `index.js` + `package.json` + `package-lock.json` + 714 `node_modules` files | `server.js` (single file) |
+| express, ws, chokidar dependencies | none |
+| No static file serving | `/files/*` serves from screen directory |
+
+## What Stays the Same
+
+- `helper.js` — no changes
+- `frame-template.html` — no changes
+- `start-server.sh` — one-line update: `index.js` to `server.js`
+- `stop-server.sh` — no changes
+- `visual-companion.md` — no changes
+- All existing server behavior and external contract
+
+## Platform Compatibility
+
+- `server.js` uses only cross-platform Node built-ins
+- `fs.watch` is reliable for single flat directories on macOS, Linux, and Windows
+- Shell scripts require bash (Git Bash on Windows, which is required for Claude Code)
+
+## Testing
+
+**Unit tests** (`ws-protocol.test.js`): Test WebSocket frame encoding/decoding, handshake computation, and protocol edge cases directly by requiring `server.js` exports.
+
+**Integration tests** (`server.test.js`): Test full server behavior — HTTP serving, WebSocket communication, file watching, brainstorming workflow. Uses `ws` npm package as a test-only client dependency (not shipped to end users).

+ 368 - 136
tests/brainstorm-server/server.test.js

@@ -1,3 +1,13 @@
+/**
+ * Integration tests for the brainstorm server.
+ *
+ * Tests the full server behavior: HTTP serving, WebSocket communication,
+ * file watching, and the brainstorming workflow.
+ *
+ * Uses the `ws` npm package as a test client (test-only dependency,
+ * not shipped to end users).
+ */
+
 const { spawn } = require('child_process');
 const http = require('http');
 const WebSocket = require('ws');
@@ -5,7 +15,7 @@ const fs = require('fs');
 const path = require('path');
 const assert = require('assert');
 
-const SERVER_PATH = path.join(__dirname, '../../skills/brainstorming/scripts/index.js');
+const SERVER_PATH = path.join(__dirname, '../../skills/brainstorming/scripts/server.js');
 const TEST_PORT = 3334;
 const TEST_DIR = '/tmp/brainstorm-test';
 
@@ -24,7 +34,11 @@ async function fetch(url) {
     http.get(url, (res) => {
       let data = '';
       res.on('data', chunk => data += chunk);
-      res.on('end', () => resolve({ status: res.statusCode, body: data }));
+      res.on('end', () => resolve({
+        status: res.statusCode,
+        headers: res.headers,
+        body: data
+      }));
     }).on('error', reject);
   });
 }
@@ -35,153 +49,371 @@ function startServer() {
   });
 }
 
+async function waitForServer(server) {
+  let stdout = '';
+  let stderr = '';
+
+  return new Promise((resolve, reject) => {
+    server.stdout.on('data', (data) => {
+      stdout += data.toString();
+      if (stdout.includes('server-started')) {
+        resolve({ stdout, stderr, getStdout: () => stdout });
+      }
+    });
+    server.stderr.on('data', (data) => { stderr += data.toString(); });
+    server.on('error', reject);
+
+    setTimeout(() => reject(new Error(`Server didn't start. stderr: ${stderr}`)), 5000);
+  });
+}
+
 async function runTests() {
   cleanup();
   fs.mkdirSync(TEST_DIR, { recursive: true });
 
   const server = startServer();
+  let stdoutAccum = '';
+  server.stdout.on('data', (data) => { stdoutAccum += data.toString(); });
 
-  let stdout = '';
-  let stderr = '';
-  server.stdout.on('data', (data) => { stdout += data.toString(); });
-  server.stderr.on('data', (data) => { stderr += data.toString(); });
+  const { stdout: initialStdout } = await waitForServer(server);
+  let passed = 0;
+  let failed = 0;
 
-  // Wait for server to start (up to 3 seconds)
-  for (let i = 0; i < 30; i++) {
-    if (stdout.includes('server-started')) break;
-    await sleep(100);
+  function test(name, fn) {
+    return fn().then(() => {
+      console.log(`  PASS: ${name}`);
+      passed++;
+    }).catch(e => {
+      console.log(`  FAIL: ${name}`);
+      console.log(`    ${e.message}`);
+      failed++;
+    });
   }
-  if (stderr) console.error('Server stderr:', stderr);
 
   try {
-    // Test 1: Server starts and outputs JSON
-    console.log('Test 1: Server startup message');
-    assert(stdout.includes('server-started'), 'Should output server-started');
-    assert(stdout.includes(TEST_PORT.toString()), 'Should include port');
-    console.log('  PASS');
-
-    // Test 2: GET / returns waiting page with helper injected when no screens exist
-    console.log('Test 2: Serves waiting page with helper injected');
-    const res = await fetch(`http://localhost:${TEST_PORT}/`);
-    assert.strictEqual(res.status, 200);
-    assert(res.body.includes('Waiting for Claude'), 'Should show waiting message');
-    assert(res.body.includes('WebSocket'), 'Should have helper.js injected');
-    console.log('  PASS');
-
-    // Test 3: WebSocket connection and event relay
-    console.log('Test 3: WebSocket relays events to stdout');
-    stdout = '';
-    const ws = new WebSocket(`ws://localhost:${TEST_PORT}`);
-    await new Promise(resolve => ws.on('open', resolve));
-
-    ws.send(JSON.stringify({ type: 'click', text: 'Test Button' }));
-    await sleep(300);
-
-    assert(stdout.includes('"source":"user-event"'), 'Should relay user events with source field');
-    assert(stdout.includes('Test Button'), 'Should include event data');
-    ws.close();
-    console.log('  PASS');
-
-    // Test 4: File change triggers reload notification
-    console.log('Test 4: File change notifies browsers');
-    const ws2 = new WebSocket(`ws://localhost:${TEST_PORT}`);
-    await new Promise(resolve => ws2.on('open', resolve));
-
-    let gotReload = false;
-    ws2.on('message', (data) => {
-      const msg = JSON.parse(data.toString());
-      if (msg.type === 'reload') gotReload = true;
-    });
-
-    fs.writeFileSync(path.join(TEST_DIR, 'test-screen.html'), '<html><body>Full doc</body></html>');
-    await sleep(500);
-
-    assert(gotReload, 'Should send reload message on file change');
-    ws2.close();
-    console.log('  PASS');
-
-    // Test: Choice events written to .events file
-    console.log('Test: Choice events written to .events file');
-    const ws3 = new WebSocket(`ws://localhost:${TEST_PORT}`);
-    await new Promise(resolve => ws3.on('open', resolve));
-
-    ws3.send(JSON.stringify({ type: 'click', choice: 'a', text: 'Option A' }));
-    await sleep(300);
-
-    const eventsFile = path.join(TEST_DIR, '.events');
-    assert(fs.existsSync(eventsFile), '.events file should exist after choice click');
-    const lines = fs.readFileSync(eventsFile, 'utf-8').trim().split('\n');
-    const event = JSON.parse(lines[lines.length - 1]);
-    assert.strictEqual(event.choice, 'a', 'Event should contain choice');
-    assert.strictEqual(event.text, 'Option A', 'Event should contain text');
-    ws3.close();
-    console.log('  PASS');
-
-    // Test: .events cleared on new screen
-    console.log('Test: .events cleared on new screen');
-    // .events file should still exist from previous test
-    assert(fs.existsSync(path.join(TEST_DIR, '.events')), '.events should exist before new screen');
-    fs.writeFileSync(path.join(TEST_DIR, 'new-screen.html'), '<h2>New screen</h2>');
-    await sleep(500);
-    assert(!fs.existsSync(path.join(TEST_DIR, '.events')), '.events should be cleared after new screen');
-    console.log('  PASS');
-
-    // Test 5: Full HTML document served as-is (not wrapped)
-    console.log('Test 5: Full HTML document served without frame wrapping');
-    const fullDoc = '<!DOCTYPE html>\n<html><head><title>Custom</title></head><body><h1>Custom Page</h1></body></html>';
-    fs.writeFileSync(path.join(TEST_DIR, 'full-doc.html'), fullDoc);
-    await sleep(300);
-
-    const fullRes = await fetch(`http://localhost:${TEST_PORT}/`);
-    assert(fullRes.body.includes('<h1>Custom Page</h1>'), 'Should contain original content');
-    assert(fullRes.body.includes('WebSocket'), 'Should still inject helper.js');
-    // Should NOT have the frame template's indicator bar
-    assert(!fullRes.body.includes('indicator-bar') || fullDoc.includes('indicator-bar'),
-      'Should not wrap full documents in frame template');
-    console.log('  PASS');
-
-    // Test 6: Bare HTML fragment gets wrapped in frame template
-    console.log('Test 6: Content fragment wrapped in frame template');
-    const fragment = '<h2>Pick a layout</h2>\n<p class="subtitle">Choose one</p>\n<div class="options"><div class="option" data-choice="a"><div class="letter">A</div><div class="content"><h3>Simple</h3></div></div></div>';
-    fs.writeFileSync(path.join(TEST_DIR, 'fragment.html'), fragment);
-    await sleep(300);
-
-    const fragRes = await fetch(`http://localhost:${TEST_PORT}/`);
-    // Should have the frame template structure
-    assert(fragRes.body.includes('indicator-bar'), 'Fragment should get indicator bar from frame');
-    assert(!fragRes.body.includes('<!-- CONTENT -->'), 'Content placeholder should be replaced');
-    // Should have the original content inside
-    assert(fragRes.body.includes('Pick a layout'), 'Fragment content should be present');
-    assert(fragRes.body.includes('data-choice="a"'), 'Fragment content should be intact');
-    // Should have helper.js injected
-    assert(fragRes.body.includes('WebSocket'), 'Fragment should have helper.js injected');
-    console.log('  PASS');
-
-    // Test 7: Helper.js includes toggleSelect and send functions
-    console.log('Test 7: Helper.js provides toggleSelect and send');
-    const helperContent = fs.readFileSync(
-      path.join(__dirname, '../../skills/brainstorming/scripts/helper.js'), 'utf-8'
-    );
-    assert(helperContent.includes('toggleSelect'), 'helper.js should define toggleSelect');
-    assert(helperContent.includes('sendEvent'), 'helper.js should define sendEvent');
-    assert(helperContent.includes('selectedChoice'), 'helper.js should track selectedChoice');
-    assert(helperContent.includes('brainstorm'), 'helper.js should expose brainstorm API');
-    assert(!helperContent.includes('sendToClaude'), 'helper.js should not contain sendToClaude');
-    console.log('  PASS');
-
-    // Test 8: Indicator bar uses CSS variables (theme support)
-    console.log('Test 8: Indicator bar uses CSS variables');
-    const templateContent = fs.readFileSync(
-      path.join(__dirname, '../../skills/brainstorming/scripts/frame-template.html'), 'utf-8'
-    );
-    assert(templateContent.includes('indicator-bar'), 'Template should have indicator bar');
-    assert(templateContent.includes('indicator-text'), 'Template should have indicator text element');
-    console.log('  PASS');
-
-    console.log('\nAll tests passed!');
+    // ========== Server Startup ==========
+    console.log('\n--- Server Startup ---');
+
+    await test('outputs server-started JSON on startup', () => {
+      const msg = JSON.parse(initialStdout.trim());
+      assert.strictEqual(msg.type, 'server-started');
+      assert.strictEqual(msg.port, TEST_PORT);
+      assert(msg.url, 'Should include URL');
+      assert(msg.screen_dir, 'Should include screen_dir');
+      return Promise.resolve();
+    });
+
+    await test('writes .server-info file', () => {
+      const infoPath = path.join(TEST_DIR, '.server-info');
+      assert(fs.existsSync(infoPath), '.server-info should exist');
+      const info = JSON.parse(fs.readFileSync(infoPath, 'utf-8').trim());
+      assert.strictEqual(info.type, 'server-started');
+      assert.strictEqual(info.port, TEST_PORT);
+      return Promise.resolve();
+    });
+
+    // ========== HTTP Serving ==========
+    console.log('\n--- HTTP Serving ---');
+
+    await test('serves waiting page when no screens exist', async () => {
+      const res = await fetch(`http://localhost:${TEST_PORT}/`);
+      assert.strictEqual(res.status, 200);
+      assert(res.body.includes('Waiting for Claude'), 'Should show waiting message');
+    });
+
+    await test('injects helper.js into waiting page', async () => {
+      const res = await fetch(`http://localhost:${TEST_PORT}/`);
+      assert(res.body.includes('WebSocket'), 'Should have helper.js injected');
+      assert(res.body.includes('toggleSelect'), 'Should have toggleSelect from helper');
+      assert(res.body.includes('brainstorm'), 'Should have brainstorm API from helper');
+    });
+
+    await test('returns Content-Type text/html', async () => {
+      const res = await fetch(`http://localhost:${TEST_PORT}/`);
+      assert(res.headers['content-type'].includes('text/html'), 'Should be text/html');
+    });
+
+    await test('serves full HTML documents as-is (not wrapped)', async () => {
+      const fullDoc = '<!DOCTYPE html>\n<html><head><title>Custom</title></head><body><h1>Custom Page</h1></body></html>';
+      fs.writeFileSync(path.join(TEST_DIR, 'full-doc.html'), fullDoc);
+      await sleep(300);
+
+      const res = await fetch(`http://localhost:${TEST_PORT}/`);
+      assert(res.body.includes('<h1>Custom Page</h1>'), 'Should contain original content');
+      assert(res.body.includes('WebSocket'), 'Should still inject helper.js');
+      assert(!res.body.includes('indicator-bar'), 'Should NOT wrap in frame template');
+    });
+
+    await test('wraps content fragments in frame template', async () => {
+      const fragment = '<h2>Pick a layout</h2>\n<div class="options"><div class="option" data-choice="a"><div class="letter">A</div></div></div>';
+      fs.writeFileSync(path.join(TEST_DIR, 'fragment.html'), fragment);
+      await sleep(300);
+
+      const res = await fetch(`http://localhost:${TEST_PORT}/`);
+      assert(res.body.includes('indicator-bar'), 'Fragment should get indicator bar');
+      assert(!res.body.includes('<!-- CONTENT -->'), 'Placeholder should be replaced');
+      assert(res.body.includes('Pick a layout'), 'Fragment content should be present');
+      assert(res.body.includes('data-choice="a"'), 'Fragment interactive elements intact');
+    });
+
+    await test('serves newest file by mtime', async () => {
+      fs.writeFileSync(path.join(TEST_DIR, 'older.html'), '<h2>Older</h2>');
+      await sleep(100);
+      fs.writeFileSync(path.join(TEST_DIR, 'newer.html'), '<h2>Newer</h2>');
+      await sleep(300);
+
+      const res = await fetch(`http://localhost:${TEST_PORT}/`);
+      assert(res.body.includes('Newer'), 'Should serve newest file');
+    });
+
+    await test('ignores non-html files for serving', async () => {
+      // Write a newer non-HTML file — should still serve newest .html
+      fs.writeFileSync(path.join(TEST_DIR, 'data.json'), '{"not": "html"}');
+      await sleep(300);
+
+      const res = await fetch(`http://localhost:${TEST_PORT}/`);
+      assert(res.body.includes('Newer'), 'Should still serve newest HTML');
+      assert(!res.body.includes('"not"'), 'Should not serve JSON');
+    });
+
+    await test('returns 404 for non-root paths', async () => {
+      const res = await fetch(`http://localhost:${TEST_PORT}/other`);
+      assert.strictEqual(res.status, 404);
+    });
+
+    // ========== WebSocket Communication ==========
+    console.log('\n--- WebSocket Communication ---');
+
+    await test('accepts WebSocket upgrade on /', async () => {
+      const ws = new WebSocket(`ws://localhost:${TEST_PORT}`);
+      await new Promise((resolve, reject) => {
+        ws.on('open', resolve);
+        ws.on('error', reject);
+      });
+      ws.close();
+    });
+
+    await test('relays user events to stdout with source field', async () => {
+      stdoutAccum = '';
+      const ws = new WebSocket(`ws://localhost:${TEST_PORT}`);
+      await new Promise(resolve => ws.on('open', resolve));
+
+      ws.send(JSON.stringify({ type: 'click', text: 'Test Button' }));
+      await sleep(300);
+
+      assert(stdoutAccum.includes('"source":"user-event"'), 'Should tag with source');
+      assert(stdoutAccum.includes('Test Button'), 'Should include event data');
+      ws.close();
+    });
+
+    await test('writes choice events to .events file', async () => {
+      // Clean up events from prior tests
+      const eventsFile = path.join(TEST_DIR, '.events');
+      if (fs.existsSync(eventsFile)) fs.unlinkSync(eventsFile);
+
+      const ws = new WebSocket(`ws://localhost:${TEST_PORT}`);
+      await new Promise(resolve => ws.on('open', resolve));
+
+      ws.send(JSON.stringify({ type: 'click', choice: 'b', text: 'Option B' }));
+      await sleep(300);
+
+      assert(fs.existsSync(eventsFile), '.events should exist');
+      const lines = fs.readFileSync(eventsFile, 'utf-8').trim().split('\n');
+      const event = JSON.parse(lines[lines.length - 1]);
+      assert.strictEqual(event.choice, 'b');
+      assert.strictEqual(event.text, 'Option B');
+      ws.close();
+    });
+
+    await test('does NOT write non-choice events to .events file', async () => {
+      const eventsFile = path.join(TEST_DIR, '.events');
+      if (fs.existsSync(eventsFile)) fs.unlinkSync(eventsFile);
+
+      const ws = new WebSocket(`ws://localhost:${TEST_PORT}`);
+      await new Promise(resolve => ws.on('open', resolve));
+
+      ws.send(JSON.stringify({ type: 'hover', text: 'Something' }));
+      await sleep(300);
+
+      // Non-choice events should not create .events file
+      assert(!fs.existsSync(eventsFile), '.events should not exist for non-choice events');
+      ws.close();
+    });
+
+    await test('handles multiple concurrent WebSocket clients', async () => {
+      const ws1 = new WebSocket(`ws://localhost:${TEST_PORT}`);
+      const ws2 = new WebSocket(`ws://localhost:${TEST_PORT}`);
+      await Promise.all([
+        new Promise(resolve => ws1.on('open', resolve)),
+        new Promise(resolve => ws2.on('open', resolve))
+      ]);
+
+      let ws1Reload = false;
+      let ws2Reload = false;
+      ws1.on('message', (data) => {
+        if (JSON.parse(data.toString()).type === 'reload') ws1Reload = true;
+      });
+      ws2.on('message', (data) => {
+        if (JSON.parse(data.toString()).type === 'reload') ws2Reload = true;
+      });
+
+      fs.writeFileSync(path.join(TEST_DIR, 'multi-client.html'), '<h2>Multi</h2>');
+      await sleep(500);
+
+      assert(ws1Reload, 'Client 1 should receive reload');
+      assert(ws2Reload, 'Client 2 should receive reload');
+      ws1.close();
+      ws2.close();
+    });
+
+    await test('cleans up closed clients from broadcast list', async () => {
+      const ws1 = new WebSocket(`ws://localhost:${TEST_PORT}`);
+      await new Promise(resolve => ws1.on('open', resolve));
+      ws1.close();
+      await sleep(100);
+
+      // This should not throw even though ws1 is closed
+      fs.writeFileSync(path.join(TEST_DIR, 'after-close.html'), '<h2>After</h2>');
+      await sleep(300);
+      // If we got here without error, the test passes
+    });
+
+    await test('handles malformed JSON from client gracefully', async () => {
+      const ws = new WebSocket(`ws://localhost:${TEST_PORT}`);
+      await new Promise(resolve => ws.on('open', resolve));
+
+      // Send invalid JSON — server should not crash
+      ws.send('not json at all {{{');
+      await sleep(300);
+
+      // Verify server is still responsive
+      const res = await fetch(`http://localhost:${TEST_PORT}/`);
+      assert.strictEqual(res.status, 200, 'Server should still be running');
+      ws.close();
+    });
+
+    // ========== File Watching ==========
+    console.log('\n--- File Watching ---');
+
+    await test('sends reload on new .html file', async () => {
+      const ws = new WebSocket(`ws://localhost:${TEST_PORT}`);
+      await new Promise(resolve => ws.on('open', resolve));
+
+      let gotReload = false;
+      ws.on('message', (data) => {
+        if (JSON.parse(data.toString()).type === 'reload') gotReload = true;
+      });
+
+      fs.writeFileSync(path.join(TEST_DIR, 'watch-new.html'), '<h2>New</h2>');
+      await sleep(500);
+
+      assert(gotReload, 'Should send reload on new file');
+      ws.close();
+    });
+
+    await test('sends reload on .html file change', async () => {
+      const filePath = path.join(TEST_DIR, 'watch-change.html');
+      fs.writeFileSync(filePath, '<h2>Original</h2>');
+      await sleep(500);
+
+      const ws = new WebSocket(`ws://localhost:${TEST_PORT}`);
+      await new Promise(resolve => ws.on('open', resolve));
+
+      let gotReload = false;
+      ws.on('message', (data) => {
+        if (JSON.parse(data.toString()).type === 'reload') gotReload = true;
+      });
+
+      fs.writeFileSync(filePath, '<h2>Modified</h2>');
+      await sleep(500);
+
+      assert(gotReload, 'Should send reload on file change');
+      ws.close();
+    });
+
+    await test('does NOT send reload for non-.html files', async () => {
+      const ws = new WebSocket(`ws://localhost:${TEST_PORT}`);
+      await new Promise(resolve => ws.on('open', resolve));
+
+      let gotReload = false;
+      ws.on('message', (data) => {
+        if (JSON.parse(data.toString()).type === 'reload') gotReload = true;
+      });
+
+      fs.writeFileSync(path.join(TEST_DIR, 'data.txt'), 'not html');
+      await sleep(500);
+
+      assert(!gotReload, 'Should NOT reload for non-HTML files');
+      ws.close();
+    });
+
+    await test('clears .events on new screen', async () => {
+      // Create an .events file
+      const eventsFile = path.join(TEST_DIR, '.events');
+      fs.writeFileSync(eventsFile, '{"choice":"a"}\n');
+      assert(fs.existsSync(eventsFile));
+
+      fs.writeFileSync(path.join(TEST_DIR, 'clear-events.html'), '<h2>New screen</h2>');
+      await sleep(500);
+
+      assert(!fs.existsSync(eventsFile), '.events should be cleared on new screen');
+    });
+
+    await test('logs screen-added on new file', async () => {
+      stdoutAccum = '';
+      fs.writeFileSync(path.join(TEST_DIR, 'log-test.html'), '<h2>Log</h2>');
+      await sleep(500);
+
+      assert(stdoutAccum.includes('screen-added'), 'Should log screen-added');
+    });
+
+    await test('logs screen-updated on file change', async () => {
+      const filePath = path.join(TEST_DIR, 'log-update.html');
+      fs.writeFileSync(filePath, '<h2>V1</h2>');
+      await sleep(500);
+
+      stdoutAccum = '';
+      fs.writeFileSync(filePath, '<h2>V2</h2>');
+      await sleep(500);
+
+      assert(stdoutAccum.includes('screen-updated'), 'Should log screen-updated');
+    });
+
+    // ========== Helper.js Content ==========
+    console.log('\n--- Helper.js Verification ---');
+
+    await test('helper.js defines required APIs', () => {
+      const helperContent = fs.readFileSync(
+        path.join(__dirname, '../../skills/brainstorming/scripts/helper.js'), 'utf-8'
+      );
+      assert(helperContent.includes('toggleSelect'), 'Should define toggleSelect');
+      assert(helperContent.includes('sendEvent'), 'Should define sendEvent');
+      assert(helperContent.includes('selectedChoice'), 'Should track selectedChoice');
+      assert(helperContent.includes('brainstorm'), 'Should expose brainstorm API');
+      return Promise.resolve();
+    });
+
+    // ========== Frame Template ==========
+    console.log('\n--- Frame Template Verification ---');
+
+    await test('frame template has required structure', () => {
+      const template = fs.readFileSync(
+        path.join(__dirname, '../../skills/brainstorming/scripts/frame-template.html'), 'utf-8'
+      );
+      assert(template.includes('indicator-bar'), 'Should have indicator bar');
+      assert(template.includes('indicator-text'), 'Should have indicator text');
+      assert(template.includes('<!-- CONTENT -->'), 'Should have content placeholder');
+      assert(template.includes('claude-content'), 'Should have content container');
+      return Promise.resolve();
+    });
+
+    // ========== Summary ==========
+    console.log(`\n--- Results: ${passed} passed, ${failed} failed ---`);
+    if (failed > 0) process.exit(1);
 
   } finally {
     server.kill();
+    await sleep(100);
     cleanup();
   }
 }

+ 392 - 0
tests/brainstorm-server/ws-protocol.test.js

@@ -0,0 +1,392 @@
+/**
+ * Unit tests for the zero-dependency WebSocket protocol implementation.
+ *
+ * Tests the WebSocket frame encoding/decoding, handshake computation,
+ * and protocol-level behavior independent of the HTTP server.
+ *
+ * The module under test exports:
+ *   - computeAcceptKey(clientKey) -> string
+ *   - encodeFrame(opcode, payload) -> Buffer
+ *   - decodeFrame(buffer) -> { opcode, payload, bytesConsumed } | null
+ *   - OPCODES: { TEXT, CLOSE, PING, PONG }
+ */
+
+const assert = require('assert');
+const crypto = require('crypto');
+const path = require('path');
+
+// The module under test — will be the new zero-dep server file
+const SERVER_PATH = path.join(__dirname, '../../skills/brainstorming/scripts/server.js');
+let ws;
+
+try {
+  ws = require(SERVER_PATH);
+} catch (e) {
+  // Module doesn't exist yet (TDD — tests written before implementation)
+  console.error(`Cannot load ${SERVER_PATH}: ${e.message}`);
+  console.error('This is expected if running tests before implementation.');
+  process.exit(1);
+}
+
+function runTests() {
+  let passed = 0;
+  let failed = 0;
+
+  function test(name, fn) {
+    try {
+      fn();
+      console.log(`  PASS: ${name}`);
+      passed++;
+    } catch (e) {
+      console.log(`  FAIL: ${name}`);
+      console.log(`    ${e.message}`);
+      failed++;
+    }
+  }
+
+  // ========== Handshake ==========
+  console.log('\n--- WebSocket Handshake ---');
+
+  test('computeAcceptKey produces correct RFC 6455 accept value', () => {
+    // RFC 6455 Section 4.2.2 example
+    // The magic GUID is "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
+    const clientKey = 'dGhlIHNhbXBsZSBub25jZQ==';
+    const expected = 's3pPLMBiTxaQ9kYGzzhZRbK+xOo=';
+    assert.strictEqual(ws.computeAcceptKey(clientKey), expected);
+  });
+
+  test('computeAcceptKey produces valid base64 for random keys', () => {
+    for (let i = 0; i < 10; i++) {
+      const randomKey = crypto.randomBytes(16).toString('base64');
+      const result = ws.computeAcceptKey(randomKey);
+      // Result should be valid base64
+      assert.strictEqual(Buffer.from(result, 'base64').toString('base64'), result);
+      // SHA-1 output is 20 bytes, base64 encoded = 28 chars
+      assert.strictEqual(result.length, 28);
+    }
+  });
+
+  // ========== Frame Encoding ==========
+  console.log('\n--- Frame Encoding (server -> client) ---');
+
+  test('encodes small text frame (< 126 bytes)', () => {
+    const payload = 'Hello';
+    const frame = ws.encodeFrame(ws.OPCODES.TEXT, Buffer.from(payload));
+    // FIN bit + TEXT opcode = 0x81, length = 5
+    assert.strictEqual(frame[0], 0x81);
+    assert.strictEqual(frame[1], 5);
+    assert.strictEqual(frame.slice(2).toString(), 'Hello');
+    assert.strictEqual(frame.length, 7);
+  });
+
+  test('encodes empty text frame', () => {
+    const frame = ws.encodeFrame(ws.OPCODES.TEXT, Buffer.alloc(0));
+    assert.strictEqual(frame[0], 0x81);
+    assert.strictEqual(frame[1], 0);
+    assert.strictEqual(frame.length, 2);
+  });
+
+  test('encodes medium text frame (126-65535 bytes)', () => {
+    const payload = Buffer.alloc(200, 0x41); // 200 'A's
+    const frame = ws.encodeFrame(ws.OPCODES.TEXT, payload);
+    assert.strictEqual(frame[0], 0x81);
+    assert.strictEqual(frame[1], 126); // extended length marker
+    assert.strictEqual(frame.readUInt16BE(2), 200);
+    assert.strictEqual(frame.slice(4).toString(), payload.toString());
+    assert.strictEqual(frame.length, 204);
+  });
+
+  test('encodes frame at exactly 126 bytes (boundary)', () => {
+    const payload = Buffer.alloc(126, 0x42);
+    const frame = ws.encodeFrame(ws.OPCODES.TEXT, payload);
+    assert.strictEqual(frame[1], 126); // extended length marker
+    assert.strictEqual(frame.readUInt16BE(2), 126);
+    assert.strictEqual(frame.length, 130);
+  });
+
+  test('encodes frame at exactly 125 bytes (max small)', () => {
+    const payload = Buffer.alloc(125, 0x43);
+    const frame = ws.encodeFrame(ws.OPCODES.TEXT, payload);
+    assert.strictEqual(frame[1], 125);
+    assert.strictEqual(frame.length, 127);
+  });
+
+  test('encodes large frame (> 65535 bytes)', () => {
+    const payload = Buffer.alloc(70000, 0x44);
+    const frame = ws.encodeFrame(ws.OPCODES.TEXT, payload);
+    assert.strictEqual(frame[0], 0x81);
+    assert.strictEqual(frame[1], 127); // 64-bit length marker
+    // 8-byte extended length at offset 2
+    const len = Number(frame.readBigUInt64BE(2));
+    assert.strictEqual(len, 70000);
+    assert.strictEqual(frame.length, 10 + 70000);
+  });
+
+  test('encodes close frame', () => {
+    const frame = ws.encodeFrame(ws.OPCODES.CLOSE, Buffer.alloc(0));
+    assert.strictEqual(frame[0], 0x88); // FIN + CLOSE
+    assert.strictEqual(frame[1], 0);
+  });
+
+  test('encodes pong frame with payload', () => {
+    const payload = Buffer.from('ping-data');
+    const frame = ws.encodeFrame(ws.OPCODES.PONG, payload);
+    assert.strictEqual(frame[0], 0x8A); // FIN + PONG
+    assert.strictEqual(frame[1], payload.length);
+    assert.strictEqual(frame.slice(2).toString(), 'ping-data');
+  });
+
+  test('server frames are never masked (per RFC 6455)', () => {
+    const frame = ws.encodeFrame(ws.OPCODES.TEXT, Buffer.from('test'));
+    // Bit 7 of byte 1 is the mask bit — must be 0 for server frames
+    assert.strictEqual(frame[1] & 0x80, 0);
+  });
+
+  // ========== Frame Decoding ==========
+  console.log('\n--- Frame Decoding (client -> server) ---');
+
+  // Helper: create a masked client frame
+  function makeClientFrame(opcode, payload, fin = true) {
+    const buf = Buffer.from(payload);
+    const mask = crypto.randomBytes(4);
+    const masked = Buffer.alloc(buf.length);
+    for (let i = 0; i < buf.length; i++) {
+      masked[i] = buf[i] ^ mask[i % 4];
+    }
+
+    let header;
+    const finBit = fin ? 0x80 : 0x00;
+    if (buf.length < 126) {
+      header = Buffer.alloc(6);
+      header[0] = finBit | opcode;
+      header[1] = 0x80 | buf.length; // mask bit set
+      mask.copy(header, 2);
+    } else if (buf.length < 65536) {
+      header = Buffer.alloc(8);
+      header[0] = finBit | opcode;
+      header[1] = 0x80 | 126;
+      header.writeUInt16BE(buf.length, 2);
+      mask.copy(header, 4);
+    } else {
+      header = Buffer.alloc(14);
+      header[0] = finBit | opcode;
+      header[1] = 0x80 | 127;
+      header.writeBigUInt64BE(BigInt(buf.length), 2);
+      mask.copy(header, 10);
+    }
+
+    return Buffer.concat([header, masked]);
+  }
+
+  test('decodes small masked text frame', () => {
+    const frame = makeClientFrame(0x01, 'Hello');
+    const result = ws.decodeFrame(frame);
+    assert(result, 'Should return a result');
+    assert.strictEqual(result.opcode, ws.OPCODES.TEXT);
+    assert.strictEqual(result.payload.toString(), 'Hello');
+    assert.strictEqual(result.bytesConsumed, frame.length);
+  });
+
+  test('decodes empty masked text frame', () => {
+    const frame = makeClientFrame(0x01, '');
+    const result = ws.decodeFrame(frame);
+    assert(result, 'Should return a result');
+    assert.strictEqual(result.opcode, ws.OPCODES.TEXT);
+    assert.strictEqual(result.payload.length, 0);
+  });
+
+  test('decodes medium masked text frame (126-65535 bytes)', () => {
+    const payload = 'A'.repeat(200);
+    const frame = makeClientFrame(0x01, payload);
+    const result = ws.decodeFrame(frame);
+    assert(result, 'Should return a result');
+    assert.strictEqual(result.payload.toString(), payload);
+  });
+
+  test('decodes large masked text frame (> 65535 bytes)', () => {
+    const payload = 'B'.repeat(70000);
+    const frame = makeClientFrame(0x01, payload);
+    const result = ws.decodeFrame(frame);
+    assert(result, 'Should return a result');
+    assert.strictEqual(result.payload.length, 70000);
+    assert.strictEqual(result.payload.toString(), payload);
+  });
+
+  test('decodes masked close frame', () => {
+    const frame = makeClientFrame(0x08, '');
+    const result = ws.decodeFrame(frame);
+    assert(result, 'Should return a result');
+    assert.strictEqual(result.opcode, ws.OPCODES.CLOSE);
+  });
+
+  test('decodes masked ping frame', () => {
+    const frame = makeClientFrame(0x09, 'ping!');
+    const result = ws.decodeFrame(frame);
+    assert(result, 'Should return a result');
+    assert.strictEqual(result.opcode, ws.OPCODES.PING);
+    assert.strictEqual(result.payload.toString(), 'ping!');
+  });
+
+  test('returns null for incomplete frame (not enough header bytes)', () => {
+    const result = ws.decodeFrame(Buffer.from([0x81]));
+    assert.strictEqual(result, null, 'Should return null for 1-byte buffer');
+  });
+
+  test('returns null for incomplete frame (header ok, payload truncated)', () => {
+    // Create a valid frame then truncate it
+    const frame = makeClientFrame(0x01, 'Hello World');
+    const truncated = frame.slice(0, frame.length - 3);
+    const result = ws.decodeFrame(truncated);
+    assert.strictEqual(result, null, 'Should return null for truncated frame');
+  });
+
+  test('returns null for incomplete extended-length header', () => {
+    // Frame claiming 16-bit length but only 3 bytes total
+    const buf = Buffer.alloc(3);
+    buf[0] = 0x81;
+    buf[1] = 0x80 | 126; // masked, 16-bit extended
+    // Missing the 2 length bytes + mask
+    const result = ws.decodeFrame(buf);
+    assert.strictEqual(result, null);
+  });
+
+  test('rejects unmasked client frame', () => {
+    // Server MUST reject unmasked client frames per RFC 6455 Section 5.1
+    const buf = Buffer.alloc(7);
+    buf[0] = 0x81; // FIN + TEXT
+    buf[1] = 5;    // length 5, NO mask bit
+    Buffer.from('Hello').copy(buf, 2);
+    assert.throws(() => ws.decodeFrame(buf), /mask/i, 'Should reject unmasked client frame');
+  });
+
+  test('handles multiple frames in a single buffer', () => {
+    const frame1 = makeClientFrame(0x01, 'first');
+    const frame2 = makeClientFrame(0x01, 'second');
+    const combined = Buffer.concat([frame1, frame2]);
+
+    const result1 = ws.decodeFrame(combined);
+    assert(result1, 'Should decode first frame');
+    assert.strictEqual(result1.payload.toString(), 'first');
+    assert.strictEqual(result1.bytesConsumed, frame1.length);
+
+    const result2 = ws.decodeFrame(combined.slice(result1.bytesConsumed));
+    assert(result2, 'Should decode second frame');
+    assert.strictEqual(result2.payload.toString(), 'second');
+  });
+
+  test('correctly unmasks with all mask byte values', () => {
+    // Use a known mask to verify unmasking arithmetic
+    const payload = Buffer.from('ABCDEFGH');
+    const mask = Buffer.from([0xFF, 0x00, 0xAA, 0x55]);
+    const masked = Buffer.alloc(payload.length);
+    for (let i = 0; i < payload.length; i++) {
+      masked[i] = payload[i] ^ mask[i % 4];
+    }
+
+    // Build frame manually
+    const header = Buffer.alloc(6);
+    header[0] = 0x81; // FIN + TEXT
+    header[1] = 0x80 | payload.length;
+    mask.copy(header, 2);
+    const frame = Buffer.concat([header, masked]);
+
+    const result = ws.decodeFrame(frame);
+    assert.strictEqual(result.payload.toString(), 'ABCDEFGH');
+  });
+
+  // ========== Frame Encoding Boundary at 65535/65536 ==========
+  console.log('\n--- Frame Size Boundaries ---');
+
+  test('encodes frame at exactly 65535 bytes (max 16-bit)', () => {
+    const payload = Buffer.alloc(65535, 0x45);
+    const frame = ws.encodeFrame(ws.OPCODES.TEXT, payload);
+    assert.strictEqual(frame[1], 126);
+    assert.strictEqual(frame.readUInt16BE(2), 65535);
+    assert.strictEqual(frame.length, 4 + 65535);
+  });
+
+  test('encodes frame at exactly 65536 bytes (min 64-bit)', () => {
+    const payload = Buffer.alloc(65536, 0x46);
+    const frame = ws.encodeFrame(ws.OPCODES.TEXT, payload);
+    assert.strictEqual(frame[1], 127);
+    assert.strictEqual(Number(frame.readBigUInt64BE(2)), 65536);
+    assert.strictEqual(frame.length, 10 + 65536);
+  });
+
+  test('decodes frame at 65535 bytes boundary', () => {
+    const payload = 'X'.repeat(65535);
+    const frame = makeClientFrame(0x01, payload);
+    const result = ws.decodeFrame(frame);
+    assert(result);
+    assert.strictEqual(result.payload.length, 65535);
+  });
+
+  test('decodes frame at 65536 bytes boundary', () => {
+    const payload = 'Y'.repeat(65536);
+    const frame = makeClientFrame(0x01, payload);
+    const result = ws.decodeFrame(frame);
+    assert(result);
+    assert.strictEqual(result.payload.length, 65536);
+  });
+
+  // ========== Close Frame with Status Code ==========
+  console.log('\n--- Close Frame Details ---');
+
+  test('decodes close frame with status code', () => {
+    // Close frame payload: 2-byte status code + optional reason
+    const statusBuf = Buffer.alloc(2);
+    statusBuf.writeUInt16BE(1000); // Normal closure
+    const frame = makeClientFrame(0x08, statusBuf);
+    const result = ws.decodeFrame(frame);
+    assert.strictEqual(result.opcode, ws.OPCODES.CLOSE);
+    assert.strictEqual(result.payload.readUInt16BE(0), 1000);
+  });
+
+  test('decodes close frame with status code and reason', () => {
+    const reason = 'Normal shutdown';
+    const payload = Buffer.alloc(2 + reason.length);
+    payload.writeUInt16BE(1000);
+    payload.write(reason, 2);
+    const frame = makeClientFrame(0x08, payload);
+    const result = ws.decodeFrame(frame);
+    assert.strictEqual(result.opcode, ws.OPCODES.CLOSE);
+    assert.strictEqual(result.payload.slice(2).toString(), reason);
+  });
+
+  // ========== JSON Roundtrip ==========
+  console.log('\n--- JSON Message Roundtrip ---');
+
+  test('roundtrip encode/decode of JSON message', () => {
+    const msg = { type: 'reload' };
+    const payload = Buffer.from(JSON.stringify(msg));
+    const serverFrame = ws.encodeFrame(ws.OPCODES.TEXT, payload);
+
+    // Verify we can read what we encoded (unmasked server frame)
+    // Server frames don't go through decodeFrame (that expects masked),
+    // so just verify the payload bytes directly
+    let offset;
+    if (serverFrame[1] < 126) {
+      offset = 2;
+    } else if (serverFrame[1] === 126) {
+      offset = 4;
+    } else {
+      offset = 10;
+    }
+    const decoded = JSON.parse(serverFrame.slice(offset).toString());
+    assert.deepStrictEqual(decoded, msg);
+  });
+
+  test('roundtrip masked client JSON message', () => {
+    const msg = { type: 'click', choice: 'a', text: 'Option A', timestamp: 1706000101 };
+    const frame = makeClientFrame(0x01, JSON.stringify(msg));
+    const result = ws.decodeFrame(frame);
+    const decoded = JSON.parse(result.payload.toString());
+    assert.deepStrictEqual(decoded, msg);
+  });
+
+  // ========== Summary ==========
+  console.log(`\n--- Results: ${passed} passed, ${failed} failed ---`);
+  if (failed > 0) process.exit(1);
+}
+
+runTests();