mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-06-03 21:21:51 +00:00
05876b3a59
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>
293 lines
15 KiB
JavaScript
293 lines
15 KiB
JavaScript
/**
|
|
* E2E (#1173): Replace #liveDot WebSocket indicator with packet-driven
|
|
* brand-logo node-pulse animation.
|
|
*
|
|
* Red-then-green pattern (per AGENTS.md TDD rule). This file is committed
|
|
* BEFORE the implementation; CI must FAIL on assertion (not import error).
|
|
*
|
|
* The implementation must expose a deterministic test hook on
|
|
* window.__corescopeLogo
|
|
* with the following surface (pure CSS animations, no per-frame mutation):
|
|
* .pulse(msg) — simulate one WS message arrival (rate-gated)
|
|
* .setConnected(b) — simulate connect/disconnect class toggle
|
|
* .lastDirection — 'a' or 'b' — direction of most recent ping
|
|
* .stats — { triggered, dropped }
|
|
* Implementations may also wire real WS handlers; this hook is the test seam.
|
|
*
|
|
* Usage: BASE_URL=http://localhost:13581 node test-logo-pulse-1173-e2e.js
|
|
*/
|
|
'use strict';
|
|
const { chromium } = require('playwright');
|
|
|
|
const BASE = process.env.BASE_URL || 'http://localhost:13581';
|
|
|
|
let passed = 0, failed = 0;
|
|
async function step(name, fn) {
|
|
try { await fn(); passed++; console.log(' ✓ ' + name); }
|
|
catch (e) { failed++; console.error(' ✗ ' + name + ': ' + e.message); }
|
|
}
|
|
function assert(c, m) { if (!c) throw new Error(m || 'assertion failed'); }
|
|
|
|
(async () => {
|
|
const browser = await chromium.launch({
|
|
headless: true,
|
|
executablePath: process.env.CHROMIUM_PATH || undefined,
|
|
args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'],
|
|
});
|
|
|
|
console.log(`\n=== #1173 logo-pulse E2E against ${BASE} ===`);
|
|
|
|
// ---- Default viewport (full brand-logo SVG visible) ----
|
|
{
|
|
const ctx = await browser.newContext({ viewport: { width: 1280, height: 800 } });
|
|
const page = await ctx.newPage();
|
|
page.setDefaultTimeout(8000);
|
|
page.on('pageerror', (e) => console.error('[pageerror]', e.message));
|
|
await page.goto(BASE + '/#/home', { waitUntil: 'domcontentloaded' });
|
|
await page.waitForSelector('.brand-logo', { timeout: 8000 });
|
|
// Wait for app boot — hook should be installed during connectWS().
|
|
await page.waitForFunction(() => !!(window.__corescopeLogo && typeof window.__corescopeLogo.pulse === 'function'), null, { timeout: 8000 }).catch(()=>{});
|
|
|
|
// (a) #liveDot must NOT exist anywhere in the document.
|
|
await step('#liveDot is removed from the DOM', async () => {
|
|
const found = await page.evaluate(() => !!document.getElementById('liveDot'));
|
|
assert(!found, '#liveDot still present in DOM');
|
|
});
|
|
|
|
// (b) Both .brand-logo and .brand-mark-only carry the new pulse classes
|
|
// on their two inner circles.
|
|
await step('both logo SVGs have .logo-node-a and .logo-node-b circles', async () => {
|
|
const info = await page.evaluate(() => {
|
|
function probe(parentSel) {
|
|
const p = document.querySelector(parentSel);
|
|
if (!p) return { exists: false };
|
|
const a = p.querySelector('circle.logo-node-a');
|
|
const b = p.querySelector('circle.logo-node-b');
|
|
return { exists: true, hasA: !!a, hasB: !!b,
|
|
aCx: a && a.getAttribute('cx'), bCx: b && b.getAttribute('cx') };
|
|
}
|
|
return { full: probe('.brand-logo'), mark: probe('.brand-mark-only') };
|
|
});
|
|
assert(info.full.exists && info.full.hasA && info.full.hasB,
|
|
'.brand-logo missing pulse classes: ' + JSON.stringify(info.full));
|
|
assert(info.mark.exists && info.mark.hasA && info.mark.hasB,
|
|
'.brand-mark-only missing pulse classes: ' + JSON.stringify(info.mark));
|
|
assert(info.full.aCx === '540' && info.full.bCx === '660',
|
|
'pulse classes attached to wrong circles (expected cx=540/660): ' + JSON.stringify(info.full));
|
|
});
|
|
|
|
// (c) Test hook installed and pulse() toggles a class on the source circle.
|
|
await step('window.__corescopeLogo.pulse() toggles .logo-pulse-active on source circle', async () => {
|
|
const r = await page.evaluate(async () => {
|
|
if (!window.__corescopeLogo || typeof window.__corescopeLogo.pulse !== 'function') {
|
|
return { hookMissing: true };
|
|
}
|
|
const a = document.querySelector('.brand-logo circle.logo-node-a');
|
|
const b = document.querySelector('.brand-logo circle.logo-node-b');
|
|
const before = { a: a.classList.contains('logo-pulse-active'),
|
|
b: b.classList.contains('logo-pulse-active') };
|
|
window.__corescopeLogo.pulse({ synthetic: true });
|
|
// Class must be present synchronously OR within one rAF (≤16ms).
|
|
await new Promise(r => requestAnimationFrame(() => r()));
|
|
const after = { a: a.classList.contains('logo-pulse-active'),
|
|
b: b.classList.contains('logo-pulse-active'),
|
|
dir: window.__corescopeLogo.lastDirection };
|
|
return { hookMissing: false, before, after };
|
|
});
|
|
assert(!r.hookMissing, 'window.__corescopeLogo.pulse hook is missing');
|
|
// Either A or B must be active (the source of the first ping).
|
|
assert(r.after.a || r.after.b, 'no circle got .logo-pulse-active after first pulse: ' + JSON.stringify(r));
|
|
});
|
|
|
|
// (d) Direction alternates: 4 messages → toggles fire on alternating circles.
|
|
await step('direction alternates A→B / B→A across 4 pings', async () => {
|
|
const dirs = await page.evaluate(async () => {
|
|
// Wait long enough between pings to clear the rate gate.
|
|
const out = [];
|
|
for (let i = 0; i < 4; i++) {
|
|
window.__corescopeLogo.pulse({ synthetic: true });
|
|
out.push(window.__corescopeLogo.lastDirection);
|
|
await new Promise(r => setTimeout(r, 80));
|
|
}
|
|
return out;
|
|
});
|
|
// Expect a strict A,B,A,B (or B,A,B,A) alternation.
|
|
assert(dirs.length === 4, 'expected 4 direction samples, got ' + dirs.length);
|
|
assert(dirs[0] && dirs[1] && dirs[0] !== dirs[1], 'first two pings did not alternate: ' + dirs);
|
|
assert(dirs[2] === dirs[0] && dirs[3] === dirs[1],
|
|
'pings 3/4 did not alternate (expected ' + dirs[0] + ',' + dirs[1] + ',' + dirs[0] + ',' + dirs[1] + ', got ' + dirs.join(',') + ')');
|
|
});
|
|
|
|
// (e) Rate cap: 100 synthetic pulses within ~100ms → ≤16 toggles fire.
|
|
await step('rate-cap: 100 pulses in ~100ms drop most (≤16 trigger)', async () => {
|
|
const r = await page.evaluate(async () => {
|
|
const before = Object.assign({}, window.__corescopeLogo.stats);
|
|
const t0 = performance.now();
|
|
for (let i = 0; i < 100; i++) window.__corescopeLogo.pulse({ synthetic: true });
|
|
const t1 = performance.now();
|
|
const after = Object.assign({}, window.__corescopeLogo.stats);
|
|
return { before, after, elapsed: t1 - t0 };
|
|
});
|
|
const triggered = (r.after.triggered || 0) - (r.before.triggered || 0);
|
|
// Permit a small slack — 100 calls in <100ms should produce 1 ping
|
|
// (the rest hit the 66ms gate). Allow up to 16 to avoid flakes if the
|
|
// burst spans a window boundary.
|
|
assert(triggered >= 1 && triggered <= 16,
|
|
'rate-gate fired ' + triggered + ' times (expected 1..16) — stats=' + JSON.stringify(r));
|
|
});
|
|
|
|
// (g) Disconnect simulation: setConnected(false) → .logo-disconnected class.
|
|
await step('setConnected(false) puts .logo-disconnected on .brand-logo', async () => {
|
|
const has = await page.evaluate(() => {
|
|
window.__corescopeLogo.setConnected(false);
|
|
const full = document.querySelector('.brand-logo').classList.contains('logo-disconnected');
|
|
const mark = document.querySelector('.brand-mark-only').classList.contains('logo-disconnected');
|
|
// restore for next steps
|
|
window.__corescopeLogo.setConnected(true);
|
|
return { full, mark };
|
|
});
|
|
assert(has.full && has.mark, 'logo-disconnected not applied to both SVG instances: ' + JSON.stringify(has));
|
|
});
|
|
|
|
// (h) Theme: pulse circles get fill from --logo-accent / --logo-accent-hi.
|
|
await step('pulse circle fills resolve to --logo-accent/--logo-accent-hi tokens', async () => {
|
|
const r = await page.evaluate(() => {
|
|
const root = document.documentElement;
|
|
const cs = getComputedStyle(root);
|
|
const accent = cs.getPropertyValue('--logo-accent').trim();
|
|
const accentHi = cs.getPropertyValue('--logo-accent-hi').trim();
|
|
const a = document.querySelector('.brand-logo circle.logo-node-a');
|
|
const b = document.querySelector('.brand-logo circle.logo-node-b');
|
|
return {
|
|
accent, accentHi,
|
|
aFill: getComputedStyle(a).fill,
|
|
bFill: getComputedStyle(b).fill,
|
|
};
|
|
});
|
|
assert(r.accent && r.accentHi, '--logo-accent / --logo-accent-hi not defined');
|
|
// Computed fill resolves to rgb(...) — just sanity-check it is non-empty
|
|
// and not the default black/transparent.
|
|
assert(r.aFill && r.aFill !== 'rgb(0, 0, 0)' && r.aFill !== 'rgba(0, 0, 0, 0)', 'node-a fill not themed: ' + r.aFill);
|
|
assert(r.bFill && r.bFill !== 'rgb(0, 0, 0)' && r.bFill !== 'rgba(0, 0, 0, 0)', 'node-b fill not themed: ' + r.bFill);
|
|
});
|
|
|
|
await ctx.close();
|
|
}
|
|
|
|
// (f) prefers-reduced-motion: blip class differs from chained pulse class.
|
|
{
|
|
const ctx = await browser.newContext({
|
|
viewport: { width: 1280, height: 800 },
|
|
reducedMotion: 'reduce',
|
|
});
|
|
const page = await ctx.newPage();
|
|
page.setDefaultTimeout(8000);
|
|
await page.goto(BASE + '/#/home', { waitUntil: 'domcontentloaded' });
|
|
await page.waitForSelector('.brand-logo', { timeout: 8000 });
|
|
await page.waitForFunction(() => !!(window.__corescopeLogo && typeof window.__corescopeLogo.pulse === 'function'), null, { timeout: 8000 }).catch(()=>{});
|
|
|
|
await step('prefers-reduced-motion: blip class is .logo-pulse-blip (not .logo-pulse-active)', async () => {
|
|
const r = await page.evaluate(async () => {
|
|
if (!window.__corescopeLogo) return { hookMissing: true };
|
|
window.__corescopeLogo.pulse({ synthetic: true });
|
|
await new Promise(r => requestAnimationFrame(() => r()));
|
|
const a = document.querySelector('.brand-logo circle.logo-node-a');
|
|
const b = document.querySelector('.brand-logo circle.logo-node-b');
|
|
return {
|
|
hookMissing: false,
|
|
activeA: a.classList.contains('logo-pulse-active'),
|
|
activeB: b.classList.contains('logo-pulse-active'),
|
|
blipA: a.classList.contains('logo-pulse-blip'),
|
|
blipB: b.classList.contains('logo-pulse-blip'),
|
|
};
|
|
});
|
|
assert(!r.hookMissing, 'window.__corescopeLogo hook missing in reduced-motion ctx');
|
|
assert(r.blipA || r.blipB, 'reduced-motion did not toggle .logo-pulse-blip: ' + JSON.stringify(r));
|
|
assert(!(r.activeA || r.activeB), 'reduced-motion incorrectly toggled chained .logo-pulse-active: ' + JSON.stringify(r));
|
|
});
|
|
|
|
await ctx.close();
|
|
}
|
|
|
|
// ---- Hidden-tab gate (#1177 carmack must-fix #1) ----
|
|
// When document.hidden=true, pulse() must return false BEFORE updating
|
|
// lastPingTs and BEFORE scheduling any rAF/setTimeout chain. No circle
|
|
// class toggles must occur.
|
|
{
|
|
const ctx = await browser.newContext({ viewport: { width: 1280, height: 800 } });
|
|
const page = await ctx.newPage();
|
|
page.setDefaultTimeout(8000);
|
|
await page.goto(BASE + '/#/home', { waitUntil: 'domcontentloaded' });
|
|
await page.waitForSelector('.brand-logo', { timeout: 8000 });
|
|
await page.waitForFunction(() => !!(window.__corescopeLogo && typeof window.__corescopeLogo.pulse === 'function'), null, { timeout: 8000 }).catch(()=>{});
|
|
|
|
await step('hidden tab: pulse() returns false and toggles no classes', async () => {
|
|
const r = await page.evaluate(async () => {
|
|
Object.defineProperty(document, 'hidden', { value: true, configurable: true });
|
|
Object.defineProperty(document, 'visibilityState', { value: 'hidden', configurable: true });
|
|
const before = Object.assign({}, window.__corescopeLogo.stats);
|
|
const ret = window.__corescopeLogo.pulse({ synthetic: true });
|
|
await new Promise(r => requestAnimationFrame(() => r()));
|
|
const a = document.querySelector('.brand-logo circle.logo-node-a');
|
|
const b = document.querySelector('.brand-logo circle.logo-node-b');
|
|
const after = Object.assign({}, window.__corescopeLogo.stats);
|
|
return {
|
|
ret, before, after,
|
|
activeA: a.classList.contains('logo-pulse-active'),
|
|
activeB: b.classList.contains('logo-pulse-active'),
|
|
blipA: a.classList.contains('logo-pulse-blip'),
|
|
blipB: b.classList.contains('logo-pulse-blip'),
|
|
};
|
|
});
|
|
assert(r.ret === false, 'pulse() should return false when document.hidden=true (got ' + r.ret + ')');
|
|
assert(!r.activeA && !r.activeB, 'logo-pulse-active should not toggle in hidden tab');
|
|
assert(!r.blipA && !r.blipB, 'logo-pulse-blip should not toggle in hidden tab');
|
|
assert((r.after.triggered || 0) === (r.before.triggered || 0),
|
|
'stats.triggered must not increment in hidden tab');
|
|
});
|
|
|
|
await ctx.close();
|
|
}
|
|
|
|
// ---- matchMedia caching (#1177 carmack must-fix #2) ----
|
|
// The reduced-motion query must be cached at module load. 100 pulses
|
|
// must NOT result in 100 window.matchMedia() calls.
|
|
{
|
|
const ctx = await browser.newContext({ viewport: { width: 1280, height: 800 } });
|
|
const page = await ctx.newPage();
|
|
page.setDefaultTimeout(8000);
|
|
// Wrap window.matchMedia BEFORE any app script runs.
|
|
await page.addInitScript(() => {
|
|
const orig = window.matchMedia;
|
|
window.__matchMediaCalls = 0;
|
|
window.matchMedia = function (q) {
|
|
try { window.__matchMediaCalls = (window.__matchMediaCalls | 0) + 1; } catch (_) {}
|
|
return orig.call(window, q);
|
|
};
|
|
});
|
|
await page.goto(BASE + '/#/home', { waitUntil: 'domcontentloaded' });
|
|
await page.waitForSelector('.brand-logo', { timeout: 8000 });
|
|
await page.waitForFunction(() => !!(window.__corescopeLogo && typeof window.__corescopeLogo.pulse === 'function'), null, { timeout: 8000 }).catch(()=>{});
|
|
|
|
await step('matchMedia: cached singleton — 100 pulses do not call window.matchMedia per pulse', async () => {
|
|
const r = await page.evaluate(async () => {
|
|
const callsBefore = window.__matchMediaCalls | 0;
|
|
for (let i = 0; i < 100; i++) window.__corescopeLogo.pulse({ synthetic: true });
|
|
await new Promise(r => setTimeout(r, 50));
|
|
const callsAfter = window.__matchMediaCalls | 0;
|
|
return { callsBefore, callsAfter, delta: callsAfter - callsBefore };
|
|
});
|
|
// 100 pulses → matchMedia should NOT be invoked per pulse. Allow 0 (cached).
|
|
assert(r.delta === 0,
|
|
'matchMedia called ' + r.delta + ' times during 100 pulses (expected 0 — should be cached at module load)');
|
|
});
|
|
|
|
await ctx.close();
|
|
}
|
|
|
|
await browser.close();
|
|
|
|
console.log(`\n=== #1173 logo-pulse E2E: ${passed} passed, ${failed} failed ===`);
|
|
process.exit(failed ? 1 : 0);
|
|
})();
|