mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-09-25 17:03:36 +00:00
fix(scope-audit): ship the stylesheet the page paints its badges with (#2005)
Fixes #2004. Two review rounds; findings and evidence on the PR. Verified by injecting the stylesheet into a running deployment and reading computed styles before and after: the badges gain background, size, uppercase and padding; at 430px the Config column stays visible; the sorted column keeps its accent. The new test fails on the eight unstyled classes against the pre-fix tree, on .ns-truncated against the first fix, and on a re-added column-hiding rule.
This commit is contained in:
@@ -249,6 +249,7 @@ jobs:
|
||||
node test-issue-1360-pill-letter-count.js
|
||||
node test-issue-1364-pill-no-clamp.js
|
||||
node test-issue-1375-scope-stats-fetch.js
|
||||
node test-scope-audit-styles-linked.js
|
||||
node test-issue-1361-cb-presets.js
|
||||
node test-issue-1380-cb-sim-overlay.js
|
||||
node test-issue-1380-cb-reset-button.js
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
<link rel="stylesheet" href="home.css?v=__BUST__">
|
||||
<link rel="stylesheet" href="live.css?v=__BUST__">
|
||||
<link rel="stylesheet" href="node-reach.css?v=__BUST__">
|
||||
<link rel="stylesheet" href="node-scopes.css?v=__BUST__">
|
||||
<link rel="stylesheet" href="scope-audit.css?v=__BUST__">
|
||||
<link rel="stylesheet" href="node-reach-coverage.css?v=__BUST__">
|
||||
<link rel="stylesheet" href="bottom-nav.css?v=__BUST__">
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
/* Node detail — Scopes section. Reuses .analytics-stats / .analytics-stat-card /
|
||||
.analytics-time-range from the analytics page (same tokens node-reach.css
|
||||
reuses); these are the scopes-specific bits only. No hex/rgb literals —
|
||||
--text-muted / --border / --status-* are defined globally. --section-bg is
|
||||
NOT: it exists only in the dark-theme blocks (style.css), never in light
|
||||
:root, so every use below MUST keep the var(--section-bg, var(--card-bg))
|
||||
fallback — the fallback is load-bearing, not decorative. */
|
||||
|
||||
.ns-head { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: 8px; margin-bottom: 8px; }
|
||||
.ns-head h4 { margin: 0; }
|
||||
.ns-window { margin: 0; }
|
||||
|
||||
.ns-declared-meta { font-size: 11px; color: var(--text-muted); margin: 0 0 10px; }
|
||||
/* Flattened from `.ns-declared-meta .ns-truncated` when this file was ported:
|
||||
the Scope Audit renders the marker in its Declared-age cell, with no
|
||||
.ns-declared-meta ancestor, so the scoped form styled nothing there. The
|
||||
standalone rule covers both callers. */
|
||||
.ns-truncated { color: var(--status-amber-text); font-weight: 600; }
|
||||
.ns-declared-meta .ns-never-asked { color: var(--text-muted); font-style: italic; }
|
||||
|
||||
.ns-empty {
|
||||
color: var(--text-muted); font-size: 13px; padding: 14px 12px;
|
||||
border: 1px dashed var(--border); border-radius: 6px; margin: 6px 0 4px;
|
||||
}
|
||||
.ns-empty.ns-empty-config { color: var(--status-amber-text); border-color: var(--status-amber-text); }
|
||||
|
||||
.ns-table { border-collapse: collapse; width: 100%; font-size: 12px; margin: 4px 0 10px; }
|
||||
.ns-table th, .ns-table td { border: 0; border-bottom: 1px solid var(--border); padding: 5px 8px; }
|
||||
.ns-table thead th { border-bottom: 2px solid var(--border); background: none; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .3px; color: var(--text-muted); text-align: left; }
|
||||
/* The rule above is (0,1,2) and outranks the global th.sort-active at (0,1,1)
|
||||
whatever the source order, so without this the sorted column label and its
|
||||
caret (fill: currentColor) both render muted. Measured on a deployment:
|
||||
rgb(74,158,255) before this file loads, rgb(91,99,112) after. Table-scoped,
|
||||
the same way .data-table and .analytics-table already solve it. */
|
||||
.ns-table thead th.sort-active { color: var(--link-color); }
|
||||
.ns-n { text-align: right; font-variant-numeric: tabular-nums; }
|
||||
.ns-scope-name { font-family: var(--mono); font-weight: 600; }
|
||||
|
||||
.ns-decl {
|
||||
display: inline-block; padding: 2px 7px; border-radius: var(--badge-radius, 4px);
|
||||
font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: .3px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ns-decl-yes { background: color-mix(in srgb, var(--status-green) 16%, transparent); color: var(--status-green-text); }
|
||||
.ns-decl-quiet { background: color-mix(in srgb, var(--status-yellow) 18%, transparent); color: var(--status-amber-text); }
|
||||
.ns-decl-warn { background: color-mix(in srgb, var(--status-red) 16%, transparent); color: var(--danger); }
|
||||
/* The border is load-bearing here and not on its siblings: --section-bg is
|
||||
defined only in the dark blocks, so in the light theme this falls back to
|
||||
--card-bg, which is white on a near-white page ground. Without the outline
|
||||
the "no flood" badge is invisible — the same failure this file is fixing.
|
||||
An inset shadow rather than a border so the chip keeps the exact box metrics
|
||||
of .ns-decl-yes/-quiet/-warn beside it in the same column. */
|
||||
.ns-decl-unknown { background: var(--section-bg, var(--card-bg)); box-shadow: inset 0 0 0 1px var(--border); color: var(--text-muted); font-weight: 600; text-transform: none; }
|
||||
|
||||
.ns-routes { font-size: 11px; color: var(--text-muted); margin: 0 0 14px; }
|
||||
.ns-routes b { color: var(--text); font-variant-numeric: tabular-nums; }
|
||||
|
||||
.ns-links { font-size: 12px; margin-top: 4px; }
|
||||
.ns-links a { color: var(--link-color); }
|
||||
|
||||
/* The fork this file comes from hides the third column of .ns-table at 480px,
|
||||
where .ns-table is the per-node Scopes panel's four-column table and column
|
||||
three is "Last seen". That rule is deliberately NOT ported: the only
|
||||
.ns-table in this repository is the Scope Audit's, whose third column is
|
||||
Config — the badge column this file exists to make visible. Measured at
|
||||
430px with the rule in place: the Config header and every badge under it
|
||||
computed to display:none. The audit table already scrolls horizontally
|
||||
(.sa-table-wrap + .sa-table min-width), so it needs no column hiding. When
|
||||
the panel lands here, it should bring a rule scoped to its own table. */
|
||||
@@ -36,7 +36,10 @@
|
||||
}
|
||||
.sa-chip-declared { background: var(--section-bg, var(--card-bg)); color: var(--text); border: 1px solid var(--border); }
|
||||
.sa-chip-undeclared { background: color-mix(in srgb, var(--status-yellow) 18%, transparent); color: var(--status-amber-text); }
|
||||
.sa-chip-wildcard { background: var(--section-bg, var(--card-bg)); color: var(--text-muted); font-weight: 700; }
|
||||
/* Same outline, same reason as .ns-decl-unknown: --section-bg is dark-theme
|
||||
only, so in light this falls back to --card-bg, which is white on a
|
||||
near-white page ground and leaves the wildcard chip unpainted. */
|
||||
.sa-chip-wildcard { background: var(--section-bg, var(--card-bg)); box-shadow: inset 0 0 0 1px var(--border); color: var(--text-muted); font-weight: 700; }
|
||||
.sa-chip-ambiguous { background: var(--section-bg, var(--card-bg)); color: var(--text-muted); border: 1px dashed var(--border); font-family: inherit; font-style: italic; }
|
||||
|
||||
.sa-count { font-size: 11px; margin-top: 6px; }
|
||||
@@ -48,9 +51,10 @@
|
||||
.sa-summary { display: flex; flex-wrap: wrap; gap: 10px 16px; font-size: 12px; color: var(--text-muted); margin: 2px 0 10px; }
|
||||
.sa-summary-item { display: inline-flex; align-items: center; gap: 5px; }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.sa-table th:nth-child(6), .sa-table td:nth-child(6) { display: none; }
|
||||
}
|
||||
/* A 640px rule hiding the table's sixth column used to live here. The table
|
||||
has five (scope-audit.js), so it hid nothing, and the wrapper already scrolls
|
||||
horizontally. Removed rather than repointed: hiding a column of this table is
|
||||
what made the Config badge disappear on phones when node-scopes.css landed. */
|
||||
|
||||
/* Observed forwarding, the green half of the merged Scopes column. Pairs with
|
||||
.sa-chip-unobserved below, which stays neutral. Only the observed side is
|
||||
|
||||
@@ -15,6 +15,7 @@ node test-aging.js
|
||||
node test-issue-1065-gesture-hints-gates.js
|
||||
node test-frontend-helpers.js
|
||||
node test-issue-2001-map-scope-state.js
|
||||
node test-scope-audit-styles-linked.js
|
||||
node test-fetch-all-nodes-pagination.js
|
||||
node test-my-repeaters-dashboard.js
|
||||
node test-repeater-metric-scatter.js
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
/* Every class the Scope Audit page paints with must have a rule that can
|
||||
* actually apply to the element the page renders, in a stylesheet index.html
|
||||
* links.
|
||||
*
|
||||
* The page shipped referencing .ns-decl / .ns-empty / .ns-table, whose rules
|
||||
* live in node-scopes.css — a file that was never added, and never linked. The
|
||||
* page still rendered: correct data, correct DOM, no console error, and every
|
||||
* badge as unstyled text. Nothing in the suite noticed, because nothing
|
||||
* compared the classes the page writes against the CSS the page loads.
|
||||
*
|
||||
* "Can actually apply" is the part a substring check gets wrong. `.ns-truncated`
|
||||
* was mentioned in exactly one rule, `.ns-declared-meta .ns-truncated`, and this
|
||||
* page renders that span with no such ancestor — styled on paper, unstyled on
|
||||
* screen, which is the same bug one level down.
|
||||
*
|
||||
* Deliberately not a whitelist of known-good class names: that would need
|
||||
* updating every time the page grows a class, which is exactly the moment this
|
||||
* check has to fire.
|
||||
*/
|
||||
'use strict';
|
||||
const fs = require('fs');
|
||||
const assert = require('assert');
|
||||
|
||||
let passed = 0, failed = 0;
|
||||
function test(name, fn) {
|
||||
try { fn(); passed++; console.log(` ✅ ${name}`); }
|
||||
catch (e) { failed++; console.log(` ❌ ${name}: ${e.message}`); }
|
||||
}
|
||||
|
||||
// linkedStylesheets returns the local stylesheet paths index.html loads, in
|
||||
// link order. Vendor CSS is included: it is loaded too, and excluding it would
|
||||
// be a guess about where a rule is allowed to live.
|
||||
function linkedStylesheets(html) {
|
||||
const out = [];
|
||||
const re = /<link[^>]+rel=["']stylesheet["'][^>]*>/g;
|
||||
let m;
|
||||
while ((m = re.exec(html))) {
|
||||
const href = /href=["']([^"']+)["']/.exec(m[0]);
|
||||
if (!href) continue;
|
||||
const path = href[1].split('?')[0];
|
||||
if (/^https?:/.test(path)) continue; // remote sheets are not ours to check
|
||||
out.push('public/' + path);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// classesUsedBy pulls class names out of the class="..." literals a page
|
||||
// script writes, plus the ' class-name' fragments it concatenates. Only the
|
||||
// page's own namespaces are checked; shared vocabulary (btn, active,
|
||||
// text-muted) belongs to style.css and is not this page's to own.
|
||||
function classesUsedBy(js, prefixes) {
|
||||
const found = new Set();
|
||||
const re = /class="([^"]*)"|'\s*([a-z][a-z0-9-]*)'/g;
|
||||
let m;
|
||||
while ((m = re.exec(js))) {
|
||||
const chunk = m[1] || m[2] || '';
|
||||
for (const raw of chunk.split(/[\s'"+]+/)) {
|
||||
const name = raw.trim();
|
||||
if (!name) continue;
|
||||
if (prefixes.some(p => name.startsWith(p))) found.add(name);
|
||||
}
|
||||
}
|
||||
return [...found].sort();
|
||||
}
|
||||
|
||||
function classRe(className) {
|
||||
return new RegExp('\\.' + className.replace(/-/g, '\\-') + '(?![a-zA-Z0-9_-])');
|
||||
}
|
||||
|
||||
// selectorsFor returns every selector that targets className, comments
|
||||
// stripped: a class named only inside a comment has no rule.
|
||||
function selectorsFor(css, className) {
|
||||
// At-rule preludes are dropped rather than skipped, so the rules INSIDE a
|
||||
// @media or @supports block are parsed like any other. Skipping them left
|
||||
// the checker blind to the rule that hid this page's Config column below
|
||||
// 480px: re-adding that rule kept the suite green.
|
||||
const clean = css
|
||||
.replace(/\/\*[\s\S]*?\*\//g, '')
|
||||
.replace(/@(?:media|supports|layer|container)[^{]*\{/g, '');
|
||||
const re = classRe(className);
|
||||
const out = [];
|
||||
for (const chunk of clean.split('}')) {
|
||||
const brace = chunk.indexOf('{');
|
||||
if (brace === -1) continue;
|
||||
for (const sel of chunk.slice(0, brace).split(',')) {
|
||||
const t = sel.trim();
|
||||
if (t && !t.startsWith('@') && re.test(t)) out.push(t);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// requiredAncestors lists the classes a selector needs on an ANCESTOR before
|
||||
// its own compound can match. ".a .b" requires a; ".a.b" requires nothing,
|
||||
// since both sit on one element.
|
||||
function requiredAncestors(selector, className) {
|
||||
const parts = selector.split(/\s*[>+~]\s*|\s+/).filter(Boolean);
|
||||
const own = parts.findIndex(p => classRe(className).test(p));
|
||||
if (own <= 0) return [];
|
||||
const need = [];
|
||||
for (const p of parts.slice(0, own)) {
|
||||
for (const m of p.match(/\.[a-zA-Z][a-zA-Z0-9_-]*/g) || []) need.push(m.slice(1));
|
||||
}
|
||||
return need;
|
||||
}
|
||||
|
||||
// A class passes when some rule targets it with no ancestor requirement, or
|
||||
// when every ancestor that rule requires is a class this page also emits.
|
||||
function ruleApplies(css, className, emitted) {
|
||||
const sels = selectorsFor(css, className);
|
||||
if (!sels.length) return false;
|
||||
return sels.some(sel => requiredAncestors(sel, className).every(a => emitted.has(a)));
|
||||
}
|
||||
|
||||
console.log('\n=== scope-audit: every class it paints with is in a linked stylesheet ===');
|
||||
|
||||
const html = fs.readFileSync('public/index.html', 'utf8');
|
||||
const sheets = linkedStylesheets(html);
|
||||
const css = sheets.map(p => (fs.existsSync(p) ? fs.readFileSync(p, 'utf8') : '')).join('\n');
|
||||
const js = fs.readFileSync('public/scope-audit.js', 'utf8');
|
||||
|
||||
// classAttrClasses reads only class="..." literals, so the ancestor set holds
|
||||
// classes that are really rendered. An earlier version accepted every
|
||||
// single-quoted lowercase token in the file, which let 'active', 'true',
|
||||
// 'number' and 'function' in and made the ancestor gate accept anything.
|
||||
function classAttrClasses(src) {
|
||||
const out = new Set();
|
||||
const re = /class="([^"]*)"/g;
|
||||
let m;
|
||||
while ((m = re.exec(src))) {
|
||||
for (const raw of m[1].split(/[\s'"+]+/)) {
|
||||
const t = raw.trim();
|
||||
if (/^[a-z][a-z0-9-]*$/.test(t)) out.add(t);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Checked classes are this page's own namespaces. The filter is defensive:
|
||||
// nothing in scope-audit.js builds a class name by concatenation today, but a
|
||||
// token like 'ns-decl-' would report as an unstyled class and say nothing.
|
||||
const used = classesUsedBy(js, ['ns-', 'sa-']).filter(c => /^[a-z][a-z0-9-]*[a-z0-9]$/.test(c));
|
||||
|
||||
// The ancestor set: classes this page renders in markup, plus the page's own
|
||||
// namespace tokens it assembles in variables, plus the app shell around it.
|
||||
// This answers "does the page render this class at all", not "is it an ancestor
|
||||
// of that element" — a rule like `.active .ns-decl` would pass. That is the
|
||||
// limit of a static check; the case it exists for is an ancestor the page never
|
||||
// renders under any arrangement.
|
||||
const emitted = new Set([...classAttrClasses(js), ...classAttrClasses(html), ...used]);
|
||||
|
||||
test('index.html links the stylesheets it names', () => {
|
||||
const missing = sheets.filter(p => !fs.existsSync(p));
|
||||
assert.deepStrictEqual(missing, [], 'linked but absent from the tree: ' + missing.join(', '));
|
||||
});
|
||||
|
||||
test('scope-audit.js uses a non-trivial number of its own classes', () => {
|
||||
assert.ok(used.length >= 10, 'expected the page to use its own vocabulary, found: ' + used.join(', '));
|
||||
});
|
||||
|
||||
test('every ns-* / sa-* class it writes has a rule that can apply', () => {
|
||||
const unstyled = used.filter(c => !ruleApplies(css, c, emitted));
|
||||
assert.deepStrictEqual(unstyled, [], 'classes with no applicable rule in any linked stylesheet: ' + unstyled.join(', '));
|
||||
});
|
||||
|
||||
// The regression that started the follow-up commit: a stylesheet hiding a
|
||||
// column of the table this page renders. The page's own CSS gives the table a
|
||||
// min-width and a horizontally scrolling wrapper, so hiding columns is never
|
||||
// the intended answer here, and the column that gets hidden is whichever one
|
||||
// the fork's panel happened to put third.
|
||||
test('no linked stylesheet hides a column of the audit table', () => {
|
||||
const clean = css.replace(/\/\*[\s\S]*?\*\//g, '').replace(/@(?:media|supports|layer|container)[^{]*\{/g, '');
|
||||
const offenders = [];
|
||||
for (const chunk of clean.split('}')) {
|
||||
const brace = chunk.indexOf('{');
|
||||
if (brace === -1) continue;
|
||||
const head = chunk.slice(0, brace);
|
||||
const body = chunk.slice(brace + 1);
|
||||
if (!/\.(ns|sa)-table/.test(head)) continue;
|
||||
if (!/nth-child|nth-of-type/.test(head)) continue;
|
||||
if (!/display\s*:\s*none/.test(body)) continue;
|
||||
offenders.push(head.trim().replace(/\s+/g, ' '));
|
||||
}
|
||||
assert.deepStrictEqual(offenders, [], 'column-hiding rules on the audit table: ' + offenders.join(' | '));
|
||||
});
|
||||
|
||||
// The checker itself, so a broken checker cannot pass the tree.
|
||||
test('the checker rejects a rule gated on an ancestor the page never renders', () => {
|
||||
const emit = new Set(['sa-page', 'ns-truncated']);
|
||||
assert.strictEqual(ruleApplies('.ns-truncated { color: red }', 'ns-truncated', emit), true);
|
||||
assert.strictEqual(ruleApplies('.sa-page .ns-truncated { color: red }', 'ns-truncated', emit), true);
|
||||
assert.strictEqual(ruleApplies('.never-rendered .ns-truncated { color: red }', 'ns-truncated', emit), false);
|
||||
assert.strictEqual(ruleApplies('/* .ns-truncated lives elsewhere */', 'ns-truncated', emit), false);
|
||||
assert.strictEqual(ruleApplies('.ns-truncated-other { color: red }', 'ns-truncated', emit), false);
|
||||
});
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed`);
|
||||
process.exit(failed === 0 ? 0 : 1);
|
||||
Reference in New Issue
Block a user