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
2 changes: 1 addition & 1 deletion client/src/Providers/__tests__/DeploymentTheme.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,7 @@ describe('DeploymentTheme', () => {
renderTheme(queryClient);

await waitFor(() => expect(warn).toHaveBeenCalledTimes(1));
expect(warn.mock.calls[0][0]).toContain('Unknown color token: rgb-not-a-token');
expect(warn.mock.calls[0][0]).toContain('Invalid RGB value for rgb-not-a-token: red');
expect(root().dataset.theme).toBe('stored');
});

Expand Down
106 changes: 106 additions & 0 deletions e2e/specs/mock/scenarios/theme-override.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { expect, test } from '@playwright/test';
import type { APIRequestContext } from '@playwright/test';
import { getPrimaryE2EUser } from '../../../setup/users.mock';
import { loginAdmin, requestResult } from '../content-filters.helpers';
import { inOneProject } from './lint.helpers';
import { withMongo } from '../db';

/**
* A theme a DB config override supplies is merged after the YAML loader ran, so the service
* applies the loader's rules itself: unknown colors are left out of what `/api/config` serves,
* and an override theme the client would reject leaves the principal with the base theme.
*/
test.describe.configure({ mode: 'serial', timeout: 120_000 });

type InterfaceBody = { interface?: { theme?: unknown; customWelcome?: string } };

/** A sibling field the override also sets, so a check never passes before the override applies. */
const MARKER = 'e2e theme override applied';

async function getPrimaryUserId(): Promise<string> {
const { email } = getPrimaryE2EUser();
const id = await withMongo(async (db) => {
const user = await db.collection('users').findOne({ email }, { projection: { _id: 1 } });
return user?._id?.toString();
});
if (!id) {
throw new Error('primary user id not found');
}
return id;
}

async function withThemeOverride(
request: APIRequestContext,
theme: unknown,
check: (token: string) => Promise<void>,
): Promise<void> {
const token = await loginAdmin(request);
const headers = { Authorization: `Bearer ${token}` };
const path = `/api/admin/config/user/${encodeURIComponent(await getPrimaryUserId())}`;
const saved = await request.put(path, {
headers,
data: { overrides: { interface: { theme, customWelcome: MARKER } } },
});
expect(saved.ok(), await saved.text()).toBe(true);
try {
await check(token);
} finally {
const removed = await request.delete(path, { headers, failOnStatusCode: false });
expect([200, 404], await removed.text()).toContain(removed.status());
}
}

async function servedInterface(
request: APIRequestContext,
token: string,
): Promise<InterfaceBody['interface']> {
const config = await requestResult(request, { path: '/api/config', token });
return (config.body as InterfaceBody | undefined)?.interface;
}

async function servedOverrideTheme(request: APIRequestContext, token: string): Promise<unknown> {
let served: InterfaceBody['interface'];
await expect
.poll(async () => {
served = await servedInterface(request, token);
return served?.customWelcome;
})
.toBe(MARKER);
return served?.theme;
}

test.describe('interface.theme from a config override', () => {
test.beforeEach(() => inOneProject());

test('an override theme with an unknown color token is served without it @scenario:override-theme-unknown-color-left-out', async ({
request,
}) => {
const theme = {
version: 1,
name: 'e2e-override',
modes: { light: { colors: { 'rgb-surface-primary': '1 2 3', 'surface-future': '4 5 6' } } },
};
await withThemeOverride(request, theme, async (token) => {
expect(await servedOverrideTheme(request, token)).toEqual({
version: 1,
name: 'e2e-override',
modes: { light: { colors: { 'rgb-surface-primary': '1 2 3' } } },
});
});
});

test('an invalid override theme leaves the user with the base theme @scenario:override-theme-invalid-keeps-base', async ({
request,
}) => {
const token = await loginAdmin(request);
const base = (await servedInterface(request, token))?.theme;
const theme = {
version: 1,
name: 'e2e-override-invalid',
modes: { dark: { colors: { 'rgb-surface-primary': '300 16 32' } } },
};
await withThemeOverride(request, theme, async (overrideToken) => {
expect(await servedOverrideTheme(request, overrideToken)).toEqual(base);
});
});
});
79 changes: 68 additions & 11 deletions e2e/specs/mock/scenarios/theme-unknown-appearance.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,36 +118,93 @@ test.describe('appearance tokens this build does not know', () => {
});

