Files
meshcore-analyzer/test-logo-rebrand-e2e.js
T
Kpa-clawbot 05876b3a59 fix(#1173): replace #liveDot with packet-driven brand-logo node-pulse (#1177)
Red commit: PENDING (will update)

Fixes #1173.

Replaces the `#liveDot` WebSocket-connected indicator with a
packet-driven node-pulse animation on the brand logo's two inner
circles.

## Behavior (locked per issue spec)
- **Animation curve:** `ease-out` (default per open-question 1).
- **Rate cap:** 15/sec (66ms gap; default per open-question 2). Excess
triggers are dropped, never queued.
- **Direction:** alternates A→B / B→A across messages (aesthetic, not
semantic).
- **Idle ≥10s:** logo at full brightness, no animation.
- **Disconnected:** `.logo-disconnected` applies `filter: grayscale(0.6)
opacity(0.7)`.
- **`prefers-reduced-motion: reduce`:** single-step `.logo-pulse-blip`
on destination only.

## Implementation
- WS handler hook lives in `public/app.js` `connectWS()` (`ws.onmessage`
triggers `Logo.pulse()`; `ws.onopen`/`ws.onclose` toggle
`Logo.setConnected()`).
- `Logo` is a small IIFE in `app.js` that exposes
`window.__corescopeLogo` for E2E injection.
- All animation is pure CSS; JS only toggles `.logo-pulse-active` /
`.logo-pulse-blip` / `.logo-disconnected`. Colors come exclusively from
`--logo-accent` / `--logo-accent-hi` tokens.
- Two new classes (`.logo-node-a`, `.logo-node-b`) attached to inner
circles in both `.brand-logo` and `.brand-mark-only` SVGs so the mobile
mark animates too.

## `#liveDot` removal proof
```
$ grep -rn liveDot public/
(no output)
```

## E2E
- E2E assertion added: `test-logo-pulse-1173-e2e.js:54` and follows.
- Wired into the Playwright matrix in `.github/workflows/deploy.yml`
(mirrors PR #1168 pattern from commit `5442652`).
- Test injects synthetic pings via `window.__corescopeLogo.pulse({
synthetic: true })`; matches the existing harness style (no new WS-mock
pattern invented).

Red→green discipline preserved: the test commit lands first and CI fails
on assertion; the implementation commit follows.

---------

Co-authored-by: Kpa-clawbot <bot@kpa-clawbot>
Co-authored-by: corescope-bot <bot@corescope.local>
2026-05-08 20:25:42 -07:00

263 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
/* Logo rebrand E2E — verifies the new CoreScope SVG logo is wired into
* the navbar (replacing the 🍄 emoji + "CoreScope" text) and that the
* homepage renders a hero version of the logo above the H1.
*
* Asserts (in order):
* 1. Navbar has an <img> whose src ends with /img/corescope-logo.svg
* OR an inline <svg class="brand-logo"> (PR #1137 inlined the SVG so
* it can inherit page CSS vars and theme on light/dark).
* The brand element must be INSIDE the .nav-brand link (so the brand
* link stays clickable).
* 2. Old .brand-icon (🍄) and .brand-text spans are gone.
* 3. The .live-dot WS-status indicator is still present and visible
* and sits to the right of the logo (left edge of dot ≥ right edge of img).
* 4. The home page (#/home) renders an <img.home-hero-logo> whose src
* ends with /img/corescope-hero.svg, ABOVE the .home-hero h1.
* 5. Both SVG assets resolve with HTTP 200 and content-type contains
* "svg" (catches a missing file regression cleanly).
*
* CI gating mirrors the existing playwright e2e tests: with
* CHROMIUM_REQUIRE=1 a missing Chromium is a HARD FAIL.
*/
'use strict';
const { chromium } = require('playwright');
const http = require('http');
const BASE = process.env.BASE_URL || 'http://localhost:13581';
function fail(msg) {
console.error(`test-logo-rebrand-e2e.js: FAIL — ${msg}`);
process.exit(1);
}
function assert(cond, msg) { if (!cond) fail(msg || 'assertion failed'); }
async function head(url) {
return new Promise((resolve, reject) => {
const u = new URL(url);
const req = http.request({
method: 'GET',
hostname: u.hostname,
port: u.port || 80,
path: u.pathname + (u.search || ''),
}, (res) => {
let body = '';
res.on('data', (c) => { body += c; if (body.length > 4096) body = body.slice(0, 4096); });
res.on('end', () => resolve({ status: res.statusCode, ct: res.headers['content-type'] || '', sample: body }));
});
req.on('error', reject);
req.end();
});
}
async function main() {
const requireChromium = process.env.CHROMIUM_REQUIRE === '1';
let browser;
try {
browser = await chromium.launch({
headless: true,
executablePath: process.env.CHROMIUM_PATH || undefined,
args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'],
});
} catch (err) {
if (requireChromium) {
console.error(`test-logo-rebrand-e2e.js: FAIL — Chromium required but unavailable: ${err.message}`);
process.exit(1);
}
console.log(`test-logo-rebrand-e2e.js: SKIP (Chromium unavailable: ${err.message.split('\n')[0]})`);
process.exit(0);
}
let passed = 0;
const total = 6;
try {
const context = await browser.newContext({ viewport: { width: 1280, height: 900 } });
const page = await context.newPage();
page.setDefaultTimeout(10000);
// 1. Navbar has the brand logo inside .nav-brand. Post PR #1137 the
// default is an inline <svg.brand-logo>; if an operator overrode
// branding.logoUrl the customizer swaps it for an <img.brand-logo>.
await page.goto(BASE + '/#/', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('.nav-brand', { timeout: 8000 });
const navBrand = await page.evaluate(() => {
const el = document.querySelector('.nav-brand .brand-logo');
if (!el) return { ok: false, reason: 'no .brand-logo in .nav-brand' };
const tag = el.tagName.toLowerCase();
if (tag === 'img') {
const src = el.getAttribute('src') || '';
return { ok: /corescope-logo\.svg($|\?)/.test(src), tag, src };
}
if (tag === 'svg') {
// Inline SVG default — verify it actually renders the brand artwork.
const hasText = !!el.querySelector('text');
return { ok: hasText, tag, src: '<inline-svg>' };
}
return { ok: false, reason: 'unexpected .brand-logo tag: ' + tag };
});
if (!navBrand.ok) {
fail(`navbar .brand-logo invalid (${navBrand.reason || 'tag=' + navBrand.tag + ' src=' + navBrand.src})`);
}
console.log(` ✅ navbar contains .brand-logo (${navBrand.tag})`);
passed++;
// 2. Old emoji + brand-text are gone
const oldIcon = await page.$('.nav-brand .brand-icon');
const oldText = await page.$('.nav-brand .brand-text');
if (oldIcon || oldText) fail('legacy .brand-icon / .brand-text still present (should be replaced by SVG logo)');
console.log(' ✅ legacy mushroom emoji + "CoreScope" text removed');
passed++;
// 3. WS connection state indicator: #1173 replaced .live-dot with the
// packet-driven brand-logo pulse. The state surface is the .brand-logo
// SVG itself (gains .logo-disconnected on close, removes it on open),
// and the test seam at window.__corescopeLogo.
//
// Note: the previous version of this test asserted the geometry of
// the .live-dot relative to the brand-logo (dot must be to the right
// of the SVG). That coverage is replaced with a brand-logo layout
// assertion (visible, non-zero box, sensible aspect) so SVG rendering
// regressions are still caught — they simply moved targets.
const noLegacyDot = await page.$('.nav-brand .live-dot, .nav-brand #liveDot');
if (noLegacyDot) fail('.live-dot / #liveDot still present — should have been removed by #1173');
const seam = await page.evaluate(() => {
return !!(window.__corescopeLogo && typeof window.__corescopeLogo.setConnected === 'function' && typeof window.__corescopeLogo.pulse === 'function');
});
if (!seam) fail('window.__corescopeLogo (setConnected + pulse) is the new WS-state seam — missing');
// Brand-logo layout sanity (replaces the dot-right-of-logo geometry assertion).
const brandLayout = await page.evaluate(() => {
const i = document.querySelector('.nav-brand .brand-logo');
if (!i) return { ok: false, reason: 'no .brand-logo' };
const r = i.getBoundingClientRect();
const cs = getComputedStyle(i);
return {
ok: true,
w: r.width, h: r.height,
visible: cs.display !== 'none' && cs.visibility !== 'hidden' && parseFloat(cs.opacity || '1') > 0,
};
});
// assert: brand-logo is visibly rendered with a sensible box.
assert(brandLayout.ok, 'brand-logo layout probe failed: ' + brandLayout.reason);
assert(brandLayout.visible, 'brand-logo not visible (display/visibility/opacity)');
assert(brandLayout.w >= 60 && brandLayout.h >= 16,
`brand-logo too small: ${brandLayout.w.toFixed(1)}×${brandLayout.h.toFixed(1)} (expected ≥60×16)`);
console.log(' ✅ legacy .live-dot removed; brand-logo Logo state seam present; brand-logo layout sane');
passed++;
// 4. Home hero image — ensure user level is set so we render the hero,
// not the new-user chooser screen.
await page.evaluate(() => { try { localStorage.setItem('meshcore-user-level', 'experienced'); } catch (_) {} });
await page.evaluate(() => { window.location.hash = '#/home'; });
await page.waitForFunction(() => location.hash === '#/home');
// Reload so the SPA router picks up the route AND localStorage is honored.
await page.reload({ waitUntil: 'domcontentloaded' });
await page.waitForSelector('.home-hero', { timeout: 8000 });
const heroBrand = await page.evaluate(() => {
const hero = document.querySelector('.home-hero');
if (!hero) return { ok: false, reason: '.home-hero missing' };
// PR #1137: inline <svg.home-hero-logo> by default; legacy <img> still
// valid for any operator who shipped a custom build.
const el = hero.querySelector('.home-hero-logo');
if (!el) return { ok: false, reason: '.home-hero-logo missing inside .home-hero' };
const tag = el.tagName.toLowerCase();
if (tag === 'img') {
const src = el.getAttribute('src') || '';
return { ok: /corescope-hero\.svg($|\?)/.test(src), tag, src };
}
if (tag === 'svg') {
const hasText = !!el.querySelector('text');
return { ok: hasText, tag };
}
return { ok: false, reason: 'unexpected .home-hero-logo tag: ' + tag };
});
if (!heroBrand.ok) {
fail(`home page .home-hero-logo invalid (${heroBrand.reason || 'tag=' + heroBrand.tag})`);
}
const order = await page.evaluate(() => {
const hero = document.querySelector('.home-hero');
if (!hero) return -1;
const img = hero.querySelector('.home-hero-logo');
const h1 = hero.querySelector('h1');
if (!img || !h1) return -2;
return (img.compareDocumentPosition(h1) & Node.DOCUMENT_POSITION_FOLLOWING) ? 1 : 0;
});
if (order !== 1) fail(`home-hero brand element must precede the <h1> (compareDocumentPosition=${order})`);
console.log(` ✅ home page hero contains .home-hero-logo (${heroBrand.tag}) above the h1`);
passed++;
// 5. Both assets actually serve
const [a, b] = await Promise.all([
head(BASE + '/img/corescope-logo.svg'),
head(BASE + '/img/corescope-hero.svg'),
]);
if (a.status !== 200 || !/svg/i.test(a.ct)) fail(`/img/corescope-logo.svg → status=${a.status} ct=${a.ct}`);
if (b.status !== 200 || !/svg/i.test(b.ct)) fail(`/img/corescope-hero.svg → status=${b.status} ct=${b.ct}`);
console.log(' ✅ both /img/corescope-{logo,hero}.svg return 200 with svg content-type');
passed++;
// 6. Customizer override path still works after the rebrand. Operators
// can override branding.siteName + branding.logoUrl via the customizer
// (cs-theme-overrides localStorage key in customize-v2.js); the old
// code mutated .brand-text / .brand-icon (which no longer exist), so
// a naive removal silently breaks the override flow. Verify the navbar
// logo <img> picks up the override on next load.
await page.evaluate(() => {
try {
// customize-v2.js storage key for live overrides.
localStorage.setItem('cs-theme-overrides', JSON.stringify({
branding: { siteName: 'OverrideSite', logoUrl: '/img/corescope-logo.svg?override=1' }
}));
} catch (_) {}
});
await page.goto(BASE + '/#/', { waitUntil: 'networkidle' });
// PR #1137: default brand is inline <svg>; the override path swaps it
// for an <img>. Wait for either tag to be present (boot-time render).
await page.waitForSelector('.nav-brand .brand-logo', { timeout: 8000 });
// Force-apply the override pipeline (in case _customizerV2.init was racing
// /api/config/theme — production code's DOMContentLoaded boot path runs
// synchronously, but instrumented JS in CI can be slower).
await page.evaluate(() => {
try {
if (window._customizerV2 && typeof window._customizerV2.init === 'function') {
window._customizerV2.init(window.SITE_CONFIG || {});
}
} catch (_) {}
});
// Give pipeline a moment to settle: the helper swaps inline-<svg> → <img>.
await page.waitForFunction(() => {
var img = document.querySelector('.nav-brand img');
return img && /override=1/.test(img.getAttribute('src') || '');
}, { timeout: 5000 }).catch(() => {});
const overrideState = await page.evaluate(() => {
var img = document.querySelector('.nav-brand img');
return {
src: img ? img.getAttribute('src') || '' : null,
alt: img ? img.getAttribute('alt') || '' : null,
title: document.title,
hasV2: !!window._customizerV2,
ovStored: localStorage.getItem('cs-theme-overrides'),
};
});
if (!overrideState.src || !/override=1/.test(overrideState.src)) {
fail(`customizer logoUrl override did not propagate to navbar img (src=${overrideState.src} hasV2=${overrideState.hasV2} ovStored=${overrideState.ovStored})`);
}
if (overrideState.title !== 'OverrideSite') {
fail(`customizer siteName override did not update document.title (got: ${overrideState.title})`);
}
console.log(' ✅ customizer branding.siteName + branding.logoUrl overrides still apply post-rebrand');
passed++;
// Clean up the override so subsequent test runs aren't polluted.
await page.evaluate(() => { try { localStorage.removeItem('cs-theme-overrides'); } catch (_) {} });
await browser.close();
console.log(`\ntest-logo-rebrand-e2e.js: ${passed}/${total} PASS`);
} catch (err) {
try { await browser.close(); } catch (_) {}
console.error(`test-logo-rebrand-e2e.js: FAIL — ${err.message}`);
process.exit(1);
}
}
main();