|
|
@@ -0,0 +1,479 @@
|
|
|
+# Zero-Dependency Brainstorm Server Implementation Plan
|
|
|
+
|
|
|
+> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
+
|
|
|
+**Goal:** Replace the brainstorm server's vendored node_modules with a single zero-dependency `server.js` using Node built-ins.
|
|
|
+
|
|
|
+**Architecture:** Single file with WebSocket protocol (RFC 6455 text frames), HTTP server (`http` module), and file watching (`fs.watch`). Exports protocol functions for unit testing when required as a module.
|
|
|
+
|
|
|
+**Tech Stack:** Node.js built-ins only: `http`, `crypto`, `fs`, `path`
|
|
|
+
|
|
|
+**Spec:** `docs/superpowers/specs/2026-03-11-zero-dep-brainstorm-server-design.md`
|
|
|
+
|
|
|
+**Existing tests:** `tests/brainstorm-server/ws-protocol.test.js` (unit), `tests/brainstorm-server/server.test.js` (integration)
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## File Map
|
|
|
+
|
|
|
+- **Create:** `skills/brainstorming/scripts/server.js` — the zero-dep replacement
|
|
|
+- **Modify:** `skills/brainstorming/scripts/start-server.sh:94,100` — change `index.js` to `server.js`
|
|
|
+- **Modify:** `.gitignore:6` — remove the `!skills/brainstorming/scripts/node_modules/` exception
|
|
|
+- **Delete:** `skills/brainstorming/scripts/index.js`
|
|
|
+- **Delete:** `skills/brainstorming/scripts/package.json`
|
|
|
+- **Delete:** `skills/brainstorming/scripts/package-lock.json`
|
|
|
+- **Delete:** `skills/brainstorming/scripts/node_modules/` (714 files)
|
|
|
+- **No changes:** `skills/brainstorming/scripts/helper.js`, `skills/brainstorming/scripts/frame-template.html`, `skills/brainstorming/scripts/stop-server.sh`
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## Chunk 1: WebSocket Protocol Layer
|
|
|
+
|
|
|
+### Task 1: Implement WebSocket protocol exports
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Create: `skills/brainstorming/scripts/server.js`
|
|
|
+- Test: `tests/brainstorm-server/ws-protocol.test.js` (already exists)
|
|
|
+
|
|
|
+- [ ] **Step 1: Create server.js with OPCODES constant and computeAcceptKey**
|
|
|
+
|
|
|
+```js
|
|
|
+const crypto = require('crypto');
|
|
|
+
|
|
|
+const OPCODES = { TEXT: 0x01, CLOSE: 0x08, PING: 0x09, PONG: 0x0A };
|
|
|
+const WS_MAGIC = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
|
|
|
+
|
|
|
+function computeAcceptKey(clientKey) {
|
|
|
+ return crypto.createHash('sha1').update(clientKey + WS_MAGIC).digest('base64');
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: Implement encodeFrame**
|
|
|
+
|
|
|
+Server frames are never masked. Three length encodings:
|
|
|
+- payload < 126: 2-byte header (FIN+opcode, length)
|
|
|
+- 126-65535: 4-byte header (FIN+opcode, 126, 16-bit length)
|
|
|
+- > 65535: 10-byte header (FIN+opcode, 127, 64-bit length)
|
|
|
+
|
|
|
+```js
|
|
|
+function encodeFrame(opcode, payload) {
|
|
|
+ const fin = 0x80;
|
|
|
+ const len = payload.length;
|
|
|
+ let header;
|
|
|
+
|
|
|
+ if (len < 126) {
|
|
|
+ header = Buffer.alloc(2);
|
|
|
+ header[0] = fin | opcode;
|
|
|
+ header[1] = len;
|
|
|
+ } else if (len < 65536) {
|
|
|
+ header = Buffer.alloc(4);
|
|
|
+ header[0] = fin | opcode;
|
|
|
+ header[1] = 126;
|
|
|
+ header.writeUInt16BE(len, 2);
|
|
|
+ } else {
|
|
|
+ header = Buffer.alloc(10);
|
|
|
+ header[0] = fin | opcode;
|
|
|
+ header[1] = 127;
|
|
|
+ header.writeBigUInt64BE(BigInt(len), 2);
|
|
|
+ }
|
|
|
+
|
|
|
+ return Buffer.concat([header, payload]);
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: Implement decodeFrame**
|
|
|
+
|
|
|
+Client frames are always masked. Returns `{ opcode, payload, bytesConsumed }` or `null` for incomplete. Throws on unmasked frames.
|
|
|
+
|
|
|
+```js
|
|
|
+function decodeFrame(buffer) {
|
|
|
+ if (buffer.length < 2) return null;
|
|
|
+
|
|
|
+ const firstByte = buffer[0];
|
|
|
+ const secondByte = buffer[1];
|
|
|
+ const opcode = firstByte & 0x0F;
|
|
|
+ const masked = (secondByte & 0x80) !== 0;
|
|
|
+ let payloadLen = secondByte & 0x7F;
|
|
|
+ let offset = 2;
|
|
|
+
|
|
|
+ if (!masked) throw new Error('Client frames must be masked');
|
|
|
+
|
|
|
+ if (payloadLen === 126) {
|
|
|
+ if (buffer.length < 4) return null;
|
|
|
+ payloadLen = buffer.readUInt16BE(2);
|
|
|
+ offset = 4;
|
|
|
+ } else if (payloadLen === 127) {
|
|
|
+ if (buffer.length < 10) return null;
|
|
|
+ payloadLen = Number(buffer.readBigUInt64BE(2));
|
|
|
+ offset = 10;
|
|
|
+ }
|
|
|
+
|
|
|
+ const maskOffset = offset;
|
|
|
+ const dataOffset = offset + 4;
|
|
|
+ const totalLen = dataOffset + payloadLen;
|
|
|
+ if (buffer.length < totalLen) return null;
|
|
|
+
|
|
|
+ const mask = buffer.slice(maskOffset, dataOffset);
|
|
|
+ const data = Buffer.alloc(payloadLen);
|
|
|
+ for (let i = 0; i < payloadLen; i++) {
|
|
|
+ data[i] = buffer[dataOffset + i] ^ mask[i % 4];
|
|
|
+ }
|
|
|
+
|
|
|
+ return { opcode, payload: data, bytesConsumed: totalLen };
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 4: Add module exports at the bottom of the file**
|
|
|
+
|
|
|
+```js
|
|
|
+module.exports = { computeAcceptKey, encodeFrame, decodeFrame, OPCODES };
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 5: Run unit tests**
|
|
|
+
|
|
|
+Run: `cd tests/brainstorm-server && node ws-protocol.test.js`
|
|
|
+Expected: All tests pass (handshake, encoding, decoding, boundaries, edge cases)
|
|
|
+
|
|
|
+- [ ] **Step 6: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add skills/brainstorming/scripts/server.js
|
|
|
+git commit -m "Add WebSocket protocol layer for zero-dep brainstorm server"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## Chunk 2: HTTP Server and Application Logic
|
|
|
+
|
|
|
+### Task 2: Add HTTP server, file watching, and WebSocket connection handling
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `skills/brainstorming/scripts/server.js`
|
|
|
+- Test: `tests/brainstorm-server/server.test.js` (already exists)
|
|
|
+
|
|
|
+- [ ] **Step 1: Add configuration and constants at top of server.js (after requires)**
|
|
|
+
|
|
|
+```js
|
|
|
+const http = require('http');
|
|
|
+const fs = require('fs');
|
|
|
+const path = require('path');
|
|
|
+
|
|
|
+const PORT = process.env.BRAINSTORM_PORT || (49152 + Math.floor(Math.random() * 16383));
|
|
|
+const HOST = process.env.BRAINSTORM_HOST || '127.0.0.1';
|
|
|
+const URL_HOST = process.env.BRAINSTORM_URL_HOST || (HOST === '127.0.0.1' ? 'localhost' : HOST);
|
|
|
+const SCREEN_DIR = process.env.BRAINSTORM_DIR || '/tmp/brainstorm';
|
|
|
+
|
|
|
+const MIME_TYPES = {
|
|
|
+ '.html': 'text/html', '.css': 'text/css', '.js': 'application/javascript',
|
|
|
+ '.json': 'application/json', '.png': 'image/png', '.jpg': 'image/jpeg',
|
|
|
+ '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.svg': 'image/svg+xml'
|
|
|
+};
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: Add WAITING_PAGE, template loading at module scope, and helper functions**
|
|
|
+
|
|
|
+Load `frameTemplate` and `helperInjection` at module scope so they're accessible to `wrapInFrame` and `handleRequest`. They only read files from `__dirname` (the scripts directory), which is valid whether the module is required or run directly.
|
|
|
+
|
|
|
+```js
|
|
|
+const WAITING_PAGE = `<!DOCTYPE html>
|
|
|
+<html>
|
|
|
+<head><title>Brainstorm Companion</title>
|
|
|
+<style>body { font-family: system-ui, sans-serif; padding: 2rem; max-width: 800px; margin: 0 auto; }
|
|
|
+h1 { color: #333; } p { color: #666; }</style>
|
|
|
+</head>
|
|
|
+<body><h1>Brainstorm Companion</h1>
|
|
|
+<p>Waiting for Claude to push a screen...</p></body></html>`;
|
|
|
+
|
|
|
+const frameTemplate = fs.readFileSync(path.join(__dirname, 'frame-template.html'), 'utf-8');
|
|
|
+const helperScript = fs.readFileSync(path.join(__dirname, 'helper.js'), 'utf-8');
|
|
|
+const helperInjection = '<script>\n' + helperScript + '\n</script>';
|
|
|
+
|
|
|
+function isFullDocument(html) {
|
|
|
+ const trimmed = html.trimStart().toLowerCase();
|
|
|
+ return trimmed.startsWith('<!doctype') || trimmed.startsWith('<html');
|
|
|
+}
|
|
|
+
|
|
|
+function wrapInFrame(content) {
|
|
|
+ return frameTemplate.replace('<!-- CONTENT -->', content);
|
|
|
+}
|
|
|
+
|
|
|
+function getNewestScreen() {
|
|
|
+ const files = fs.readdirSync(SCREEN_DIR)
|
|
|
+ .filter(f => f.endsWith('.html'))
|
|
|
+ .map(f => {
|
|
|
+ const fp = path.join(SCREEN_DIR, f);
|
|
|
+ return { path: fp, mtime: fs.statSync(fp).mtime.getTime() };
|
|
|
+ })
|
|
|
+ .sort((a, b) => b.mtime - a.mtime);
|
|
|
+ return files.length > 0 ? files[0].path : null;
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: Add HTTP request handler**
|
|
|
+
|
|
|
+```js
|
|
|
+function handleRequest(req, res) {
|
|
|
+ if (req.method === 'GET' && req.url === '/') {
|
|
|
+ const screenFile = getNewestScreen();
|
|
|
+ let html = screenFile
|
|
|
+ ? (raw => isFullDocument(raw) ? raw : wrapInFrame(raw))(fs.readFileSync(screenFile, 'utf-8'))
|
|
|
+ : WAITING_PAGE;
|
|
|
+
|
|
|
+ if (html.includes('</body>')) {
|
|
|
+ html = html.replace('</body>', helperInjection + '\n</body>');
|
|
|
+ } else {
|
|
|
+ html += helperInjection;
|
|
|
+ }
|
|
|
+
|
|
|
+ res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
|
+ res.end(html);
|
|
|
+ } else if (req.method === 'GET' && req.url.startsWith('/files/')) {
|
|
|
+ const fileName = req.url.slice(7); // strip '/files/'
|
|
|
+ const filePath = path.join(SCREEN_DIR, path.basename(fileName));
|
|
|
+ if (!fs.existsSync(filePath)) {
|
|
|
+ res.writeHead(404);
|
|
|
+ res.end('Not found');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const ext = path.extname(filePath).toLowerCase();
|
|
|
+ const contentType = MIME_TYPES[ext] || 'application/octet-stream';
|
|
|
+ res.writeHead(200, { 'Content-Type': contentType });
|
|
|
+ res.end(fs.readFileSync(filePath));
|
|
|
+ } else {
|
|
|
+ res.writeHead(404);
|
|
|
+ res.end('Not found');
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 4: Add WebSocket connection handling**
|
|
|
+
|
|
|
+```js
|
|
|
+const clients = new Set();
|
|
|
+
|
|
|
+function handleUpgrade(req, socket) {
|
|
|
+ const key = req.headers['sec-websocket-key'];
|
|
|
+ if (!key) { socket.destroy(); return; }
|
|
|
+
|
|
|
+ const accept = computeAcceptKey(key);
|
|
|
+ socket.write(
|
|
|
+ 'HTTP/1.1 101 Switching Protocols\r\n' +
|
|
|
+ 'Upgrade: websocket\r\n' +
|
|
|
+ 'Connection: Upgrade\r\n' +
|
|
|
+ 'Sec-WebSocket-Accept: ' + accept + '\r\n\r\n'
|
|
|
+ );
|
|
|
+
|
|
|
+ let buffer = Buffer.alloc(0);
|
|
|
+ clients.add(socket);
|
|
|
+
|
|
|
+ socket.on('data', (chunk) => {
|
|
|
+ buffer = Buffer.concat([buffer, chunk]);
|
|
|
+ while (buffer.length > 0) {
|
|
|
+ let result;
|
|
|
+ try {
|
|
|
+ result = decodeFrame(buffer);
|
|
|
+ } catch (e) {
|
|
|
+ socket.end(encodeFrame(OPCODES.CLOSE, Buffer.alloc(0)));
|
|
|
+ clients.delete(socket);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ if (!result) break;
|
|
|
+ buffer = buffer.slice(result.bytesConsumed);
|
|
|
+
|
|
|
+ switch (result.opcode) {
|
|
|
+ case OPCODES.TEXT:
|
|
|
+ handleMessage(result.payload.toString());
|
|
|
+ break;
|
|
|
+ case OPCODES.CLOSE:
|
|
|
+ socket.end(encodeFrame(OPCODES.CLOSE, Buffer.alloc(0)));
|
|
|
+ clients.delete(socket);
|
|
|
+ return;
|
|
|
+ case OPCODES.PING:
|
|
|
+ socket.write(encodeFrame(OPCODES.PONG, result.payload));
|
|
|
+ break;
|
|
|
+ case OPCODES.PONG:
|
|
|
+ break;
|
|
|
+ default:
|
|
|
+ // Unsupported opcode — close with 1003
|
|
|
+ const closeBuf = Buffer.alloc(2);
|
|
|
+ closeBuf.writeUInt16BE(1003);
|
|
|
+ socket.end(encodeFrame(OPCODES.CLOSE, closeBuf));
|
|
|
+ clients.delete(socket);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ socket.on('close', () => clients.delete(socket));
|
|
|
+ socket.on('error', () => clients.delete(socket));
|
|
|
+}
|
|
|
+
|
|
|
+function handleMessage(text) {
|
|
|
+ let event;
|
|
|
+ try {
|
|
|
+ event = JSON.parse(text);
|
|
|
+ } catch (e) {
|
|
|
+ console.error('Failed to parse WebSocket message:', e.message);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ console.log(JSON.stringify({ source: 'user-event', ...event }));
|
|
|
+ if (event.choice) {
|
|
|
+ const eventsFile = path.join(SCREEN_DIR, '.events');
|
|
|
+ fs.appendFileSync(eventsFile, JSON.stringify(event) + '\n');
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function broadcast(msg) {
|
|
|
+ const frame = encodeFrame(OPCODES.TEXT, Buffer.from(JSON.stringify(msg)));
|
|
|
+ for (const socket of clients) {
|
|
|
+ try { socket.write(frame); } catch (e) { clients.delete(socket); }
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 5: Add debounce timer map**
|
|
|
+
|
|
|
+```js
|
|
|
+const debounceTimers = new Map();
|
|
|
+```
|
|
|
+
|
|
|
+File watching logic is inlined in `startServer` (Step 6) to keep watcher lifecycle together with server lifecycle and include an `error` handler per spec.
|
|
|
+
|
|
|
+- [ ] **Step 6: Add startServer function and conditional main**
|
|
|
+
|
|
|
+`frameTemplate` and `helperInjection` are already at module scope (Step 2). `startServer` just creates the screen dir, starts the HTTP server, watcher, and logs startup info.
|
|
|
+
|
|
|
+```js
|
|
|
+function startServer() {
|
|
|
+ if (!fs.existsSync(SCREEN_DIR)) fs.mkdirSync(SCREEN_DIR, { recursive: true });
|
|
|
+
|
|
|
+ const server = http.createServer(handleRequest);
|
|
|
+ server.on('upgrade', handleUpgrade);
|
|
|
+
|
|
|
+ const watcher = fs.watch(SCREEN_DIR, (eventType, filename) => {
|
|
|
+ if (!filename || !filename.endsWith('.html')) return;
|
|
|
+ if (debounceTimers.has(filename)) clearTimeout(debounceTimers.get(filename));
|
|
|
+ debounceTimers.set(filename, setTimeout(() => {
|
|
|
+ debounceTimers.delete(filename);
|
|
|
+ const filePath = path.join(SCREEN_DIR, filename);
|
|
|
+ if (eventType === 'rename' && fs.existsSync(filePath)) {
|
|
|
+ const eventsFile = path.join(SCREEN_DIR, '.events');
|
|
|
+ if (fs.existsSync(eventsFile)) fs.unlinkSync(eventsFile);
|
|
|
+ console.log(JSON.stringify({ type: 'screen-added', file: filePath }));
|
|
|
+ } else if (eventType === 'change') {
|
|
|
+ console.log(JSON.stringify({ type: 'screen-updated', file: filePath }));
|
|
|
+ }
|
|
|
+ broadcast({ type: 'reload' });
|
|
|
+ }, 100));
|
|
|
+ });
|
|
|
+ watcher.on('error', (err) => console.error('fs.watch error:', err.message));
|
|
|
+
|
|
|
+ server.listen(PORT, HOST, () => {
|
|
|
+ const info = JSON.stringify({
|
|
|
+ type: 'server-started', port: Number(PORT), host: HOST,
|
|
|
+ url_host: URL_HOST, url: 'http://' + URL_HOST + ':' + PORT,
|
|
|
+ screen_dir: SCREEN_DIR
|
|
|
+ });
|
|
|
+ console.log(info);
|
|
|
+ fs.writeFileSync(path.join(SCREEN_DIR, '.server-info'), info + '\n');
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+if (require.main === module) {
|
|
|
+ startServer();
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 7: Run integration tests**
|
|
|
+
|
|
|
+The test directory already has a `package.json` with `ws` as a dependency. Install it if needed, then run tests.
|
|
|
+
|
|
|
+Run: `cd tests/brainstorm-server && npm install && node server.test.js`
|
|
|
+Expected: All tests pass
|
|
|
+
|
|
|
+- [ ] **Step 8: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add skills/brainstorming/scripts/server.js
|
|
|
+git commit -m "Add HTTP server, WebSocket handling, and file watching to server.js"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## Chunk 3: Swap and Cleanup
|
|
|
+
|
|
|
+### Task 3: Update start-server.sh and remove old files
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `skills/brainstorming/scripts/start-server.sh:94,100`
|
|
|
+- Modify: `.gitignore:6`
|
|
|
+- Delete: `skills/brainstorming/scripts/index.js`
|
|
|
+- Delete: `skills/brainstorming/scripts/package.json`
|
|
|
+- Delete: `skills/brainstorming/scripts/package-lock.json`
|
|
|
+- Delete: `skills/brainstorming/scripts/node_modules/` (entire directory)
|
|
|
+
|
|
|
+- [ ] **Step 1: Update start-server.sh — change `index.js` to `server.js`**
|
|
|
+
|
|
|
+Two lines to change:
|
|
|
+
|
|
|
+Line 94: `env BRAINSTORM_DIR="$SCREEN_DIR" BRAINSTORM_HOST="$BIND_HOST" BRAINSTORM_URL_HOST="$URL_HOST" node server.js`
|
|
|
+
|
|
|
+Line 100: `nohup env BRAINSTORM_DIR="$SCREEN_DIR" BRAINSTORM_HOST="$BIND_HOST" BRAINSTORM_URL_HOST="$URL_HOST" node server.js > "$LOG_FILE" 2>&1 &`
|
|
|
+
|
|
|
+- [ ] **Step 2: Remove the gitignore exception for node_modules**
|
|
|
+
|
|
|
+In `.gitignore`, delete line 6: `!skills/brainstorming/scripts/node_modules/`
|
|
|
+
|
|
|
+- [ ] **Step 3: Delete old files**
|
|
|
+
|
|
|
+```bash
|
|
|
+git rm skills/brainstorming/scripts/index.js
|
|
|
+git rm skills/brainstorming/scripts/package.json
|
|
|
+git rm skills/brainstorming/scripts/package-lock.json
|
|
|
+git rm -r skills/brainstorming/scripts/node_modules/
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 4: Run both test suites**
|
|
|
+
|
|
|
+Run: `cd tests/brainstorm-server && node ws-protocol.test.js && node server.test.js`
|
|
|
+Expected: All tests pass
|
|
|
+
|
|
|
+- [ ] **Step 5: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add skills/brainstorming/scripts/ .gitignore
|
|
|
+git commit -m "Remove vendored node_modules, swap to zero-dep server.js"
|
|
|
+```
|
|
|
+
|
|
|
+### Task 4: Manual smoke test
|
|
|
+
|
|
|
+- [ ] **Step 1: Start the server manually**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd skills/brainstorming/scripts
|
|
|
+BRAINSTORM_DIR=/tmp/brainstorm-smoke BRAINSTORM_PORT=9876 node server.js
|
|
|
+```
|
|
|
+
|
|
|
+Expected: `server-started` JSON printed with port 9876
|
|
|
+
|
|
|
+- [ ] **Step 2: Open browser to http://localhost:9876**
|
|
|
+
|
|
|
+Expected: Waiting page with "Waiting for Claude to push a screen..."
|
|
|
+
|
|
|
+- [ ] **Step 3: Write an HTML file to the screen directory**
|
|
|
+
|
|
|
+```bash
|
|
|
+echo '<h2>Hello from smoke test</h2>' > /tmp/brainstorm-smoke/test.html
|
|
|
+```
|
|
|
+
|
|
|
+Expected: Browser reloads and shows "Hello from smoke test" wrapped in frame template
|
|
|
+
|
|
|
+- [ ] **Step 4: Verify WebSocket works — check browser console**
|
|
|
+
|
|
|
+Open browser dev tools. The WebSocket connection should show as connected (no errors in console). The frame template's status indicator should show "Connected".
|
|
|
+
|
|
|
+- [ ] **Step 5: Stop server with Ctrl-C, clean up**
|
|
|
+
|
|
|
+```bash
|
|
|
+rm -rf /tmp/brainstorm-smoke
|
|
|
+```
|