Repository navigation
馃К test: Pin Built-In Theme Output to the Cache Epoch #16875
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We鈥檒l occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
c3f01ad
6d22e28
0695853
9626fc4
8dfec10
254bffe
a068262
a33f1f9
1f1944f
cedd75a
c972bfb
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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, | ||
|
|
@@ -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 = [ | ||
| '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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When AGENTS.md reference: AGENTS.md:L49-L52 Useful? React with 馃憤聽/ 馃憥.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Each acceptance probe defines only one appearance token, but AGENTS.md reference: AGENTS.md:L49-L52 Useful? React with 馃憤聽/ 馃憥.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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')])]; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Fresh evidence beyond the shadow- Useful? React with 馃憤聽/ 馃憥.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Deferred to berry-13#299 (test-only pin hardening). There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When an appearance validator is broadened so that an earlier AGENTS.md reference: AGENTS.md:L49-L52 Useful? React with 馃憤聽/ 馃憥.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Fresh evidence after the generated-fixture rewrite is that AGENTS.md reference: AGENTS.md:L150-L151 Useful? React with 馃憤聽/ 馃憥.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Fresh evidence beyond the earlier combined-source fix is that Useful? React with 馃憤聽/ 馃憥.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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(''); | ||
| }); | ||
| }); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fresh evidence after the accepted-value fix is that this candidate list still omits currently valid literals such as
fieldFillStyle: 'transparent'andlabelFontWeight: 'inherit'. If either validator is tightened to remove one of those values,ACCEPTEDand 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 馃憤聽/ 馃憥.
There was a problem hiding this comment.
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.