Skip to content
Merged
Show file tree
Hide file tree
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
8 changes: 4 additions & 4 deletions client/src/Providers/DeploymentTheme.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,24 +8,24 @@ import {
createContext,
useLayoutEffect,
} from 'react';
import { QueryKeys } from 'librechat-data-provider';
import { notifyManager, useQueryClient } from '@tanstack/react-query';
import { QueryKeys, isBundledThemeName } from 'librechat-data-provider';
import {
ThemeProvider,
clickHouseTheme,
libreChatTheme,
fromLegacyTheme,
validateThemeDefinition,
} from '@librechat/client';
import type { TInterfaceConfig, BundledThemeName } from 'librechat-data-provider';
import type { IThemeRGB, ThemeDefinition } from '@librechat/client';
import type { TInterfaceConfig } from 'librechat-data-provider';
import type { ComponentProps } from 'react';
import { getThemeFromEnv } from '~/utils/getThemeFromEnv';
import { useGetStartupConfig } from '~/data-provider';

type DeploymentThemeValue = TInterfaceConfig['theme'];

const bundledThemes: Readonly<Record<string, ThemeDefinition>> = {
const bundledThemes: Readonly<Record<BundledThemeName, ThemeDefinition>> = {
librechat: libreChatTheme,
clickhouse: clickHouseTheme,
};
Expand All @@ -41,7 +41,7 @@ export function resolveDeploymentTheme(theme: DeploymentThemeValue): ThemeDefini
}

if (typeof theme === 'string') {
const definition = Object.hasOwn(bundledThemes, theme) ? bundledThemes[theme] : undefined;
const definition = isBundledThemeName(theme) ? bundledThemes[theme] : undefined;
if (!definition) {
console.warn(`[DeploymentTheme] Ignoring unknown interface.theme "${theme}"`);
}
Expand Down
129 changes: 129 additions & 0 deletions e2e/specs/mock/scenarios/yaml-theme-fallback.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { resolve } from 'node:path';
import { expect, test } from '@playwright/test';
import { inOneProject, repoRoot, run } from './lint.helpers';

/**
* A mistake in `interface.theme` used to fail the whole librechat.yaml and stop the server. The
* config loader that `/api` wires up now drops only the theme, names each problem by its path, and
* loads everything else. The mock lane serves one shared yaml, so these load their own fixtures
* through that same loader in a child process, the way the server does at startup and on reload.
*/
test.describe.configure({ timeout: 120_000 });

const fixtures = resolve(repoRoot, 'packages/api/src/app/__fixtures__/theme');
const RESULT = '__THEME_SCENARIO_RESULT__';
/** The logger colours its console output. */
const ANSI_COLOR = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, 'g');

const loadScript = `
const load = require('./server/services/Config/loadCustomConfig');
process.exit = (code) => {
console.log('${RESULT}' + JSON.stringify({ exited: code }));
process.reallyExit(0);
};
load(false, { mode: process.argv[1] }).then(
(config) => {
console.log('${RESULT}' + JSON.stringify({ interface: config && config.interface }));
process.reallyExit(0);
},
(error) => {
console.log('${RESULT}' + JSON.stringify({ rejected: error.name }));
process.reallyExit(0);
},
);
`;

type LoadOutcome = {
interface?: { theme?: unknown; modelSelect?: boolean };
exited?: number;
rejected?: string;
};

function loadFixture(name: string, mode: 'startup' | 'reload' = 'startup') {
process.env.CONFIG_PATH = resolve(fixtures, `${name}.yaml`);
const result = run('node', ['-e', loadScript, mode], { cwd: resolve(repoRoot, 'api') });
const log = result.output.replace(ANSI_COLOR, '');
const line = log.split('\n').find((entry) => entry.startsWith(RESULT));
if (!line) {
throw new Error(`The loader printed no result:\n${log}`);
}
return { outcome: JSON.parse(line.slice(RESULT.length)) as LoadOutcome, log };
}

