Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
300 changes: 298 additions & 2 deletions client/src/Providers/__tests__/themeCache.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
import { clickHouseTheme } from '@librechat/client';
import { themeRoleFingerprint } from 'librechat-data-provider';
import {
THEME_VERSION,
themeRoleFingerprint,
themeAppearanceTokens,
} from 'librechat-data-provider';
import {
themeBrandTokens,
themeColorTokens,
resolveTheme,
libreChatTheme,
clickHouseTheme,
validateThemeDefinition,
} from '@librechat/client';
import type { ThemeDefinition, ResolvedThemeStyle } from '@librechat/client';
import type { ThemeCacheEntry } from '../themeCache';
import {
themeOwner,
Expand Down Expand Up @@ -153,3 +165,287 @@ describe('theme cache storage', () => {
expect(localStorage.getItem(THEME_CACHE_KEY)).toBeNull();
});
});

/**
* The baseline the cache's persisted output is pinned to. The cache version keys on the role set
* and a hand-bumped `THEME_CACHE_EPOCH`, so a release that changes what a cacheable theme
* resolves to without adding a role would replay stale styling at boot. The digest covers what
* the cache persists (`buildThemeCache(...).modes`, with the property order canonicalized) for
* `librechat` and `clickhouse`, the definitions that can enter the cache (the boot script never
* replays one under high contrast), and for generated definitions:
* - every color, brand and appearance role overridden alone, theme-wide, in light only and in
* dark only (the other mode absent), plus a `none` sample for each shadow-like role;
* - every role the resolver derives from others, found by diffing its resolved output against the
* bare theme, with all of its sources named together and again with the role named as well, theme-wide and per mode, so
* fallback precedence and opting out are covered without listing the chains by hand;
* - every brand set theme-wide, in light and in dark with conflicting values;
* - definitions with one or both mode blocks absent.
* A new role, or a new fallback, joins by construction.
*/
const PIN = { fingerprint: '1.2.leqd1k', digest: '1fm6x5m' };

const digestOf = (text: string): string => {
let hash = 5381;
for (let i = 0; i < text.length; i++) {
hash = ((hash * 33) ^ text.charCodeAt(i)) >>> 0;
}
return hash.toString(36);
};

/** Locale-independent, so the digest does not depend on the collation Jest runs under. */
const byCodePoint = (a: string, b: string): number => (a < b ? -1 : Number(a > b));

const APPEARANCE_CANDIDATES = [

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Include every accepted enum spelling in the candidates

Fresh evidence after the accepted-value fix is that this candidate list still omits currently valid literals such as fieldFillStyle: 'transparent' and labelFontWeight: 'inherit'. If either validator is tightened to remove one of those values, ACCEPTED and every generated output remain unchanged, so the pin stays green while an existing cache for a now-invalid custom theme can still be replayed at boot under the old fingerprint; include all finite enum literals in the pinned candidates.

AGENTS.md reference: AGENTS.md:L49-L52

Useful? React with 馃憤聽/ 馃憥.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deferred to berry-13#301: the pin is a tripwire for output changes without an epoch bump, and widening its coverage of validator semantics is tracked there.

'0.5rem',
'1.25rem',
'soft',
'dim',
'fill',
'600',
'0.5',
'150ms',
'9rem',
'0 1px 2px 0 rgb(0 0 0 / 0.2)',
'ui-sans-serif, sans-serif',
'1.5',
'ring',
'border',
'none',
];

type Scope = 'both' | 'light' | 'dark';
type Mode = 'light' | 'dark';
type Overrides = {
colors?: Record<string, string>;
appearance?: Record<string, string>;
brands?: Record<string, string>;
};
type Fixture = { key: string; theme: ThemeDefinition };

const SCOPES: Scope[] = ['both', 'light', 'dark'];
const MODES: Mode[] = ['light', 'dark'];
const COLOR_SENTINEL: Record<Mode, (index: number) => string> = {
light: (index) => `${10 + index} 20 30`,
dark: (index) => `${200 - index} 210 220`,
};

