mirror of
https://github.com/obra/superpowers.git
synced 2026-06-18 16:49:04 +08:00
Add visual companion Prime Radiant branding
This commit is contained in:
committed by
Jesse Vincent
parent
985434ddb0
commit
529e192c32
309
tests/brainstorm-server/branding.test.js
Normal file
309
tests/brainstorm-server/branding.test.js
Normal file
@@ -0,0 +1,309 @@
|
||||
/**
|
||||
* Tests for the visual companion's Superpowers/Prime Radiant branding.
|
||||
*/
|
||||
|
||||
const { spawn } = require('child_process');
|
||||
const http = require('http');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const assert = require('assert');
|
||||
|
||||
const REPO_ROOT = path.join(__dirname, '../..');
|
||||
const SERVER_PATH = path.join(REPO_ROOT, 'skills/brainstorming/scripts/server.cjs');
|
||||
const PACKAGE_VERSION = JSON.parse(
|
||||
fs.readFileSync(path.join(REPO_ROOT, 'package.json'), 'utf-8')
|
||||
).version;
|
||||
const TOKEN = 'testtoken-branding-0123456789abcdef';
|
||||
const ASSET_URL = 'https://primeradiant.com/brand/superpowers-visual-brainstorming-logo.png';
|
||||
|
||||
function cleanup(dir) {
|
||||
if (fs.existsSync(dir)) {
|
||||
fs.rmSync(dir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function startServer({ port, dir, env = {} }) {
|
||||
cleanup(dir);
|
||||
return spawn('node', [SERVER_PATH], {
|
||||
env: {
|
||||
...process.env,
|
||||
BRAINSTORM_PORT: String(port),
|
||||
BRAINSTORM_DIR: dir,
|
||||
BRAINSTORM_TOKEN: TOKEN,
|
||||
...env
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function waitForServer(server) {
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => reject(new Error(`Server did not start. stderr: ${stderr}`)), 5000);
|
||||
server.stdout.on('data', (data) => {
|
||||
stdout += data.toString();
|
||||
if (stdout.includes('server-started')) {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
server.stderr.on('data', (data) => { stderr += data.toString(); });
|
||||
server.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
function fetchHtml(port) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const headers = { Cookie: `brainstorm-key-${port}=${TOKEN}` };
|
||||
http.get(`http://localhost:${port}/`, { headers }, (res) => {
|
||||
let body = '';
|
||||
res.on('data', chunk => { body += chunk; });
|
||||
res.on('end', () => resolve(body));
|
||||
}).on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
function writeFragment(dir) {
|
||||
const contentDir = path.join(dir, 'content');
|
||||
fs.mkdirSync(contentDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(contentDir, 'screen.html'), '<h2>Pick a layout</h2>');
|
||||
}
|
||||
|
||||
async function withServer(options, fn) {
|
||||
const server = startServer(options);
|
||||
try {
|
||||
await waitForServer(server);
|
||||
await fn();
|
||||
} finally {
|
||||
if (server.exitCode === null && server.signalCode === null) {
|
||||
server.kill();
|
||||
await new Promise(resolve => server.once('exit', resolve));
|
||||
}
|
||||
await sleep(100);
|
||||
cleanup(options.dir);
|
||||
}
|
||||
}
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
async function test(name, fn) {
|
||||
try {
|
||||
await fn();
|
||||
console.log(` PASS: ${name}`);
|
||||
passed++;
|
||||
} catch (e) {
|
||||
console.log(` FAIL: ${name}`);
|
||||
console.log(` ${e.message}`);
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
function assertBrandedWithLogo(html) {
|
||||
assert(
|
||||
html.includes(`Superpowers v${PACKAGE_VERSION}`),
|
||||
'branding text should include dynamic package version'
|
||||
);
|
||||
assert(
|
||||
!html.includes(`Superpowers v${PACKAGE_VERSION} by`),
|
||||
'branding text should not include "by" when the logo is visible'
|
||||
);
|
||||
assert(
|
||||
/<img class="brand-logo"[^>]*>\s*<span class="brand-copy">Superpowers v/.test(html),
|
||||
'visible logo should appear before the Superpowers version text'
|
||||
);
|
||||
assert(
|
||||
/\.brand a\s*\{[^}]*line-height:\s*1/i.test(html),
|
||||
'brand row should align the logo and version text by their visual height'
|
||||
);
|
||||
assert(
|
||||
/\.brand a\s*\{[^}]*gap:\s*0\.5rem/i.test(html),
|
||||
'brand row should keep the logo and version text close together'
|
||||
);
|
||||
assert(
|
||||
/\.brand a\s*\{[^}]*max-width:\s*100%/i.test(html),
|
||||
'brand link should be constrained so it cannot overlap the status column'
|
||||
);
|
||||
assert(
|
||||
/\.brand\s*\{[^}]*line-height:\s*1/i.test(html),
|
||||
'brand wrapper should not inherit the page line height'
|
||||
);
|
||||
assert(
|
||||
/\.brand\s*\{[^}]*overflow:\s*hidden/i.test(html),
|
||||
'brand wrapper should clip before it reaches the status column'
|
||||
);
|
||||
}
|
||||
|
||||
function assertBrandedFallbackText(html) {
|
||||
assert(
|
||||
html.includes(`Prime Radiant Superpowers v${PACKAGE_VERSION}`),
|
||||
'disabled telemetry should keep plain text Prime Radiant/Superpowers branding'
|
||||
);
|
||||
}
|
||||
|
||||
function assertTelemetryImage(html) {
|
||||
const expectedUrl = `${ASSET_URL}?v=${encodeURIComponent(PACKAGE_VERSION)}`;
|
||||
assert(html.includes(`src="${expectedUrl}"`), 'remote image should use the dedicated main-domain asset with only v=');
|
||||
assert(!html.includes('event='), 'remote image URL must not include event=');
|
||||
assert(!html.includes('surface='), 'remote image URL must not include surface=');
|
||||
assert(!html.includes('launch_id='), 'remote image URL must not include launch_id=');
|
||||
assert(!html.includes('lid='), 'remote image URL must not include lid=');
|
||||
}
|
||||
|
||||
function assertLogoKeepsTransparentBackground(html) {
|
||||
assert(
|
||||
/\.brand-logo\s*\{[^}]*height:\s*1em/i.test(html),
|
||||
'logo should match the surrounding brand text size'
|
||||
);
|
||||
assert(
|
||||
/\.brand-logo\s*\{[^}]*display:\s*block/i.test(html),
|
||||
'logo should not reserve inline-image descender space'
|
||||
);
|
||||
assert(
|
||||
/\.brand-copy\s*\{[^}]*line-height:\s*1/i.test(html),
|
||||
'version text should use the same compact line height as the logo'
|
||||
);
|
||||
assert(
|
||||
/\.brand-copy\s*\{[^}]*min-width:\s*0/i.test(html),
|
||||
'version text should be allowed to shrink inside the brand row'
|
||||
);
|
||||
assert(
|
||||
/\.brand-copy\s*\{[^}]*transform:\s*translateY\(-1px\)/i.test(html),
|
||||
'version text should compensate for bottom padding inside the logo asset'
|
||||
);
|
||||
assert(
|
||||
/\.brand-logo\s*\{[^}]*filter:\s*invert\(1\)/i.test(html),
|
||||
'white logo asset should invert on light backgrounds'
|
||||
);
|
||||
assert(
|
||||
!/\.brand-logo\s*\{[^}]*background:/i.test(html),
|
||||
'logo should keep its transparent background'
|
||||
);
|
||||
assert(
|
||||
!/\.brand-logo\s*\{[^}]*padding:/i.test(html),
|
||||
'logo should not rely on a padded backing'
|
||||
);
|
||||
}
|
||||
|
||||
function assertFramedLogoSupportsDarkTheme(html) {
|
||||
assert(
|
||||
/@media\s*\(prefers-color-scheme:\s*dark\)[\s\S]*\.brand-logo\s*\{[^}]*filter:\s*none/i.test(html),
|
||||
'framed screens should leave the white logo unfiltered in dark mode'
|
||||
);
|
||||
}
|
||||
|
||||
function assertFramedScreenUsesBrandHeader(html) {
|
||||
const logoCount = (html.match(/class="brand-logo"/g) || []).length;
|
||||
assert.strictEqual(logoCount, 1, 'framed screens should render the logo only in the header');
|
||||
assert(!html.includes('<div class="indicator-bar">'), 'framed screens should not render footer chrome');
|
||||
assert(
|
||||
/<div class="header">[\s\S]*<div class="brand">[\s\S]*<div class="status">Connecting…<\/div>/.test(html),
|
||||
'header should contain branding and connection status'
|
||||
);
|
||||
assert(!html.includes('id="indicator-text"'), 'header should not render the selection indicator text');
|
||||
assert(!html.includes('Click an option above'), 'header should not render the selection instruction');
|
||||
}
|
||||
|
||||
function assertHeaderAvoidsNarrowOverlap(html) {
|
||||
assert(
|
||||
/grid-template-columns:\s*minmax\(0,\s*1fr\)\s*auto/i.test(html),
|
||||
'header should allocate shrinkable space to branding before the status column'
|
||||
);
|
||||
assert(
|
||||
/\.header \.status\s*\{[^}]*grid-column:\s*2/i.test(html),
|
||||
'status should live in the final fixed-width grid column'
|
||||
);
|
||||
assert(
|
||||
/\.header \.brand\s*\{[^}]*width:\s*100%/i.test(html),
|
||||
'header brand should fill its grid track so overflow clipping prevents overlap'
|
||||
);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('\n--- Visual Companion Branding ---');
|
||||
|
||||
await test('framed screens render versioned Prime Radiant logo by default', async () => {
|
||||
const port = 3451;
|
||||
const dir = '/tmp/brainstorm-branding-default';
|
||||
await withServer({ port, dir }, async () => {
|
||||
writeFragment(dir);
|
||||
await sleep(300);
|
||||
const html = await fetchHtml(port);
|
||||
assertBrandedWithLogo(html);
|
||||
assertTelemetryImage(html);
|
||||
assertLogoKeepsTransparentBackground(html);
|
||||
assertFramedLogoSupportsDarkTheme(html);
|
||||
assertFramedScreenUsesBrandHeader(html);
|
||||
assertHeaderAvoidsNarrowOverlap(html);
|
||||
});
|
||||
});
|
||||
|
||||
await test('waiting screen renders versioned Prime Radiant logo by default', async () => {
|
||||
const port = 3452;
|
||||
const dir = '/tmp/brainstorm-branding-waiting';
|
||||
await withServer({ port, dir }, async () => {
|
||||
const html = await fetchHtml(port);
|
||||
assert(html.includes('Waiting for the agent'), 'waiting page should still render');
|
||||
assertBrandedWithLogo(html);
|
||||
assertTelemetryImage(html);
|
||||
assertLogoKeepsTransparentBackground(html);
|
||||
});
|
||||
});
|
||||
|
||||
await test('SUPERPOWERS_DISABLE_TELEMETRY=true omits remote image but keeps local branding', async () => {
|
||||
const port = 3453;
|
||||
const dir = '/tmp/brainstorm-branding-disabled';
|
||||
await withServer({ port, dir, env: { SUPERPOWERS_DISABLE_TELEMETRY: 'true' } }, async () => {
|
||||
writeFragment(dir);
|
||||
await sleep(300);
|
||||
const html = await fetchHtml(port);
|
||||
assertBrandedFallbackText(html);
|
||||
assert(!html.includes(ASSET_URL), 'disabled telemetry should omit the remote image');
|
||||
});
|
||||
});
|
||||
|
||||
await test('SUPERPOWERS_DISABLE_TELEMETRY=yes also omits the remote image on the waiting screen', async () => {
|
||||
const port = 3454;
|
||||
const dir = '/tmp/brainstorm-branding-disabled-waiting';
|
||||
await withServer({ port, dir, env: { SUPERPOWERS_DISABLE_TELEMETRY: 'yes' } }, async () => {
|
||||
const html = await fetchHtml(port);
|
||||
assertBrandedFallbackText(html);
|
||||
assert(!html.includes(ASSET_URL), 'disabled telemetry should omit the remote image');
|
||||
});
|
||||
});
|
||||
|
||||
await test('DISABLE_TELEMETRY=true omits remote image for Claude Code telemetry opt-out', async () => {
|
||||
const port = 3455;
|
||||
const dir = '/tmp/brainstorm-branding-claude-disable-telemetry';
|
||||
await withServer({ port, dir, env: { DISABLE_TELEMETRY: 'true' } }, async () => {
|
||||
writeFragment(dir);
|
||||
await sleep(300);
|
||||
const html = await fetchHtml(port);
|
||||
assertBrandedFallbackText(html);
|
||||
assert(!html.includes(ASSET_URL), 'Claude Code telemetry opt-out should omit the remote image');
|
||||
});
|
||||
});
|
||||
|
||||
await test('CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 omits remote image for Claude Code traffic opt-out', async () => {
|
||||
const port = 3456;
|
||||
const dir = '/tmp/brainstorm-branding-claude-disable-nonessential';
|
||||
await withServer({ port, dir, env: { CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1' } }, async () => {
|
||||
const html = await fetchHtml(port);
|
||||
assertBrandedFallbackText(html);
|
||||
assert(!html.includes(ASSET_URL), 'Claude Code non-essential traffic opt-out should omit the remote image');
|
||||
});
|
||||
});
|
||||
|
||||
console.log(`\n--- Results: ${passed} passed, ${failed} failed ---`);
|
||||
if (failed > 0) process.exitCode = 1;
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('Test failed:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "brainstorm-server-tests",
|
||||
"version": "1.0.0",
|
||||
"scripts": {
|
||||
"test": "node ws-protocol.test.js && node helper.test.js && node browser-launcher.test.js && node auth.test.js && node server.test.js && node lifecycle.test.js && bash start-server.test.sh && bash stop-server.test.sh"
|
||||
"test": "node ws-protocol.test.js && node helper.test.js && node browser-launcher.test.js && node auth.test.js && node branding.test.js && node server.test.js && node lifecycle.test.js && bash start-server.test.sh && bash stop-server.test.sh"
|
||||
},
|
||||
"dependencies": {
|
||||
"ws": "^8.19.0"
|
||||
|
||||
@@ -196,7 +196,7 @@ async function runTests() {
|
||||
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');
|
||||
assert(!res.body.includes('<div class="header">'), 'Should NOT wrap in frame template');
|
||||
});
|
||||
|
||||
await test('wraps content fragments in frame template', async () => {
|
||||
@@ -205,7 +205,7 @@ async function runTests() {
|
||||
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('<div class="header">'), 'Fragment should get header chrome');
|
||||
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');
|
||||
@@ -560,8 +560,16 @@ async function runTests() {
|
||||
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('<div class="header">'), 'Should have top header markup');
|
||||
assert(!template.includes('indicator-bar'), 'Should not have footer chrome');
|
||||
assert(!template.includes('indicator-text'), 'Header should not render selection indicator text');
|
||||
assert(template.includes('<!-- BRANDING -->'), 'Should have branding placeholder');
|
||||
assert(template.includes('<div class="status">Connecting…</div>'), 'Header should include connection status');
|
||||
assert(template.includes('grid-template-columns: minmax(0, 1fr) auto;'), 'Header should let brand text shrink before the status column');
|
||||
assert(template.includes('padding: 0.5rem 1.5rem;'), 'Header should keep equal left and right edge padding');
|
||||
assert(template.includes('.header .brand { justify-self: start; width: 100%; font-size: 0.75rem; line-height: 1; }'), 'Header brand should align left, fill its grid track, and match header text size');
|
||||
assert(template.includes('.header .status { grid-column: 2; line-height: 1; }'), 'Header status should sit in the right column');
|
||||
assert(!template.includes('<div></div>'), 'Header should not use an empty spacer before branding');
|
||||
assert(template.includes('<!-- CONTENT -->'), 'Should have content placeholder');
|
||||
assert(template.includes('frame-content'), 'Should have content container');
|
||||
return Promise.resolve();
|
||||
|
||||
Reference in New Issue
Block a user