test.describe('interface.theme in librechat.yaml', () => {
test.beforeEach(() => inOneProject());
test.afterEach(() => {
delete process.env.CONFIG_PATH;
});

test('a typo in a theme token leaves the server running on the default theme @scenario:yaml-theme-typo-falls-back', () => {
const { outcome, log } = loadFixture('unknown-token');

expect(outcome.exited).toBeUndefined();
expect(outcome.interface).toEqual({ modelSelect: true });
expect(log).toContain('the default theme applies instead');
expect(log).toContain(
'interface.theme.modes.light.colors.rgb-surfce-secondary: Unknown color token: rgb-surfce-secondary',
);
expect(log).toContain(
'interface.theme.modes.light.colors.surface-tertiary: Unknown color token: surface-tertiary',
);
});

test('a bad token value is reported with its path and the theme is dropped @scenario:yaml-theme-bad-value-reported', () => {
const { outcome, log } = loadFixture('bad-value');

expect(outcome.exited).toBeUndefined();
expect(outcome.interface).toEqual({ modelSelect: true });
expect(log).toContain(
'interface.theme.modes.dark.colors.rgb-surface-primary: Invalid RGB value for rgb-surface-primary: 300 16 32',
);
expect(log).toContain(
'interface.theme.modes.dark.appearance.controlRadius: Invalid appearance value for controlRadius: 4 pixels',
);
});

test('a misspelled bundled theme name falls back with a warning @scenario:yaml-theme-unknown-name-falls-back', () => {
const { outcome, log } = loadFixture('unknown-name');

expect(outcome.exited).toBeUndefined();
expect(outcome.interface).toEqual({ modelSelect: true });
expect(log).toContain(
'interface.theme: Unknown bundled theme "clickhous", expected one of: librechat, clickhouse',
);
expect(loadFixture('bundled').outcome.interface?.theme).toBe('clickhouse');
});

test('a valid inline theme loads exactly as written @scenario:yaml-theme-valid-unchanged', () => {
const { outcome, log } = loadFixture('valid');

expect(outcome.interface?.theme).toEqual({
version: 1,
name: 'acme',
modes: {
light: {
colors: { 'rgb-surface-primary': '240 244 255' },
appearance: { controlRadius: '0.25rem' },
},
dark: { colors: { 'rgb-surface-primary': '12 16 32' } },
},
brands: { 'provider-openai': '#10a37f' },
});
expect(log).not.toContain('interface.theme');
});

test('a config reload with a broken theme applies the same fallback @scenario:yaml-theme-reload-falls-back', () => {
const { outcome, log } = loadFixture('unknown-token', 'reload');

expect(outcome.rejected).toBeUndefined();
expect(outcome.interface).toEqual({ modelSelect: true });
expect(log).toContain('interface.theme.modes.light.colors.rgb-surfce-secondary');
});

test('an invalid key outside the theme still stops startup @scenario:yaml-theme-other-errors-still-exit', () => {
const { outcome } = loadFixture('unrelated-error');

expect(outcome.exited).toBe(1);
expect(loadFixture('unrelated-error', 'reload').outcome.rejected).toBe('ConfigReloadError');
});
});
13 changes: 13 additions & 0 deletions packages/api/src/app/__fixtures__/theme/bad-value.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
version: 1.3.17
cache: true
interface:
modelSelect: true
theme:
version: 1
name: acme
modes:
dark:
colors:
rgb-surface-primary: '300 16 32'
appearance:
controlRadius: 4 pixels
5 changes: 5 additions & 0 deletions packages/api/src/app/__fixtures__/theme/bundled.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
version: 1.3.17
cache: true
interface:
modelSelect: true
theme: clickhouse
7 changes: 7 additions & 0 deletions packages/api/src/app/__fixtures__/theme/malformed.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
version: 1.3.17
cache: true
interface:
modelSelect: true
theme:
- acme
- clickhouse
11 changes: 11 additions & 0 deletions packages/api/src/app/__fixtures__/theme/spacing.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
version: 1.3.17
cache: true
interface:
modelSelect: true
theme:
version: 1
name: acme
modes:
light:
colors:
rgb-surface-primary: '240 244 255'
12 changes: 12 additions & 0 deletions packages/api/src/app/__fixtures__/theme/unknown-appearance.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
version: 1.3.17
cache: true
interface:
modelSelect: true
theme:
version: 1
name: acme
modes:
light:
appearance:
controlRadius: 2px
futureSpacing: 3rem
5 changes: 5 additions & 0 deletions packages/api/src/app/__fixtures__/theme/unknown-name.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
version: 1.3.17
cache: true
interface:
modelSelect: true
theme: clickhous
13 changes: 13 additions & 0 deletions packages/api/src/app/__fixtures__/theme/unknown-token.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
version: 1.3.17
cache: true
interface:
modelSelect: true
theme:
version: 1
name: acme
modes:
light:
colors:
rgb-surface-primary: '240 244 255'
rgb-surfce-secondary: '250 250 250'
surface-tertiary: '245 245 245'
12 changes: 12 additions & 0 deletions packages/api/src/app/__fixtures__/theme/unrelated-error.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
version: 1.3.17
cache: true
interface:
modelSelect: true
theme:
version: 1
name: acme
modes:
light:
colors:
rgb-surfce-primary: '240 244 255'
notAConfigKey: true
18 changes: 18 additions & 0 deletions packages/api/src/app/__fixtures__/theme/valid.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
version: 1.3.17
cache: true
interface:
modelSelect: true
theme:
version: 1
name: acme
modes:
light:
colors:
rgb-surface-primary: '240 244 255'
appearance:
controlRadius: 0.25rem
dark:
colors:
rgb-surface-primary: '12 16 32'
brands:
provider-openai: '#10a37f'
Loading
Loading