/** A definition whose inactive mode block is absent, not empty. */
const define = (
name: string,
overrides: (mode: Mode) => Overrides,
scope: Scope,
wideBrands?: Record<string, string>,
): ThemeDefinition =>
({
version: THEME_VERSION,
name,
...(wideBrands && { brands: wideBrands }),
modes: Object.fromEntries(
MODES.filter((mode) => scope === 'both' || scope === mode).map((mode) => [
mode,
overrides(mode),
]),
),
}) as ThemeDefinition;

const BARE = define('bare', () => ({}), 'both');
const isValid = (theme: ThemeDefinition): boolean => validateThemeDefinition(theme).length === 0;

/** Every candidate the validator accepts for each appearance role, pinned so a narrowing or widening shows. */
const ACCEPTED: Record<string, string[]> = Object.fromEntries(
themeAppearanceTokens.map((token) => [
Comment on lines +255 to +256

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Canonicalize the accepted-role map before hashing

When appearanceValidators is reordered without changing any roles or behavior, themeAppearanceTokens follows that insertion order and this Object.fromEntries preserves it in ACCEPTED. Although the fingerprint sorts the role set and the persisted fixtures are canonicalized, JSON.stringify({ accepted: ACCEPTED, ... }) then produces a different digest and instructs the contributor to bump the epoch, unnecessarily retiring valid stored themes; sort these entries by token before hashing.

AGENTS.md reference: AGENTS.md:L49-L52

Useful? React with 馃憤聽/ 馃憥.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deferred to berry-13#301: the pin is a tripwire for output changes without an epoch bump, and widening its coverage of validator semantics is tracked there.

token,
APPEARANCE_CANDIDATES.filter((value) =>
isValid(define(token, () => ({ appearance: { [token]: value } }), 'both')),
),
Comment on lines +258 to +260

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Exercise cross-role appearance validation

Each acceptance probe defines only one appearance token, but collectSwitchIssues validates switchWidth and switchHeight jointly. A tightening that still accepts each sampled value against its default counterpart, yet rejects a formerly valid custom pair, therefore changes neither ACCEPTED nor the persisted fixtures (the bundled ClickHouse pair can also remain valid), allowing that rejected pair's old cache to flash at boot without an epoch bump; add paired validation fixtures to the pin.

AGENTS.md reference: AGENTS.md:L49-L52

Useful? React with 馃憤聽/ 馃憥.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deferred to berry-13#301: the pin is a tripwire for output changes without an epoch bump, and widening its coverage of validator semantics is tracked there.

]),
);

/** The first sample the validator accepts for an appearance role, and `none` when it takes it. */
function appearanceSamples(token: string): string[] {
const accepted = ACCEPTED[token];
if (accepted.length === 0) {
throw new Error(`Add a valid sample for the appearance role ${token} to APPEARANCE_CANDIDATES`);
}
return [...new Set([accepted[0], ...accepted.filter((value) => value === 'none')])];

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Include noncanonical zero in appearance samples

Fresh evidence beyond the shadow-none fix is that this selection retains only the first accepted value and none, so chromeBorderAlpha is exercised with 0.5 while ClickHouse supplies only the already-canonical literal 0. No pinned fixture covers valid values such as .0 or 0.00, meaning a change to canonicalAppearance() that stops converting those values to 0 would alter persisted cache output without changing this digest or requiring the cache epoch bump.

Useful? React with 馃憤聽/ 馃憥.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deferred to berry-13#299 (test-only pin hardening).

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Decouple appearance samples from validator expansion

When an appearance validator is broadened so that an earlier APPEARANCE_CANDIDATES value becomes valid, accepted[0] silently changes the fixture input and therefore the digest while themeRoleFingerprint() remains unchanged. pinStatus() then instructs the contributor to bump THEME_CACHE_EPOCH, even though every previously accepted definition still resolves identically and its cache remains valid, causing an unnecessary cache retirement and potential boot-time theme flash. Keep each role's representative input stable, or distinguish fixture-set changes from resolver-output changes.

AGENTS.md reference: AGENTS.md:L49-L52

Useful? React with 馃憤聽/ 馃憥.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 1f1944f: the pin now carries a separate digest of the generated fixture inputs, so a validator that starts accepting an earlier candidate reports a pin refresh, not an epoch bump.

}

const SAMPLES = Object.fromEntries(
themeAppearanceTokens.map((token) => [token, appearanceSamples(token)]),
);