/**
* The same holds for a color role added after this build: a well-formed `rgb-` name with a valid
* triplet is dropped with a warning, so a newer definition keeps every color this build paints.
* The same holds for a color token this build does not know, whether it is a role added later
* or a misspelling: a plain token name with a valid triplet is dropped with a warning, so the
* definition keeps every color this build paints. A bad value still rejects the definition.
*/
test.describe('color tokens this build does not know', () => {
const NEWER_COLORS = {
version: 1,
name: 'e2e-newer-colors',
modes: {
light: { colors: { 'rgb-surface-primary': '240 244 255', 'rgb-future-role': '1 2 3' } },
dark: { colors: { 'rgb-surface-primary': '12 16 32', 'rgb-future-role': '1 2 3' } },
light: {
colors: {
'rgb-surface-primary': '240 244 255',
'rgb-future-role': '1 2 3',
'surface-future': '7 8 9',
},
},
dark: {
colors: {
'rgb-surface-primary': '12 16 32',
'rgb-future-role': '1 2 3',
'surface-future': '7 8 9',
},
},
},
};

test('a stored theme with an unknown color role keeps the colors this build paints @scenario:stored-theme-unknown-color-token-applies-rest', async ({
page,
}) => {
async function expectNewerColorsApplied(page: Page) {
await expect(page.locator('html')).toHaveAttribute('data-theme', NEWER_COLORS.name);
const dark = await page.evaluate(() => document.documentElement.classList.contains('dark'));
expect(await themeValue(page, '--surface-primary')).toBe(dark ? '12 16 32' : '240 244 255');
expect(await themeValue(page, '--future-role')).toBe('');
const style = (await page.locator('html').getAttribute('style')) ?? '';
expect(style).not.toContain('future');
expect(style).not.toContain('7 8 9');
}

const collectWarnings = (page: Page): string[] => {
const warnings: string[] = [];
page.on('console', (message) => {
if (message.type() === 'warning') {
warnings.push(message.text());
}
});
return warnings;
};

test('a stored theme with an unknown color token keeps the colors this build paints @scenario:stored-theme-unknown-color-token-applies-rest', async ({
page,
}) => {
const warnings = collectWarnings(page);
await storeTheme(page, NEWER_COLORS);

await openChat(page);

await expect(page.locator('html')).toHaveAttribute('data-theme', NEWER_COLORS.name);
const dark = await page.evaluate(() => document.documentElement.classList.contains('dark'));
expect(await themeValue(page, '--surface-primary')).toBe(dark ? '12 16 32' : '240 244 255');
expect(await themeValue(page, '--future-role')).toBe('');
await expectNewerColorsApplied(page);
expect(warnings.join('\n')).toContain('color token ignored: rgb-future-role');
expect(warnings.join('\n')).toContain('color token ignored: surface-future');
});

test('a deployment theme with an unknown color token keeps the colors this build paints @scenario:deployment-theme-unknown-color-token-applies-rest', async ({
page,
}) => {
const warnings = collectWarnings(page);
await serveTheme(page, NEWER_COLORS);

await openChat(page);

await expectNewerColorsApplied(page);
expect(warnings.join('\n')).toContain('color token ignored: surface-future');
});

test('a stored theme whose unknown color token has an invalid value is still rejected @scenario:stored-theme-unknown-color-invalid-value-rejected', async ({
page,
}) => {
const theme = {
version: 1,
name: 'e2e-invalid-unknown-color',
modes: {
light: { colors: { 'rgb-surface-primary': '240 244 255', 'surface-future': 'red' } },
dark: { colors: { 'rgb-surface-primary': '12 16 32', 'surface-future': 'red' } },
},
};
await storeTheme(page, theme);

await openChat(page);

await expect(page.locator('html')).not.toHaveAttribute('data-theme', theme.name);
expect(await themeValue(page, '--surface-primary')).not.toBe('240 244 255');
});
});
21 changes: 13 additions & 8 deletions e2e/specs/mock/scenarios/yaml-theme-fallback.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ 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
* loads everything else; a token this version does not know only warns. 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 });
Expand Down Expand Up @@ -56,17 +56,22 @@ test.describe('interface.theme in librechat.yaml', () => {
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', () => {
test('a color token this version does not know is ignored and the theme still loads @scenario:yaml-theme-unknown-color-kept', () => {
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(outcome.interface?.modelSelect).toBe(true);
expect(outcome.interface?.theme).toEqual({
version: 1,
name: 'acme',
modes: { light: { colors: { 'rgb-surface-primary': '240 244 255' } } },
});
expect(log).not.toContain('the default theme applies instead');
expect(log).toContain(
'interface.theme.modes.light.colors.rgb-surfce-secondary: Unknown color token: rgb-surfce-secondary',
'interface.theme.modes.light.colors.rgb-surfce-secondary: Unknown light color token ignored: rgb-surfce-secondary',
);
expect(log).toContain(
'interface.theme.modes.light.colors.surface-tertiary: Unknown color token: surface-tertiary',
'interface.theme.modes.light.colors.surface-tertiary: Unknown light color token ignored: surface-tertiary',
);
});

Expand Down Expand Up @@ -113,11 +118,11 @@ test.describe('interface.theme in librechat.yaml', () => {
});

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');
const { outcome, log } = loadFixture('bad-value', 'reload');

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

test('an invalid key outside the theme still stops startup @scenario:yaml-theme-other-errors-still-exit', () => {
Expand Down
15 changes: 15 additions & 0 deletions packages/api/src/app/__fixtures__/theme/unknown-token-invalid.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
version: 1.3.17
cache: true
interface:
modelSelect: true
theme:
version: 1
name: acme
modes:
light:
colors:
rgb-surface-primary: '240 244 255'
rgb-future-role: 'red'
dark:
colors:
'Surface Tertiary': '245 245 245'
35 changes: 27 additions & 8 deletions packages/api/src/app/loader.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,21 +55,40 @@ describe('createCustomConfigLoader interface.theme', () => {
expect(exit).not.toHaveBeenCalled();
});

it('drops a theme naming an unknown color token and keeps the rest of the config', async () => {
it('keeps a theme naming a color token this version does not know, without that token', async () => {
const config = await load('unknown-token');

expect(exit).not.toHaveBeenCalled();
expect(config).not.toBeNull();
expect(config?.interface).not.toHaveProperty('theme');
expect(config?.interface?.theme).toEqual({
version: 1,
name: 'acme',
modes: { light: { colors: { 'rgb-surface-primary': '240 244 255' } } },
});
expect(config?.interface?.modelSelect).toBe(true);
expect(config?.cache).toBe(true);
expect(warnings(warn)).toContain(
'interface.theme.modes.light.colors.rgb-surfce-secondary: Unknown color token: rgb-surfce-secondary',
'interface.theme.modes.light.colors.rgb-surfce-secondary: Unknown light color token ignored: rgb-surfce-secondary',
);
expect(warnings(warn)).toContain(
'interface.theme.modes.light.colors.surface-tertiary: Unknown color token: surface-tertiary',
'interface.theme.modes.light.colors.surface-tertiary: Unknown light color token ignored: surface-tertiary',
);
expect(warnings(warn)).not.toContain('the default theme applies instead');
});

it('still drops a theme whose unknown color token has an invalid value or name', async () => {
const config = await load('unknown-token-invalid');

expect(exit).not.toHaveBeenCalled();
expect(config?.interface).not.toHaveProperty('theme');
expect(config?.interface?.modelSelect).toBe(true);
const logged = warnings(warn);
expect(logged).toContain(
'interface.theme.modes.light.colors.rgb-future-role: Invalid RGB value for rgb-future-role: red',
);
expect(logged).toContain(
'interface.theme.modes.dark.colors.Surface Tertiary: Unknown color token: Surface Tertiary',
);
expect(warnings(warn)).toContain('the default theme applies instead');
expect(logged).toContain('the default theme applies instead');
});

it('reports every bad value with its path and drops the theme', async () => {
Expand Down Expand Up @@ -141,12 +160,12 @@ describe('createCustomConfigLoader interface.theme', () => {

describe('reload mode', () => {
it('applies the same fallback instead of rejecting the reload', async () => {
const config = await load('unknown-token', 'reload');
const config = await load('bad-value', 'reload');

expect(config?.interface).not.toHaveProperty('theme');
expect(config?.interface?.modelSelect).toBe(true);
expect(warnings(warn)).toContain(
'interface.theme.modes.light.colors.rgb-surfce-secondary: Unknown color token',
'interface.theme.modes.dark.colors.rgb-surface-primary: Invalid RGB value',
);
});

Expand Down
Loading
Loading