mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-09-27 04:27:59 +00:00
**Red commit:** `851cc8c3a024b1675558092d772444bf4f1ec625` — failing test on a stub branch (will link CI run after PR opens). Partial fix for #1648 (M5 of 6). **Do NOT close the tracking issue** — M6 (server-side residual emoji sweep + lint gate) still pending. ## Per-file swap counts | File | Phosphor `<use>` refs | Notes | |---|---|---| | `public/customize.js` | 20 | DEFAULTS → `ph:<name>` tokens; render path keeps legacy emoji branch (back-compat) | | `public/customize-v2.js` | 26 | same as v1; cv2 overrides path unchanged | | `public/home.js` | (helpers added) | `_renderHomeGlyph` / `_renderHomeLabel` accept both `ph:<name>` and legacy emoji | | `public/geofilter-builder.html` | 5 | clear / undo / save / load buttons (+inline `.ph-icon` CSS) | | `public/audio.js` | 1 | audio unlock prompt | | `public/filter-ux.js` | 5 (3 new) | help popover star + close, saved-filter delete | | `public/style.css` | 0 | `#chList .ch-share-btn::before { content: '📤' }` removed; JS now renders an inline sprite | | `cmd/server/routes.go` | (6 `ph:` tokens) | onboarding home defaults updated in lockstep with customize-v2.js | ## Operator config back-compat — PROMINENT Per design call #1 (user-locked): existing operator-stored emoji values in `config.json` / `localStorage` are **NOT** touched. The render path supports both: ```js function renderConfigGlyph(value) { var m = String(value || '').match(/^ph:([a-z][a-z0-9-]+)$/); if (m) return '<svg class="ph-icon"><use href="/icons/phosphor-sprite.svg#ph-' + m[1] + '"/></svg>'; return esc(value); // EMOJI-OK-LEGACY-RENDER — operator-stored emoji/text path } ``` Defaults flipped to `ph:<name>` tokens, so new operators (and operators who hit "Reset to Defaults") see Phosphor sprites. Operators with stored emoji values continue to see their emoji exactly as before. Verified end-to-end (see E2E (b) below). ## cmd/server/routes.go — changed in lockstep Per design call #2: the home-defaults `steps` / `footerLinks` mirror the JS DEFAULTS, so they MUST update together. routes.go now emits `ph:<name>` tokens; the frontend home-render path resolves them. Existing tests (`TestConfigThemeHomeDefaults`) still pass — they assert structure, not glyph values. ## E2E assertions added - `test-issue-1648-m5-emoji-scan.js` — per-file zero-emoji + ph-token DEFAULTS + sprite presence - `test-issue-1648-m5-icons-e2e.js`: - (a) customize chrome — tabs/header rendered as sprites; chrome text icon-free - **(b) back-compat — injects fake `🐙` operator step into localStorage, reloads, opens customize, asserts the emoji renders verbatim in both the input value AND the live preview span; asserts the ph-token step renders as a sprite** (design call #1 in action) - (c) `/channels` modal sprite count - (d) `/audio-lab` sprite presence - (e) `geofilter-builder.html` control buttons sprite-driven - (f) every `<use>` resolves to a defined symbol id ## Out of scope (M6 cleanup) - cmd/server/routes.go residual server-rendered emoji **not** tied to customize defaults (none found by my grep — file already audited) - `make lint-no-emoji` CI grep gate (M6 owns it) - `public/icons/README.md` workflow doc cross-stack: justified — design call #2 requires Go + JS update together. --------- Co-authored-by: openclaw-bot <bot@openclaw.local>
271 lines
13 KiB
JavaScript
271 lines
13 KiB
JavaScript
#!/usr/bin/env node
|
|
/* Issue #1648 — M5: emoji → Phosphor sprite (E2E behavioral).
|
|
*
|
|
* Asserts (in a real Chromium against a running server):
|
|
* (a) Customize panel — toggling the customizer renders Phosphor sprites
|
|
* in tab labels, header, and reset/save buttons.
|
|
* (b) Customize panel BACK-COMPAT: when localStorage has operator config
|
|
* with a legacy emoji step value, that emoji must STILL render — the
|
|
* new code must NOT override operator-stored emoji values (design
|
|
* call #1 from the M5 brief).
|
|
* (c) /channels modal open — modal action buttons render sprites.
|
|
* (d) /audio-lab — audio unlock prompt + action icons render sprites.
|
|
* (e) geofilter-builder.html — control buttons render sprites.
|
|
* (f) NO .notdef anywhere — every <use> resolves to a defined symbol id.
|
|
*
|
|
* CI gating: CHROMIUM_REQUIRE=1 makes Chromium-launch failure a HARD FAIL.
|
|
*/
|
|
'use strict';
|
|
|
|
const { chromium } = require('playwright');
|
|
const assert = require('assert');
|
|
|
|
const BASE = process.env.BASE_URL || 'http://localhost:13581';
|
|
const M5_FORBIDDEN_RE = /[\u{1F300}-\u{1FAFF}\u{2600}-\u{27BF}◆●■▲★☆○✓✗⚠✉✕]/u;
|
|
|
|
let passes = 0, failures = 0;
|
|
function pass(msg) { console.log(` ✓ ${msg}`); passes++; }
|
|
function fail(msg) { console.error(` ✗ ${msg}`); failures++; }
|
|
|
|
async function gotoSpa(page, route) {
|
|
await page.goto(`${BASE}/#${route}`, { waitUntil: 'domcontentloaded' });
|
|
await page.waitForFunction(() => !!document.querySelector('#app'), null, { timeout: 8000 }).catch(() => {});
|
|
await page.waitForTimeout(700);
|
|
}
|
|
|
|
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-issue-1648-m5-icons-e2e.js: HARD FAIL — Chromium unavailable: ${err.message}`);
|
|
process.exit(1);
|
|
}
|
|
console.warn(`SKIP — Chromium unavailable: ${err.message}`);
|
|
process.exit(0);
|
|
}
|
|
|
|
const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 } });
|
|
const page = await ctx.newPage();
|
|
|
|
// ── (a) Customize panel — defaults render as Phosphor sprites ──────
|
|
await gotoSpa(page, '/');
|
|
// Click the customize trigger (palette icon in nav).
|
|
await page.evaluate(() => {
|
|
const btn = document.querySelector('[data-toggle="customize"], #customizeToggle, .cust-toggle, [aria-label*="ustomize" i]');
|
|
if (btn) btn.click();
|
|
else if (window.Customize && window.Customize.toggle) window.Customize.toggle();
|
|
else if (window.CustomizeV2 && window.CustomizeV2.toggle) window.CustomizeV2.toggle();
|
|
});
|
|
await page.waitForTimeout(900);
|
|
const custA = await page.evaluate(() => {
|
|
const overlay = document.querySelector('.cust-overlay');
|
|
if (!overlay) return { open: false };
|
|
const tabs = overlay.querySelectorAll('.cust-tab');
|
|
let tabSprites = 0; tabs.forEach(t => { tabSprites += t.querySelectorAll('svg.ph-icon use').length; });
|
|
return {
|
|
open: true,
|
|
tabCount: tabs.length,
|
|
tabSprites,
|
|
headerSprites: overlay.querySelectorAll('.cust-header svg.ph-icon use').length,
|
|
overlayText: overlay.textContent || '',
|
|
};
|
|
});
|
|
if (!custA.open) {
|
|
fail('(a) customize panel did not open — cannot assert');
|
|
} else {
|
|
if (custA.tabSprites < custA.tabCount) {
|
|
fail(`(a) customize tabs: ${custA.tabSprites} sprite(s) for ${custA.tabCount} tabs (each tab needs ≥1)`);
|
|
} else {
|
|
pass(`(a) customize tabs: ${custA.tabSprites} sprite(s) across ${custA.tabCount} tabs`);
|
|
}
|
|
if (custA.headerSprites < 2) {
|
|
fail(`(a) customize header: ${custA.headerSprites} sprite(s) (expected ≥2 — title + close)`);
|
|
} else {
|
|
pass(`(a) customize header: ${custA.headerSprites} sprite(s)`);
|
|
}
|
|
// Note: the overlay still RENDERS legacy operator emoji values via
|
|
// renderConfigGlyph for back-compat (test (b) below). The overlay's own
|
|
// chrome/tabs/headers must be sprite-driven, so we look at chrome only.
|
|
const chromeText = await page.evaluate(() => {
|
|
const ovl = document.querySelector('.cust-overlay');
|
|
if (!ovl) return '';
|
|
// Walk only the tab + header chrome, not the input previews (which
|
|
// legitimately echo operator values — including legacy emoji).
|
|
const parts = [];
|
|
ovl.querySelectorAll('.cust-tab, .cust-header').forEach(el => parts.push(el.textContent || ''));
|
|
return parts.join(' | ');
|
|
});
|
|
if (M5_FORBIDDEN_RE.test(chromeText)) {
|
|
fail(`(a) customize chrome still contains an emoji/misc-icon: ${JSON.stringify(chromeText.match(M5_FORBIDDEN_RE))}`);
|
|
} else {
|
|
pass('(a) customize chrome (tabs + header) is icon-free text');
|
|
}
|
|
}
|
|
|
|
// ── (b) BACK-COMPAT: operator-stored emoji values must still render ──
|
|
// Inject a fake user-theme into localStorage with a legacy emoji step,
|
|
// reload, re-open customize, navigate to home tab, and confirm the
|
|
// emoji STILL renders verbatim (not stripped, not replaced).
|
|
await page.evaluate(() => {
|
|
const legacy = {
|
|
home: {
|
|
heroTitle: 'CoreScope',
|
|
heroSubtitle: 'legacy test',
|
|
steps: [
|
|
{ emoji: '🐙', title: 'Legacy octopus step', description: 'Operator-stored emoji must survive M5 migration.' },
|
|
{ emoji: 'ph:bluetooth', title: 'New ph token step', description: 'Renders as sprite.' },
|
|
],
|
|
footerLinks: [{ label: '🦄 Legacy unicorn', url: '#/legacy' }],
|
|
},
|
|
};
|
|
localStorage.setItem('meshcore-user-theme', JSON.stringify(legacy));
|
|
// customize-v2 reads from cv2 overrides too
|
|
try {
|
|
const cv2 = { home: legacy.home };
|
|
localStorage.setItem('meshcore-cv2-overrides', JSON.stringify(cv2));
|
|
} catch (e) {}
|
|
});
|
|
await page.reload({ waitUntil: 'domcontentloaded' });
|
|
await page.waitForTimeout(700);
|
|
// Re-open customize and switch to Home tab.
|
|
await page.evaluate(() => {
|
|
const btn = document.querySelector('[data-toggle="customize"], #customizeToggle, .cust-toggle, [aria-label*="ustomize" i]');
|
|
if (btn) btn.click();
|
|
else if (window.Customize && window.Customize.toggle) window.Customize.toggle();
|
|
else if (window.CustomizeV2 && window.CustomizeV2.toggle) window.CustomizeV2.toggle();
|
|
});
|
|
await page.waitForTimeout(700);
|
|
await page.evaluate(() => {
|
|
const home = document.querySelector('.cust-overlay .cust-tab[data-tab="home"]');
|
|
if (home) home.click();
|
|
});
|
|
await page.waitForTimeout(500);
|
|
const custB = await page.evaluate(() => {
|
|
const overlay = document.querySelector('.cust-overlay');
|
|
if (!overlay) return { open: false };
|
|
// The step emoji input value should contain the literal 🐙 char.
|
|
const inputs = Array.from(overlay.querySelectorAll('.cust-emoji-input'));
|
|
const previews = Array.from(overlay.querySelectorAll('.cust-emoji-preview'));
|
|
return {
|
|
open: true,
|
|
inputValues: inputs.map(i => i.value),
|
|
previewHtmls: previews.map(p => p.innerHTML),
|
|
};
|
|
});
|
|
if (!custB.open) {
|
|
fail('(b) back-compat: customize panel did not open after localStorage inject');
|
|
} else {
|
|
const hasOctopus = custB.inputValues.some(v => v && v.indexOf('🐙') !== -1);
|
|
if (!hasOctopus) {
|
|
fail(`(b) back-compat: operator-stored emoji 🐙 missing from inputs (values=${JSON.stringify(custB.inputValues)})`);
|
|
} else {
|
|
pass('(b) back-compat: operator-stored emoji 🐙 survives in step input value');
|
|
}
|
|
const hasOctopusPreview = custB.previewHtmls.some(h => h && h.indexOf('🐙') !== -1);
|
|
if (!hasOctopusPreview) {
|
|
fail(`(b) back-compat: emoji preview span does not render 🐙 (htmls=${JSON.stringify(custB.previewHtmls)})`);
|
|
} else {
|
|
pass('(b) back-compat: renderConfigGlyph rendered 🐙 as text (legacy branch)');
|
|
}
|
|
const hasPhSprite = custB.previewHtmls.some(h => h && /<use[^>]+ph-bluetooth/.test(h));
|
|
if (!hasPhSprite) {
|
|
fail('(b) back-compat: new ph:bluetooth step did not render as sprite');
|
|
} else {
|
|
pass('(b) back-compat: ph:bluetooth step rendered as Phosphor sprite alongside legacy emoji');
|
|
}
|
|
}
|
|
// Clean up the legacy injection so it doesn't leak into later assertions.
|
|
await page.evaluate(() => {
|
|
localStorage.removeItem('meshcore-user-theme');
|
|
localStorage.removeItem('meshcore-cv2-overrides');
|
|
});
|
|
|
|
// ── (c) /channels modal open — modal controls render sprites ────────
|
|
await page.reload({ waitUntil: 'domcontentloaded' });
|
|
await gotoSpa(page, '/channels');
|
|
// The share/QR modals are gated on a user-added channel; we just assert
|
|
// any modal-close button present on the page already renders a sprite
|
|
// (the M3 swap landed `.modal-close` chrome → sprite).
|
|
const ch = await page.evaluate(() => {
|
|
const modals = Array.from(document.querySelectorAll('.modal, .ch-modal, .modal-overlay'));
|
|
let modalSprites = 0;
|
|
modals.forEach(m => { modalSprites += m.querySelectorAll('svg.ph-icon use').length; });
|
|
const pageSprites = document.querySelectorAll('svg.ph-icon use').length;
|
|
return { modalCount: modals.length, modalSprites, pageSprites };
|
|
});
|
|
if (ch.pageSprites < 3) fail(`(c) /channels: only ${ch.pageSprites} sprites total (expected ≥3)`);
|
|
else pass(`(c) /channels: ${ch.pageSprites} sprite refs on page (${ch.modalSprites} inside ${ch.modalCount} modal element(s))`);
|
|
|
|
// ── (d) /audio-lab — sprite icons in audio prompts/controls ────────
|
|
await gotoSpa(page, '/audio-lab');
|
|
await page.waitForTimeout(500);
|
|
const aud = await page.evaluate(() => {
|
|
return {
|
|
sprites: document.querySelectorAll('svg.ph-icon use').length,
|
|
bodyText: (document.getElementById('app') || document.body).textContent || '',
|
|
};
|
|
});
|
|
if (aud.sprites < 1) fail(`(d) /audio-lab: ${aud.sprites} sprite refs (expected ≥1)`);
|
|
else pass(`(d) /audio-lab: ${aud.sprites} sprite refs`);
|
|
|
|
// ── (e) geofilter-builder.html (standalone) ─────────────────────────
|
|
await page.goto(`${BASE}/geofilter-builder.html`, { waitUntil: 'domcontentloaded' });
|
|
await page.waitForTimeout(500);
|
|
const geo = await page.evaluate(() => {
|
|
const ctrls = document.querySelectorAll('.controls button');
|
|
let s = 0; ctrls.forEach(b => { s += b.querySelectorAll('svg.ph-icon use').length; });
|
|
return {
|
|
ctrlSprites: s,
|
|
ctrlCount: ctrls.length,
|
|
btnTexts: Array.from(ctrls).map(b => b.textContent || ''),
|
|
};
|
|
});
|
|
if (geo.ctrlSprites < geo.ctrlCount) {
|
|
fail(`(e) geofilter-builder: ${geo.ctrlSprites} sprite(s) for ${geo.ctrlCount} control button(s)`);
|
|
} else {
|
|
pass(`(e) geofilter-builder: ${geo.ctrlSprites} sprite(s) across ${geo.ctrlCount} control buttons`);
|
|
}
|
|
const ctrlEmoji = geo.btnTexts.find(t => M5_FORBIDDEN_RE.test(t));
|
|
if (ctrlEmoji) fail(`(e) geofilter-builder: control button still contains emoji/misc-icon: ${JSON.stringify(ctrlEmoji)}`);
|
|
else pass('(e) geofilter-builder: control button text is icon-free');
|
|
|
|
// ── (f) Every <use> resolves ────────────────────────────────────────
|
|
await gotoSpa(page, '/');
|
|
const undef = await page.evaluate(async () => {
|
|
const resp = await fetch('/icons/phosphor-sprite.svg').catch(() => null);
|
|
if (!resp || !resp.ok) return { error: 'sprite fetch failed' };
|
|
const text = await resp.text();
|
|
const ids = new Set();
|
|
for (const m of text.matchAll(/id="(ph-[a-z-]+)"/g)) ids.add(m[1]);
|
|
const uses = Array.from(document.querySelectorAll('svg.ph-icon use'));
|
|
const missing = [];
|
|
for (const u of uses) {
|
|
const href = u.getAttribute('href') || u.getAttribute('xlink:href') || '';
|
|
const m = href.match(/#(ph-[a-z-]+)/);
|
|
if (!m) { missing.push(href); continue; }
|
|
if (!ids.has(m[1])) missing.push(m[1]);
|
|
}
|
|
return { count: uses.length, ids: ids.size, missing };
|
|
});
|
|
if (undef.error) fail(`(f) sprite fetch: ${undef.error}`);
|
|
else if (undef.missing && undef.missing.length) fail(`(f) ${undef.missing.length} sprite ref(s) unresolved: ${undef.missing.slice(0,5).join(', ')}`);
|
|
else pass(`(f) all ${undef.count} sprite refs resolve to one of ${undef.ids} defined symbols`);
|
|
|
|
await browser.close();
|
|
console.log(`\ntest-issue-1648-m5-icons-e2e.js: ${passes} passed, ${failures} failed`);
|
|
assert.strictEqual(failures, 0, `${failures} M5 icon-render assertions failed`);
|
|
process.exit(0);
|
|
}
|
|
|
|
main().catch((err) => {
|
|
console.error('test-issue-1648-m5-icons-e2e.js: FAIL —', err);
|
|
process.exit(1);
|
|
});
|