const colorOverrides = (tokens: readonly string[], mode: Mode): Record<string, string> =>
Object.fromEntries(tokens.map((token, index) => [token, COLOR_SENTINEL[mode](index)]));

const appearanceOverrides = (tokens: readonly string[]): Record<string, string> =>
Object.fromEntries(tokens.map((token) => [token, SAMPLES[token][0]]));
Comment on lines +280 to +281

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Give inherited appearance sources distinct values

Fresh evidence after the generated-fixture rewrite is that appearanceOverrides() assigns every source its own first accepted sample, so roles sharing a validator receive identical values. For example, fontFamily and displayFontFamily are both discovered as sources for dialogTitleFontFamily, but both become ui-sans-serif, sans-serif; reversing their precedence when both are supplied would therefore leave this digest unchanged even though a valid inline theme with conflicting families would persist different styling. Generate distinct valid values for appearance sources, as colorOverrides() does.

AGENTS.md reference: AGENTS.md:L150-L151

Useful? React with 馃憤聽/ 馃憥.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deferred to berry-13#299 (test-only pin hardening).


/** Role to the roles whose single override changes it, read from the resolver. */
function derivedRoles(): { colors: Map<string, string[]>; appearance: Map<string, string[]> } {
const base = MODES.map((mode) => resolveTheme(BARE, mode));
const found = {
colors: new Map<string, Set<string>>(),
appearance: new Map<string, Set<string>>(),
};
const note = (kind: 'colors' | 'appearance', source: string, theme: ThemeDefinition) =>
MODES.forEach((mode, index) => {
const resolved = resolveTheme(theme, mode);
const before = base[index][kind] as Record<string, unknown>;
const after = resolved[kind] as Record<string, unknown>;
Object.keys(after)
.filter((key) => key !== source && after[key] !== before[key])
.forEach((key) => found[kind].set(key, (found[kind].get(key) ?? new Set()).add(source)));
Comment on lines +295 to +297

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Include jointly activated fallback sources

Fresh evidence beyond the earlier combined-source fix is that derivedRoles() can discover only a source whose single-role override changes the target. rgb-text-secondary does not alter rgb-series-8 by itself; the resolver reads it only when at least one of rgb-series-1 through rgb-series-7 is also customized, so it is omitted from the generated source combination. Changing that fallback to use the bundled secondary text would therefore leave this digest unchanged even though a valid inline theme defining both roles would persist a different series-eight color; add probes or a fixture for sources that become relevant only when another role activates the fallback.

Useful? React with 馃憤聽/ 馃憥.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deferred to berry-13#299 (test-only pin hardening).

});
themeColorTokens.forEach((token) =>
note(
'colors',
token,
define(token, (mode) => ({ colors: colorOverrides([token], mode) }), 'both'),
),
);
themeAppearanceTokens.forEach((token) =>
note(
'appearance',
token,
define(token, () => ({ appearance: appearanceOverrides([token]) }), 'both'),
),
);
const sorted = (map: Map<string, Set<string>>) =>
new Map([...map].map(([key, sources]) => [key, [...sources].sort()] as [string, string[]]));
return { colors: sorted(found.colors), appearance: sorted(found.appearance) };
}

function roleFixtures(): Fixture[] {
const colors = themeColorTokens.flatMap((token) =>
SCOPES.map((scope) => ({
key: `color:${token}:${scope}`,
theme: define(token, (mode) => ({ colors: colorOverrides([token], mode) }), scope),
})),
);
const brands = themeBrandTokens.flatMap((token) => [
{
key: `brand:${token}:wide`,
theme: define(token, () => ({}), 'both', { [token]: '#123456' }),
},
...MODES.map((mode) => ({
key: `brand:${token}:${mode}`,
theme: define(token, () => ({ brands: { [token]: '#123456' } }), mode),
})),
{
key: `brand:${token}:conflict`,
theme: define(
token,
(mode) => ({ brands: { [token]: mode === 'light' ? '#aa0000' : '#00aa00' } }),
'both',
{ [token]: '#0000aa' },
),
},
]);
const appearance = themeAppearanceTokens.flatMap((token) =>
SAMPLES[token].flatMap((value, index) =>
SCOPES.map((scope) => ({
key: `appearance:${token}:${index}:${scope}`,
theme: define(token, () => ({ appearance: { [token]: value } }), scope),
})),
),
);
return [...colors, ...brands, ...appearance];
}

