Makelore 2.0 initial clean snapshot
This commit is contained in:
36
resources/skills/superpowers/tests/brainstorm-server/package-lock.json
generated
Normal file
36
resources/skills/superpowers/tests/brainstorm-server/package-lock.json
generated
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "brainstorm-server-tests",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "brainstorm-server-tests",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"ws": "^8.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.19.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
|
||||
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "brainstorm-server-tests",
|
||||
"version": "1.0.0",
|
||||
"scripts": {
|
||||
"test": "node server.test.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"ws": "^8.19.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,427 @@
|
||||
/**
|
||||
* 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');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const assert = require('assert');
|
||||
|
||||
const SERVER_PATH = path.join(__dirname, '../../skills/brainstorming/scripts/server.cjs');
|
||||
const TEST_PORT = 3334;
|
||||
const TEST_DIR = '/tmp/brainstorm-test';
|
||||
const CONTENT_DIR = path.join(TEST_DIR, 'content');
|
||||
const STATE_DIR = path.join(TEST_DIR, 'state');
|
||||
|
||||
function cleanup() {
|
||||
if (fs.existsSync(TEST_DIR)) {
|
||||
fs.rmSync(TEST_DIR, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function sleep(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function fetch(url) {
|
||||
return new Promise((resolve, reject) => {
|
||||
http.get(url, (res) => {
|
||||
let data = '';
|
||||
res.on('data', chunk => data += chunk);
|
||||
res.on('end', () => resolve({
|
||||
status: res.statusCode,
|
||||
headers: res.headers,
|
||||
body: data
|
||||
}));
|
||||
}).on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
function startServer() {
|
||||
return spawn('node', [SERVER_PATH], {
|
||||
env: { ...process.env, BRAINSTORM_PORT: TEST_PORT, BRAINSTORM_DIR: TEST_DIR }
|
||||
});
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
const server = startServer();
|
||||
let stdoutAccum = '';
|
||||
server.stdout.on('data', (data) => { stdoutAccum += data.toString(); });
|
||||
|
||||
const { stdout: initialStdout } = await waitForServer(server);
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function test(name, fn) {
|
||||
return fn().then(() => {
|
||||
console.log(` PASS: ${name}`);
|
||||
passed++;
|
||||
}).catch(e => {
|
||||
console.log(` FAIL: ${name}`);
|
||||
console.log(` ${e.message}`);
|
||||
failed++;
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
// ========== 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 to state/', () => {
|
||||
const infoPath = path.join(STATE_DIR, 'server-info');
|
||||
assert(fs.existsSync(infoPath), 'state/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);
|
||||
assert.strictEqual(info.screen_dir, CONTENT_DIR, 'screen_dir should point to content/');
|
||||
assert.strictEqual(info.state_dir, STATE_DIR, 'state_dir should point to state/');
|
||||
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 the agent'), '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(CONTENT_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(CONTENT_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(CONTENT_DIR, 'older.html'), '<h2>Older</h2>');
|
||||
await sleep(100);
|
||||
fs.writeFileSync(path.join(CONTENT_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(CONTENT_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 state/events', async () => {
|
||||
// Clean up events from prior tests
|
||||
const eventsFile = path.join(STATE_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 state/events', async () => {
|
||||
const eventsFile = path.join(STATE_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(CONTENT_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(CONTENT_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(CONTENT_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(CONTENT_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(CONTENT_DIR, 'data.txt'), 'not html');
|
||||
await sleep(500);
|
||||
|
||||
assert(!gotReload, 'Should NOT reload for non-HTML files');
|
||||
ws.close();
|
||||
});
|
||||
|
||||
await test('clears state/events on new screen', async () => {
|
||||
// Create an events file
|
||||
const eventsFile = path.join(STATE_DIR, 'events');
|
||||
fs.writeFileSync(eventsFile, '{"choice":"a"}\n');
|
||||
assert(fs.existsSync(eventsFile));
|
||||
|
||||
fs.writeFileSync(path.join(CONTENT_DIR, 'clear-events.html'), '<h2>New screen</h2>');
|
||||
await sleep(500);
|
||||
|
||||
assert(!fs.existsSync(eventsFile), 'state/events should be cleared on new screen');
|
||||
});
|
||||
|
||||
await test('logs screen-added on new file', async () => {
|
||||
stdoutAccum = '';
|
||||
fs.writeFileSync(path.join(CONTENT_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(CONTENT_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();
|
||||
}
|
||||
}
|
||||
|
||||
runTests().catch(err => {
|
||||
console.error('Test failed:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,351 @@
|
||||
#!/usr/bin/env bash
|
||||
# Windows lifecycle tests for the brainstorm server.
|
||||
#
|
||||
# Verifies that the brainstorm server survives the 60-second lifecycle
|
||||
# check on Windows, where OWNER_PID monitoring is disabled because the
|
||||
# MSYS2 PID namespace is invisible to Node.js.
|
||||
#
|
||||
# Requirements:
|
||||
# - Node.js in PATH
|
||||
# - Run from the repository root, or set SUPERPOWERS_ROOT
|
||||
# - On Windows: Git Bash (OSTYPE=msys*)
|
||||
#
|
||||
# Usage:
|
||||
# bash tests/brainstorm-server/windows-lifecycle.test.sh
|
||||
set -uo pipefail
|
||||
|
||||
# ========== Configuration ==========
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
REPO_ROOT="${SUPERPOWERS_ROOT:-$(cd "$SCRIPT_DIR/../.." && pwd)}"
|
||||
START_SCRIPT="$REPO_ROOT/skills/brainstorming/scripts/start-server.sh"
|
||||
STOP_SCRIPT="$REPO_ROOT/skills/brainstorming/scripts/stop-server.sh"
|
||||
SERVER_JS="$REPO_ROOT/skills/brainstorming/scripts/server.js"
|
||||
|
||||
TEST_DIR="${TMPDIR:-/tmp}/brainstorm-win-test-$$"
|
||||
|
||||
passed=0
|
||||
failed=0
|
||||
skipped=0
|
||||
|
||||
# ========== Helpers ==========
|
||||
|
||||
cleanup() {
|
||||
# Kill any server processes we started
|
||||
for pidvar in SERVER_PID CONTROL_PID STOP_TEST_PID; do
|
||||
pid="${!pidvar:-}"
|
||||
if [[ -n "$pid" ]]; then
|
||||
kill "$pid" 2>/dev/null || true
|
||||
wait "$pid" 2>/dev/null || true
|
||||
fi
|
||||
done
|
||||
if [[ -n "${TEST_DIR:-}" && -d "$TEST_DIR" ]]; then
|
||||
rm -rf "$TEST_DIR"
|
||||
fi
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
pass() {
|
||||
echo " PASS: $1"
|
||||
passed=$((passed + 1))
|
||||
}
|
||||
|
||||
fail() {
|
||||
echo " FAIL: $1"
|
||||
echo " $2"
|
||||
failed=$((failed + 1))
|
||||
}
|
||||
|
||||
skip() {
|
||||
echo " SKIP: $1 ($2)"
|
||||
skipped=$((skipped + 1))
|
||||
}
|
||||
|
||||
wait_for_server_info() {
|
||||
local dir="$1"
|
||||
for _ in $(seq 1 50); do
|
||||
if [[ -f "$dir/.server-info" ]]; then
|
||||
return 0
|
||||
fi
|
||||
sleep 0.1
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
get_port_from_info() {
|
||||
# Read the port from .server-info. Use grep/sed instead of Node.js
|
||||
# to avoid MSYS2-to-Windows path translation issues.
|
||||
grep -o '"port":[0-9]*' "$1/.server-info" | head -1 | sed 's/"port"://'
|
||||
}
|
||||
|
||||
http_check() {
|
||||
local port="$1"
|
||||
node -e "
|
||||
const http = require('http');
|
||||
http.get('http://localhost:$port/', (res) => {
|
||||
process.exit(res.statusCode === 200 ? 0 : 1);
|
||||
}).on('error', () => process.exit(1));
|
||||
" 2>/dev/null
|
||||
}
|
||||
|
||||
# ========== Platform Detection ==========
|
||||
|
||||
echo ""
|
||||
echo "=== Brainstorm Server Windows Lifecycle Tests ==="
|
||||
echo "Platform: ${OSTYPE:-unknown}"
|
||||
echo "MSYSTEM: ${MSYSTEM:-unset}"
|
||||
echo "Node: $(node --version 2>/dev/null || echo 'not found')"
|
||||
echo ""
|
||||
|
||||
is_windows="false"
|
||||
case "${OSTYPE:-}" in
|
||||
msys*|cygwin*|mingw*) is_windows="true" ;;
|
||||
esac
|
||||
if [[ -n "${MSYSTEM:-}" ]]; then
|
||||
is_windows="true"
|
||||
fi
|
||||
|
||||
if [[ "$is_windows" != "true" ]]; then
|
||||
echo "NOTE: Not running on Windows/MSYS2 (OSTYPE=${OSTYPE:-unset})."
|
||||
echo "Windows-specific tests will be skipped. Tests 4-6 still run."
|
||||
echo ""
|
||||
fi
|
||||
|
||||
mkdir -p "$TEST_DIR"
|
||||
|
||||
SERVER_PID=""
|
||||
CONTROL_PID=""
|
||||
STOP_TEST_PID=""
|
||||
|
||||
# ========== Test 1: OWNER_PID is empty on Windows ==========
|
||||
|
||||
echo "--- Owner PID Resolution ---"
|
||||
|
||||
if [[ "$is_windows" == "true" ]]; then
|
||||
# Replicate the PID resolution logic from start-server.sh lines 104-112
|
||||
TEST_OWNER_PID="$(ps -o ppid= -p "$PPID" 2>/dev/null | tr -d ' ' || true)"
|
||||
if [[ -z "$TEST_OWNER_PID" || "$TEST_OWNER_PID" == "1" ]]; then
|
||||
TEST_OWNER_PID="$PPID"
|
||||
fi
|
||||
# The fix: clear on Windows
|
||||
case "${OSTYPE:-}" in
|
||||
msys*|cygwin*|mingw*) TEST_OWNER_PID="" ;;
|
||||
esac
|
||||
|
||||
if [[ -z "$TEST_OWNER_PID" ]]; then
|
||||
pass "OWNER_PID is empty on Windows after fix"
|
||||
else
|
||||
fail "OWNER_PID is empty on Windows after fix" \
|
||||
"Expected empty, got '$TEST_OWNER_PID'"
|
||||
fi
|
||||
else
|
||||
skip "OWNER_PID is empty on Windows" "not on Windows"
|
||||
fi
|
||||
|
||||
# ========== Test 2: start-server.sh passes empty BRAINSTORM_OWNER_PID ==========
|
||||
|
||||
if [[ "$is_windows" == "true" ]]; then
|
||||
# Use a fake 'node' that captures the env var and exits
|
||||
FAKE_NODE_DIR="$TEST_DIR/fake-bin"
|
||||
mkdir -p "$FAKE_NODE_DIR"
|
||||
cat > "$FAKE_NODE_DIR/node" <<'FAKENODE'
|
||||
#!/usr/bin/env bash
|
||||
echo "CAPTURED_OWNER_PID=${BRAINSTORM_OWNER_PID:-__UNSET__}"
|
||||
exit 0
|
||||
FAKENODE
|
||||
chmod +x "$FAKE_NODE_DIR/node"
|
||||
|
||||
captured=$(PATH="$FAKE_NODE_DIR:$PATH" bash "$START_SCRIPT" --project-dir "$TEST_DIR/session" --foreground 2>/dev/null || true)
|
||||
owner_pid_value=$(echo "$captured" | grep "CAPTURED_OWNER_PID=" | head -1 | sed 's/CAPTURED_OWNER_PID=//')
|
||||
|
||||
if [[ "$owner_pid_value" == "" || "$owner_pid_value" == "__UNSET__" ]]; then
|
||||
pass "start-server.sh passes empty BRAINSTORM_OWNER_PID on Windows"
|
||||
else
|
||||
fail "start-server.sh passes empty BRAINSTORM_OWNER_PID on Windows" \
|
||||
"Expected empty or unset, got '$owner_pid_value'"
|
||||
fi
|
||||
|
||||
rm -rf "$FAKE_NODE_DIR" "$TEST_DIR/session"
|
||||
else
|
||||
skip "start-server.sh passes empty BRAINSTORM_OWNER_PID" "not on Windows"
|
||||
fi
|
||||
|
||||
# ========== Test 3: Auto-foreground detection on Windows ==========
|
||||
|
||||
echo ""
|
||||
echo "--- Foreground Mode Detection ---"
|
||||
|
||||
if [[ "$is_windows" == "true" ]]; then
|
||||
FAKE_NODE_DIR="$TEST_DIR/fake-bin"
|
||||
mkdir -p "$FAKE_NODE_DIR"
|
||||
cat > "$FAKE_NODE_DIR/node" <<'FAKENODE'
|
||||
#!/usr/bin/env bash
|
||||
echo "FOREGROUND_MODE=true"
|
||||
exit 0
|
||||
FAKENODE
|
||||
chmod +x "$FAKE_NODE_DIR/node"
|
||||
|
||||
# Run WITHOUT --foreground flag — Windows should auto-detect
|
||||
captured=$(PATH="$FAKE_NODE_DIR:$PATH" bash "$START_SCRIPT" --project-dir "$TEST_DIR/session2" 2>/dev/null || true)
|
||||
|
||||
if echo "$captured" | grep -q "FOREGROUND_MODE=true"; then
|
||||
pass "Windows auto-detects foreground mode"
|
||||
else
|
||||
fail "Windows auto-detects foreground mode" \
|
||||
"Expected foreground code path, output: $captured"
|
||||
fi
|
||||
|
||||
rm -rf "$FAKE_NODE_DIR" "$TEST_DIR/session2"
|
||||
else
|
||||
skip "Windows auto-detects foreground mode" "not on Windows"
|
||||
fi
|
||||
|
||||
# ========== Test 4: Server survives past 60-second lifecycle check ==========
|
||||
|
||||
echo ""
|
||||
echo "--- Server Survival (lifecycle check) ---"
|
||||
|
||||
mkdir -p "$TEST_DIR/survival"
|
||||
|
||||
echo " Starting server (will wait ~75s to verify survival past lifecycle check)..."
|
||||
|
||||
BRAINSTORM_DIR="$TEST_DIR/survival" \
|
||||
BRAINSTORM_HOST="127.0.0.1" \
|
||||
BRAINSTORM_URL_HOST="localhost" \
|
||||
BRAINSTORM_OWNER_PID="" \
|
||||
BRAINSTORM_PORT=$((49152 + RANDOM % 16383)) \
|
||||
node "$SERVER_JS" > "$TEST_DIR/survival/.server.log" 2>&1 &
|
||||
SERVER_PID=$!
|
||||
|
||||
if ! wait_for_server_info "$TEST_DIR/survival"; then
|
||||
fail "Server starts successfully" "Server did not write .server-info within 5 seconds"
|
||||
kill "$SERVER_PID" 2>/dev/null || true
|
||||
SERVER_PID=""
|
||||
else
|
||||
pass "Server starts successfully with empty OWNER_PID"
|
||||
|
||||
SERVER_PORT=$(get_port_from_info "$TEST_DIR/survival")
|
||||
|
||||
sleep 75
|
||||
|
||||
if kill -0 "$SERVER_PID" 2>/dev/null; then
|
||||
pass "Server is still alive after 75 seconds"
|
||||
else
|
||||
fail "Server is still alive after 75 seconds" \
|
||||
"Server died. Log tail: $(tail -5 "$TEST_DIR/survival/.server.log" 2>/dev/null)"
|
||||
fi
|
||||
|
||||
if http_check "$SERVER_PORT"; then
|
||||
pass "Server responds to HTTP after lifecycle check window"
|
||||
else
|
||||
fail "Server responds to HTTP after lifecycle check window" \
|
||||
"HTTP request to port $SERVER_PORT failed"
|
||||
fi
|
||||
|
||||
if grep -q "owner process exited" "$TEST_DIR/survival/.server.log" 2>/dev/null; then
|
||||
fail "No 'owner process exited' in logs" \
|
||||
"Found spurious owner-exit shutdown in log"
|
||||
else
|
||||
pass "No 'owner process exited' in logs"
|
||||
fi
|
||||
|
||||
kill "$SERVER_PID" 2>/dev/null || true
|
||||
wait "$SERVER_PID" 2>/dev/null || true
|
||||
SERVER_PID=""
|
||||
fi
|
||||
|
||||
# ========== Test 5: Bad OWNER_PID causes shutdown (control) ==========
|
||||
|
||||
echo ""
|
||||
echo "--- Control: Bad OWNER_PID causes shutdown ---"
|
||||
|
||||
mkdir -p "$TEST_DIR/control"
|
||||
|
||||
# Find a PID that does not exist
|
||||
BAD_PID=99999
|
||||
while kill -0 "$BAD_PID" 2>/dev/null; do
|
||||
BAD_PID=$((BAD_PID + 1))
|
||||
done
|
||||
|
||||
BRAINSTORM_DIR="$TEST_DIR/control" \
|
||||
BRAINSTORM_HOST="127.0.0.1" \
|
||||
BRAINSTORM_URL_HOST="localhost" \
|
||||
BRAINSTORM_OWNER_PID="$BAD_PID" \
|
||||
BRAINSTORM_PORT=$((49152 + RANDOM % 16383)) \
|
||||
node "$SERVER_JS" > "$TEST_DIR/control/.server.log" 2>&1 &
|
||||
CONTROL_PID=$!
|
||||
|
||||
if ! wait_for_server_info "$TEST_DIR/control"; then
|
||||
fail "Control server starts" "Server did not write .server-info within 5 seconds"
|
||||
kill "$CONTROL_PID" 2>/dev/null || true
|
||||
CONTROL_PID=""
|
||||
else
|
||||
pass "Control server starts with bad OWNER_PID=$BAD_PID"
|
||||
|
||||
echo " Waiting ~75s for lifecycle check to kill server..."
|
||||
sleep 75
|
||||
|
||||
if kill -0 "$CONTROL_PID" 2>/dev/null; then
|
||||
fail "Control server self-terminates with bad OWNER_PID" \
|
||||
"Server is still alive (expected it to die)"
|
||||
kill "$CONTROL_PID" 2>/dev/null || true
|
||||
else
|
||||
pass "Control server self-terminates with bad OWNER_PID"
|
||||
fi
|
||||
|
||||
if grep -q "owner process exited" "$TEST_DIR/control/.server.log" 2>/dev/null; then
|
||||
pass "Control server logs 'owner process exited'"
|
||||
else
|
||||
fail "Control server logs 'owner process exited'" \
|
||||
"Log tail: $(tail -5 "$TEST_DIR/control/.server.log" 2>/dev/null)"
|
||||
fi
|
||||
fi
|
||||
|
||||
wait "$CONTROL_PID" 2>/dev/null || true
|
||||
CONTROL_PID=""
|
||||
|
||||
# ========== Test 6: stop-server.sh cleanly stops the server ==========
|
||||
|
||||
echo ""
|
||||
echo "--- Clean Shutdown ---"
|
||||
|
||||
mkdir -p "$TEST_DIR/stop-test"
|
||||
|
||||
BRAINSTORM_DIR="$TEST_DIR/stop-test" \
|
||||
BRAINSTORM_HOST="127.0.0.1" \
|
||||
BRAINSTORM_URL_HOST="localhost" \
|
||||
BRAINSTORM_OWNER_PID="" \
|
||||
BRAINSTORM_PORT=$((49152 + RANDOM % 16383)) \
|
||||
node "$SERVER_JS" > "$TEST_DIR/stop-test/.server.log" 2>&1 &
|
||||
STOP_TEST_PID=$!
|
||||
echo "$STOP_TEST_PID" > "$TEST_DIR/stop-test/.server.pid"
|
||||
|
||||
if ! wait_for_server_info "$TEST_DIR/stop-test"; then
|
||||
fail "Stop-test server starts" "Server did not start"
|
||||
kill "$STOP_TEST_PID" 2>/dev/null || true
|
||||
STOP_TEST_PID=""
|
||||
else
|
||||
bash "$STOP_SCRIPT" "$TEST_DIR/stop-test" >/dev/null 2>&1 || true
|
||||
sleep 1
|
||||
|
||||
if ! kill -0 "$STOP_TEST_PID" 2>/dev/null; then
|
||||
pass "stop-server.sh cleanly stops the server"
|
||||
else
|
||||
fail "stop-server.sh cleanly stops the server" \
|
||||
"Server PID $STOP_TEST_PID is still alive after stop"
|
||||
kill "$STOP_TEST_PID" 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
|
||||
wait "$STOP_TEST_PID" 2>/dev/null || true
|
||||
STOP_TEST_PID=""
|
||||
|
||||
# ========== Summary ==========
|
||||
|
||||
echo ""
|
||||
echo "=== Results: $passed passed, $failed failed, $skipped skipped ==="
|
||||
|
||||
if [[ $failed -gt 0 ]]; then
|
||||
exit 1
|
||||
fi
|
||||
exit 0
|
||||
@@ -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.cjs');
|
||||
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();
|
||||
Reference in New Issue
Block a user