Files
sencho/scripts/check-preference-parity.mjs
Anso a54ebcc7e2 feat(fleet): separate the Fleet tabs into two sections, with a layout setting (#2092)
* feat(fleet): group the Fleet tabs into Observe and Operate, with a layout setting

The Fleet tab strip was ten tabs in one row, wider than a 1280px window.

The strip now sets the tabs under two labelled groups on one strip: Observe
(Overview, Readiness, Map, Docker Labels) and Operate (Snapshots, Blueprints,
Routing, Federation, Actions, Secrets). It scrolls sideways when the window is
too narrow and keeps the tab you are on in view. Every tab still opens with one
click and every tab keeps its value, so deep links and URL sync are unchanged.

A new account preference, Settings, Appearance, Fleet, Fleet tab layout, picks
how the strip is laid out: Grouped (default), Flat (the previous single row), or
Compact (Overview, Readiness, Map, Blueprints and Actions, with the rest under a
More menu that names the active hidden tab). It is saved to the account and
synced like the other appearance preferences, defaults to Grouped for a document
that lacks it, and is cleared with the rest of the preference cache when another
account signs in.

* refactor(fleet): drop the Observe and Operate labels and the old flat strip

The group labels only lengthened the tab row, and the previous single-row
layout is no longer needed. The Fleet tab strip is now one row: Overview,
Readiness, Map and Docker Labels, a vertical separator, then Snapshots,
Blueprints, Routing, Federation, Actions and Secrets. It still scrolls sideways
when the window is too narrow and keeps the active tab in view.

The Fleet tab layout setting now offers Flat (the default, this row) and
Compact (five tabs with the rest under More). The retired grouped value reads
as Flat.

* test(fleet): raise the cross-tab storage event without the StorageEvent constructor

The code quality scan flagged the two-argument StorageEvent calls as
superfluous trailing arguments. The tests now build the event from a
plain Event carrying the key and the new value, which is all the hook
reads.
2026-10-07 09:14:25 -04:00

177 lines
8.3 KiB
JavaScript

#!/usr/bin/env node
/**
* Enum parity check for the per-user preference documents.
*
* The backend Zod schemas (backend/src/routes/userPreferences.ts) and the
* frontend registries (frontend/src/hooks/use-theme.ts,
* frontend/src/hooks/use-density.ts, frontend/src/hooks/use-top-nav-mode.ts,
* frontend/src/hooks/use-top-nav-align.ts,
* frontend/src/hooks/use-log-chip-color-mode.ts,
* frontend/src/lib/navigation/appNavRegistry.ts) must accept the same value
* sets. The backend cannot import frontend sources (tsconfig rootDir is
* ./src and backend tests participate in production compilation), so this
* script parses the literal declarations from source text instead.
*
* Exits non-zero on any value-set drift. Wired as the `pretest` script of
* both backend and frontend package.json.
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(__dirname, '..');
const read = (rel) => fs.readFileSync(path.join(repoRoot, rel), 'utf8');
const routeSrc = read('backend/src/routes/userPreferences.ts');
const themeSrc = read('frontend/src/hooks/use-theme.ts');
const navModeSrc = read('frontend/src/hooks/use-top-nav-mode.ts');
const navAlignSrc = read('frontend/src/hooks/use-top-nav-align.ts');
const densitySrc = read('frontend/src/hooks/use-density.ts');
const logChipSrc = read('frontend/src/hooks/use-log-chip-color-mode.ts');
const sidebarLayoutSrc = read('frontend/src/hooks/use-sidebar-layout.ts');
const anatomyLayoutSrc = read('frontend/src/hooks/use-anatomy-layout.ts');
const fleetTabLayoutSrc = read('frontend/src/hooks/use-fleet-tab-layout.ts');
const navRegistrySrc = read('frontend/src/lib/navigation/appNavRegistry.ts');
let failed = false;
const fail = (msg) => { console.error(`[preference-parity] FAIL: ${msg}`); failed = true; };
const ok = (msg) => console.log(`[preference-parity] ok: ${msg}`);
/** Extract the quoted members of a `const NAME = [...]` string-array literal. */
function extractArrayLiteral(src, name) {
const re = new RegExp(`\\bconst ${name}\\b[^=]*=\\s*\\[([^\\]]*)\\]`, 's');
const m = src.match(re);
if (!m) return null;
const values = [...m[1].matchAll(/'([^']+)'/g)].map((x) => x[1]);
return values.length > 0 ? values : null;
}
/** Extract the quoted members of a `Set(['a','b'])` literal. */
function extractSetLiteral(src, name) {
const re = new RegExp(`${name}\\s*=\\s*new Set\\(\\[([^\\]]*)\\]\\)`, 's');
const m = src.match(re);
if (!m) return null;
const values = [...m[1].matchAll(/'([^']+)'/g)].map((x) => x[1]);
return values.length > 0 ? values : null;
}
/** Extract the quoted members of a union type `type NAME = 'a' | 'b';`. */
function extractUnionType(src, name) {
const re = new RegExp(`(?:export )?type ${name}\\s*=[^;]*;`, 's');
const m = src.match(re);
if (!m) return null;
const values = [...m[0].matchAll(/'([^']+)'/g)].map((x) => x[1]);
return values.length > 0 ? values : null;
}
/** Extract the ids of registry items carrying `quickLinkEligible: true`.
* Pairs each quoted `value:` in the registry array with the
* `quickLinkEligible:` flag in the same item and counts the pairs; the
* caller fails the comparison when the pair count disagrees with the number
* of `quickLinkEligible:` flags in the source, so a refactor that renames
* either property (breaking the pairing) cannot pass vacuously. */
function extractEligibleIds(src) {
const items = [...src.matchAll(/value:\s*'([^']+)'[\s\S]*?quickLinkEligible:\s*(true|false)/g)];
const eligible = [];
for (const [, value, flag] of items) {
if (flag === 'true') eligible.push(value);
}
return { eligible: eligible.length > 0 ? eligible : null, paired: items.length };
}
function compare(label, backend, frontend) {
if (!backend || !frontend) {
fail(`${label}: could not extract declaration (backend=${JSON.stringify(backend)}, frontend=${JSON.stringify(frontend)})`);
return;
}
const b = [...backend].sort().join(',');
const f = [...frontend].sort().join(',');
if (b !== f) {
fail(`${label}: backend [${b}] != frontend [${f}]`);
return;
}
ok(`${label}: ${backend.length} values match`);
}
function compareCount(label, expected, actual) {
if (expected !== actual) {
fail(`${label}: expected ${expected} paired declarations, found ${actual} (extractor out of date?)`);
return;
}
ok(`${label}: ${actual} paired declarations extracted`);
}
// Backend declarations (string-array tables).
const bThemes = extractArrayLiteral(routeSrc, 'THEME_MODES');
const bAccents = extractArrayLiteral(routeSrc, 'ACCENT_IDS');
const bUiFonts = extractArrayLiteral(routeSrc, 'UI_FONT_IDS');
const bMonoFonts = extractArrayLiteral(routeSrc, 'MONO_FONT_IDS');
const bVisual = extractArrayLiteral(routeSrc, 'VISUAL_STYLES');
const bHeading = extractArrayLiteral(routeSrc, 'HEADING_STYLES');
const bChart = extractArrayLiteral(routeSrc, 'CHART_STYLES');
const bDensities = extractArrayLiteral(routeSrc, 'DENSITIES');
const bLogChip = extractArrayLiteral(routeSrc, 'LOG_CHIP_COLOR_MODES');
const bSidebarModes = extractArrayLiteral(routeSrc, 'SIDEBAR_MODES');
const bAnatomyModes = extractArrayLiteral(routeSrc, 'ANATOMY_MODES');
const bFleetTabLayouts = extractArrayLiteral(routeSrc, 'FLEET_TAB_LAYOUTS');
const bNavModes = extractArrayLiteral(routeSrc, 'NAV_MODES');
const bAligns = extractArrayLiteral(routeSrc, 'NAV_ALIGNS');
const bQuickLinks = extractArrayLiteral(routeSrc, 'QUICK_LINK_IDS');
// Frontend declarations (union types and Set literals).
const fThemes = extractUnionType(themeSrc, 'ThemeMode');
const fAccents = extractUnionType(themeSrc, 'AccentId');
const fUiFonts = extractUnionType(themeSrc, 'UiFont');
const fMonoFonts = extractUnionType(themeSrc, 'MonoFont');
const fVisual = extractArrayLiteral(themeSrc, 'VISUAL_STYLES');
const fHeading = extractArrayLiteral(themeSrc, 'HEADING_STYLES');
const fChart = extractArrayLiteral(themeSrc, 'CHART_STYLES');
const fDensity = extractUnionType(densitySrc, 'Density');
const fLogChip = extractUnionType(logChipSrc, 'LogChipColorMode');
const fSidebarModes = extractUnionType(sidebarLayoutSrc, 'SidebarMode');
const fAnatomyModes = extractUnionType(anatomyLayoutSrc, 'AnatomyMode');
const fFleetTabLayouts = extractUnionType(fleetTabLayoutSrc, 'FleetTabLayout');
const fNavModes = extractSetLiteral(navModeSrc, 'VALID') ?? extractUnionType(navModeSrc, 'TopNavMode');
const fAligns = extractUnionType(navAlignSrc, 'TopNavAlign');
const fEligible = extractEligibleIds(navRegistrySrc);
compare('theme modes', bThemes, fThemes);
compare('accents', bAccents, fAccents);
compare('ui fonts', bUiFonts, fUiFonts);
compare('mono fonts', bMonoFonts, fMonoFonts);
compare('visual styles', bVisual, fVisual);
compare('heading styles', bHeading, fHeading);
compare('chart styles', bChart, fChart);
compare('densities', bDensities, fDensity);
compare('log chip color modes', bLogChip, fLogChip);
compare('sidebar modes', bSidebarModes, fSidebarModes);
compare('anatomy modes', bAnatomyModes, fAnatomyModes);
compare('fleet tab layouts', bFleetTabLayouts, fFleetTabLayouts);
compare('nav modes', bNavModes, fNavModes);
compare('nav aligns', bAligns, fAligns);
// Quick links: the backend list must be a superset of the frontend's
// eligible ids (the backend rejects unknown ids; headroom allows the
// frontend cap to grow without a 400 on previously saved documents). The
// paired-declaration guard additionally proves the registry extractor still
// matches every `quickLinkEligible:` flag in the source, so a refactor that
// renames the property cannot make the check pass vacuously.
if (!bQuickLinks || !fEligible) {
fail(`quick links: could not extract declaration (backend=${bQuickLinks?.length} frontend=${fEligible?.eligible?.length})`);
} else {
compareCount('quick links pairing', (navRegistrySrc.match(/ quickLinkEligible:/g) ?? []).length, fEligible.paired);
const missing = fEligible.eligible.filter((id) => !bQuickLinks.includes(id));
if (missing.length > 0) {
fail(`quick links: frontend eligible ids missing from backend enum: ${missing.join(', ')}`);
} else {
ok(`quick links: all ${fEligible.eligible.length} frontend eligible ids present in backend enum (${bQuickLinks.length} entries)`);
}
}
if (failed) {
console.error('[preference-parity] value-set drift detected; update both sides in the same change.');
process.exit(1);
}
console.log('[preference-parity] all value sets match.');