/** Each derived role with all of its sources named together, then with the role named as well. */
function derivedFixtures(): Fixture[] {
const { colors, appearance } = derivedRoles();
const fixtures = (
kind: 'color' | 'appearance',
chains: Map<string, string[]>,
overrides: (tokens: string[], mode: Mode) => Overrides,
) =>
[...chains].flatMap(([target, sources]) =>
SCOPES.flatMap((scope) => [
{
key: `derived:${kind}:${target}:sources:${scope}`,
theme: define(target, (mode) => overrides(sources, mode), scope),
},
{
key: `derived:${kind}:${target}:named:${scope}`,
theme: define(target, (mode) => overrides([...sources, target], mode), scope),
},
]),
);
return [
...fixtures('color', colors, (tokens, mode) => ({ colors: colorOverrides(tokens, mode) })),
...fixtures('appearance', appearance, (tokens) => ({
appearance: appearanceOverrides(tokens),
})),
];
}

/** Mode blocks that are missing or empty, on a bare theme and on a bundled one. */
function modeFixtures(): Fixture[] {
const without = (theme: ThemeDefinition, mode: Mode): ThemeDefinition => {
const modes = { ...theme.modes };
delete modes[mode];
return { ...theme, modes };
};
return [
{ key: 'modes:none', theme: { version: THEME_VERSION, name: 'none', modes: {} } },
{ key: 'modes:empty', theme: define('empty', () => ({}), 'both') },
{ key: 'modes:clickhouse:no-light', theme: without(clickHouseTheme, 'light') },
{ key: 'modes:clickhouse:no-dark', theme: without(clickHouseTheme, 'dark') },
];
}

/** Property order is positional in the cache entry and means nothing to the page. */
const canonical = ({ properties, attributes }: ResolvedThemeStyle) => ({
properties: [...properties].sort(([a], [b]) => byCodePoint(a, b)),
attributes: Object.fromEntries(Object.entries(attributes).sort(([a], [b]) => byCodePoint(a, b))),
});

const persistedOutput = () => {
const fixtures: Fixture[] = [
{ key: 'a:librechat', theme: libreChatTheme },
{ key: 'a:clickhouse', theme: clickHouseTheme },
...roleFixtures(),
...derivedFixtures(),
...modeFixtures(),
].sort((a, b) => byCodePoint(a.key, b.key));
return fixtures.map(({ key, theme }) => {
const { light, dark } = buildThemeCache(OWNER, key, theme).modes;
return [key, canonical(light), canonical(dark)];
});
};

/** What the contributor has to do, or an empty string when the pin is current. */
function pinStatus(actual: typeof PIN): string {
const refreshed = JSON.stringify(actual);
if (actual.fingerprint !== PIN.fingerprint) {
return `The cache version changed (role set, theme version or epoch), which already retires cached entries: set PIN to ${refreshed}.`;
}
if (actual.digest !== PIN.digest) {
return `The persisted output or the accepted appearance values changed without a version change: bump THEME_CACHE_EPOCH in packages/data-provider/src/theme.ts, then set PIN to the refreshed fingerprint and digest (digest ${actual.digest}).`;
}
return '';
}

describe('resolver output pin', () => {
it('matches the persisted output of every cacheable definition and generated fixture', () => {
const status = pinStatus({
fingerprint: themeRoleFingerprint(),
digest: digestOf(JSON.stringify({ accepted: ACCEPTED, output: persistedOutput() })),
});
expect(status).toBe('');
});

it('finds the fallback chains in the resolver, not in a list', () => {
const { colors, appearance } = derivedRoles();
expect(colors.get('rgb-surface-code')).toContain('rgb-surface-primary-alt');
expect(colors.get('rgb-link-prose')).toContain('rgb-link');
expect(appearance.get('menuShadow')).toContain('shadowLg');
});

it('tells a version change from an output change', () => {
expect(pinStatus({ ...PIN, fingerprint: 'other' })).toMatch(/cache version changed/);
expect(pinStatus({ ...PIN, digest: 'other' })).toMatch(/bump THEME_CACHE_EPOCH/);
expect(pinStatus(PIN)).toBe('');
});
});
Loading