mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 09:39:25 +00:00
fix: keep custom themes stable during reloads (#153001)
* fix: keep custom themes stable during reloads * fix(config): complete value-tree import migration
This commit is contained in:
parent
68df653df9
commit
2ba076dc63
9 changed files with 776 additions and 101 deletions
|
|
@ -76,6 +76,14 @@ and may include `font-sans` and `font-mono`. Use CSS color values such as hex,
|
|||
`rgb()`, `hsl()`, or `oklch()`. Font families describe locally available fonts;
|
||||
definitions cannot load external stylesheets or resources.
|
||||
|
||||
Use consistent CSS separators: `rgb(20 30 40 / 50%)` or
|
||||
`rgba(20, 30, 40, 0.5)`. Modern functions such as `oklch()` use spaces between
|
||||
components and `/` before opacity. Font lists use comma-separated family names;
|
||||
quote names containing punctuation or beginning with a digit, such as
|
||||
`"123 Font", monospace`. Also quote names containing CSS keywords, such as
|
||||
`"Foo serif"`. Malformed colors and unbalanced font quotes are rejected before
|
||||
the theme is saved.
|
||||
|
||||
This example creates and activates a dark theme in one call:
|
||||
|
||||
```json
|
||||
|
|
|
|||
|
|
@ -34,10 +34,33 @@ describe("portable theme definition", () => {
|
|||
{ background: "#000;display:none" },
|
||||
{ background: "var(--other-theme)" },
|
||||
{ background: "rgb()" },
|
||||
{ background: "rgb(1 2 3 .5)" },
|
||||
{ background: "rgb(1, 2 3)" },
|
||||
{ background: "rgb(1 2, 3)" },
|
||||
{ background: "rgb(1, 2, 3 / .5)" },
|
||||
{ background: "rgb(1%, 2, 3%)" },
|
||||
{ background: "rgb(1. 2 3)" },
|
||||
{ background: "rgb(1\u00a02\u00a03)" },
|
||||
{ background: "hsl(180, 40, 50)" },
|
||||
{ background: "hsl(180 40% 50% .5)" },
|
||||
{ background: "oklch(50%, 0.2, 180)" },
|
||||
{ background: "lab(50%, 20, 10)" },
|
||||
{ background: "color(srgb\u00a00 0 0)" },
|
||||
{ background: "red/* hidden */" },
|
||||
{ "font-sans": "monospace; background: url(https://example.invalid)" },
|
||||
{ "font-sans": "var(--font-body)" },
|
||||
])("rejects executable or dependent CSS values %j", (palette) => {
|
||||
{ "font-sans": "'unterminated" },
|
||||
{ "font-sans": "Roboto,,monospace" },
|
||||
{ "font-sans": "123Font" },
|
||||
{ "font-sans": "Foo.Bar" },
|
||||
{ "font-sans": "-1font" },
|
||||
{ "font-sans": "serif Foo" },
|
||||
{ "font-sans": "Foo serif" },
|
||||
{ "font-sans": "Foo inherit" },
|
||||
{ "font-sans": "default Foo" },
|
||||
{ "font-sans": "default" },
|
||||
{ "font-sans": "-webkit-body Foo" },
|
||||
])("rejects unsafe or malformed CSS values %j", (palette) => {
|
||||
expect(
|
||||
parseThemeDefinition(
|
||||
createThemeDefinitionFixture({ dark: createThemePaletteFixture(palette) }),
|
||||
|
|
@ -45,6 +68,43 @@ describe("portable theme definition", () => {
|
|||
).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
"rgb(1 2 3)",
|
||||
"rgb(1e2 2 3)",
|
||||
"rgb(1% 2 3% / 50%)",
|
||||
"rgba(1, 2, 3, .5)",
|
||||
"rgb(1%, 2%, 3%, 50%)",
|
||||
"hsl(180 40 50 / .5)",
|
||||
"hsla(0.5turn, 40%, 50%, .5)",
|
||||
"lab(50% -20 10 / .5)",
|
||||
"lch(50% 20 180deg)",
|
||||
"oklab(50% -.2 .1 / 50%)",
|
||||
"oklch(50% 0.2 180)",
|
||||
"color(display-p3 .1 .2 .3 / .5)",
|
||||
])("preserves supported color syntax: %s", (background) => {
|
||||
expect(
|
||||
normalizeThemeDefinition(
|
||||
createThemeDefinitionFixture({ dark: createThemePaletteFixture({ background }) }),
|
||||
).dark?.background,
|
||||
).toBe(background);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"JetBrains Mono, monospace",
|
||||
"'A,B', monospace",
|
||||
"\"A'B\", 'C\"D'",
|
||||
'"123 Font", monospace',
|
||||
"'serif Foo', 'Foo serif', 'Foo inherit', 'default Foo', 'default', 'inherit'",
|
||||
"--font, Foo_Bar",
|
||||
'""',
|
||||
])("preserves font family names: %s", (font) => {
|
||||
expect(
|
||||
normalizeThemeDefinition(
|
||||
createThemeDefinitionFixture({ dark: createThemePaletteFixture({ "font-sans": font }) }),
|
||||
).dark?.["font-sans"],
|
||||
).toBe(font);
|
||||
});
|
||||
|
||||
it("rejects missing modes, incomplete palettes, unknown properties, and oversized stored values", () => {
|
||||
const { background: _background, ...incomplete } = createThemePaletteFixture();
|
||||
expect(() => normalizeThemeDefinition({ name: "Empty", description: "No colors" })).toThrow(
|
||||
|
|
|
|||
|
|
@ -163,24 +163,68 @@ function requireText(value: unknown, label: string, maxLength: number): string {
|
|||
return value.trim();
|
||||
}
|
||||
|
||||
const NUMBER = "[+-]?(?:\\d+(?:\\.\\d*)?|\\.\\d+)";
|
||||
const NUMBER = "[+-]?(?:\\d+(?:\\.\\d+)?|\\.\\d+)(?:e[+-]?\\d+)?";
|
||||
const COMPONENT = `${NUMBER}%?`;
|
||||
const HUE = `${NUMBER}(?:deg|grad|rad|turn)?`;
|
||||
const COMPONENT_SEPARATOR = "(?:\\s*,\\s*|\\s+)";
|
||||
const LEGACY_COLOR_FUNCTION = new RegExp(
|
||||
`^(?:(?:rgb|rgba)\\( *(?:${NUMBER} *, *${NUMBER} *, *${NUMBER}|${NUMBER}% *, *${NUMBER}% *, *${NUMBER}%)|(?:hsl|hsla)\\( *${HUE} *, *${NUMBER}% *, *${NUMBER}%)(?: *, *${COMPONENT})? *\\)$`,
|
||||
"i",
|
||||
);
|
||||
const COLOR_FUNCTION = new RegExp(
|
||||
`^(?:(?:rgb|rgba|oklab|lab)\\(\\s*${COMPONENT}${COMPONENT_SEPARATOR}${COMPONENT}${COMPONENT_SEPARATOR}${COMPONENT}|(?:hsl|hsla)\\(\\s*${HUE}${COMPONENT_SEPARATOR}${COMPONENT}${COMPONENT_SEPARATOR}${COMPONENT}|(?:oklch|lch)\\(\\s*${COMPONENT}${COMPONENT_SEPARATOR}${COMPONENT}${COMPONENT_SEPARATOR}${HUE})(?:(?:\\s*[,/]\\s*|\\s+)${COMPONENT})?\\s*\\)$`,
|
||||
`^(?:(?:rgb|rgba|oklab|lab)\\( *${COMPONENT} +${COMPONENT} +${COMPONENT}|(?:hsl|hsla)\\( *${HUE} +${COMPONENT} +${COMPONENT}|(?:oklch|lch)\\( *${COMPONENT} +${COMPONENT} +${HUE})(?: */ *${COMPONENT})? *\\)$`,
|
||||
"i",
|
||||
);
|
||||
const COLOR_SPACE_FUNCTION = new RegExp(
|
||||
`^color\\(\\s*(?:srgb|srgb-linear|display-p3|a98-rgb|prophoto-rgb|rec2020|xyz|xyz-d50|xyz-d65)\\s+${COMPONENT}\\s+${COMPONENT}\\s+${COMPONENT}(?:\\s*/\\s*${COMPONENT})?\\s*\\)$`,
|
||||
`^color\\( *(?:srgb|srgb-linear|display-p3|a98-rgb|prophoto-rgb|rec2020|xyz|xyz-d50|xyz-d65) +${COMPONENT} +${COMPONENT} +${COMPONENT}(?: */ *${COMPONENT})? *\\)$`,
|
||||
"i",
|
||||
);
|
||||
const FONT_IDENTIFIER = "(?:--[a-z0-9_-]*|-?[a-z_][a-z0-9_-]*)";
|
||||
const FONT_FAMILY = `(?:"[a-z0-9 ,'._-]*"|'[a-z0-9 ,"._-]*'|${FONT_IDENTIFIER}(?: +${FONT_IDENTIFIER})*)`;
|
||||
const FONT_FAMILY_LIST = new RegExp(`^${FONT_FAMILY}(?: *, *${FONT_FAMILY})*$`, "i");
|
||||
const CSS_WIDE_KEYWORDS = new Set(["inherit", "initial", "unset", "revert", "revert-layer"]);
|
||||
const GENERIC_FONT_FAMILIES = new Set([
|
||||
"serif",
|
||||
"sans-serif",
|
||||
"monospace",
|
||||
"cursive",
|
||||
"fantasy",
|
||||
"system-ui",
|
||||
"ui-serif",
|
||||
"ui-sans-serif",
|
||||
"ui-monospace",
|
||||
"ui-rounded",
|
||||
"emoji",
|
||||
"math",
|
||||
"fangsong",
|
||||
"-webkit-body",
|
||||
]);
|
||||
|
||||
function isFontFamilyList(value: string): boolean {
|
||||
if (!FONT_FAMILY_LIST.test(value)) {
|
||||
return false;
|
||||
}
|
||||
// Quoted names may contain commas or match reserved keywords.
|
||||
return value
|
||||
.replace(/"[^"]*"|'[^']*'/g, "")
|
||||
.split(",")
|
||||
.every((family) => {
|
||||
const normalized = family.trim().toLowerCase();
|
||||
const words = normalized.split(/ +/);
|
||||
return words.every(
|
||||
(word) =>
|
||||
!CSS_WIDE_KEYWORDS.has(word) &&
|
||||
word !== "default" &&
|
||||
(words.length === 1 || !GENERIC_FONT_FAMILIES.has(word)),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function requireColor(value: unknown, label: string): string {
|
||||
const color = requireText(value, label, THEME_TOKEN_MAX_LENGTH);
|
||||
if (
|
||||
!/^#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i.test(color) &&
|
||||
!/^(?:transparent|black|white)$/i.test(color) &&
|
||||
!LEGACY_COLOR_FUNCTION.test(color) &&
|
||||
!COLOR_FUNCTION.test(color) &&
|
||||
!COLOR_SPACE_FUNCTION.test(color)
|
||||
) {
|
||||
|
|
@ -204,7 +248,7 @@ function normalizePalette(value: unknown, mode: ThemeColorMode): ThemePalette {
|
|||
continue;
|
||||
}
|
||||
const font = requireText(palette[key], `theme.${mode}.${key}`, THEME_TOKEN_MAX_LENGTH);
|
||||
if (!/^[a-z0-9 ,'"._-]+$/i.test(font)) {
|
||||
if (!isFontFamilyList(font)) {
|
||||
throw new Error(`theme.${mode}.${key} must contain only font family names`);
|
||||
}
|
||||
result[key] = font;
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|||
import { GATEWAY_CLIENT_IDS } from "../../../packages/gateway-protocol/src/client-info.js";
|
||||
import type { ErrorShape } from "../../../packages/gateway-protocol/src/index.js";
|
||||
import type { ThemeCatalogEntry } from "../../../packages/gateway-protocol/src/theme.js";
|
||||
import { createDeferred } from "../../../test/helpers/promise.js";
|
||||
import {
|
||||
createThemeDefinitionFixture,
|
||||
createThemePaletteFixture,
|
||||
|
|
@ -13,7 +14,7 @@ import { closeOpenClawStateDatabaseAsync } from "../../state/openclaw-state-db-c
|
|||
import { openOpenClawStateDatabase } from "../../state/openclaw-state-db.js";
|
||||
import * as userPreferences from "../../state/user-preferences.js";
|
||||
import { getUserPreferences, setUserPreferences } from "../../state/user-preferences.js";
|
||||
import { ensureProfileForEmail } from "../../state/user-profiles.js";
|
||||
import { ensureProfileForEmail, linkEmail } from "../../state/user-profiles.js";
|
||||
import {
|
||||
createOpenClawTestState,
|
||||
type OpenClawTestState,
|
||||
|
|
@ -146,6 +147,31 @@ function changePreferencesAfterSnapshot(entries: Record<string, unknown>) {
|
|||
);
|
||||
}
|
||||
|
||||
async function withConcurrentPreferenceSnapshots<T>(count: number, run: () => Promise<T>) {
|
||||
const readPreferences = userPreferences.getCanonicalUserPreferences;
|
||||
const release = createDeferred();
|
||||
let remaining = count;
|
||||
const read = vi
|
||||
.spyOn(userPreferences, "getCanonicalUserPreferences")
|
||||
.mockImplementation(async (...args) => {
|
||||
const snapshot = await readPreferences(...args);
|
||||
if (remaining > 0) {
|
||||
remaining -= 1;
|
||||
if (remaining === 0) {
|
||||
release.resolve();
|
||||
}
|
||||
await release.promise;
|
||||
}
|
||||
return snapshot;
|
||||
});
|
||||
try {
|
||||
return await run();
|
||||
} finally {
|
||||
release.resolve();
|
||||
read.mockRestore();
|
||||
}
|
||||
}
|
||||
|
||||
describe("theme RPC", () => {
|
||||
it("lists descriptive choices and inspects a plugin definition without exposing palettes in list entries", async () => {
|
||||
pluginThemes.push(pluginTheme());
|
||||
|
|
@ -275,15 +301,23 @@ describe("theme RPC", () => {
|
|||
|
||||
it.each([
|
||||
{ background: "url(https://example.test/collect)" },
|
||||
{ background: "rgb(1 2 3 .5)" },
|
||||
{ background: "oklch(50%, 0.2, 180)" },
|
||||
{ "font-sans": "sans-serif; background: red" },
|
||||
])("rejects unsafe theme values without importing or selecting: %j", async (palette) => {
|
||||
const definition = createThemeDefinitionFixture();
|
||||
definition.dark = { ...definition.dark!, ...palette };
|
||||
expect(await invoke("themes.import", { id: "unsafe", definition, apply: true })).toMatchObject({
|
||||
ok: false,
|
||||
});
|
||||
expect(getUserPreferences(requesterProfileId)).toEqual({});
|
||||
});
|
||||
{ "font-sans": "'unterminated" },
|
||||
])(
|
||||
"rejects unsafe or malformed theme values without importing or selecting: %j",
|
||||
async (palette) => {
|
||||
const definition = createThemeDefinitionFixture();
|
||||
definition.dark = { ...definition.dark!, ...palette };
|
||||
expect(
|
||||
await invoke("themes.import", { id: "unsafe", definition, apply: true }),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
});
|
||||
expect(getUserPreferences(requesterProfileId)).toEqual({});
|
||||
},
|
||||
);
|
||||
|
||||
it("clears theme overrides against Gateway defaults while retaining saved custom themes", async () => {
|
||||
const definition = createThemeDefinitionFixture();
|
||||
|
|
@ -466,6 +500,212 @@ describe("theme RPC", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("preserves independent concurrent imports and atomically settles competing selections", async () => {
|
||||
const expected: Record<string, unknown> = { "chat.showThinking": true };
|
||||
expect(setUserPreferences(requesterProfileId, expected).ok).toBe(true);
|
||||
for (let round = 0; round < 4; round += 1) {
|
||||
Object.assign(expected, { "ui.theme": "claw", "ui.themeMode": "system" });
|
||||
expect(
|
||||
setUserPreferences(requesterProfileId, { "ui.theme": "claw", "ui.themeMode": "system" }).ok,
|
||||
).toBe(true);
|
||||
const ids = Array.from({ length: 4 }, (_, index) => `independent-${round}-${index}`);
|
||||
const definitions = ids.map((name) => createThemeDefinitionFixture({ name }));
|
||||
const imports = await withConcurrentPreferenceSnapshots(ids.length, () =>
|
||||
Promise.all(
|
||||
ids.map((id, index) => invoke("themes.import", { id, definition: definitions[index] })),
|
||||
),
|
||||
);
|
||||
for (const [index, result] of imports.entries()) {
|
||||
expect(result).toMatchObject({ ok: true, payload: { application: "saved" } });
|
||||
expected[`ui.themeDefinition.${ids[index]}`] = definitions[index];
|
||||
}
|
||||
expect(getUserPreferences(requesterProfileId)).toEqual(expected);
|
||||
|
||||
const choices = [
|
||||
...[0, 1].map((index) => ({
|
||||
method: "themes.import" as const,
|
||||
params: {
|
||||
id: `applied-${round}-${index}`,
|
||||
definition: createThemeDefinitionFixture({ name: `Applied ${round}-${index}` }),
|
||||
apply: true,
|
||||
mode: "dark",
|
||||
},
|
||||
selectedId: `user/applied-${round}-${index}`,
|
||||
})),
|
||||
...["rose", "tide"].map((id) => ({
|
||||
method: "themes.set" as const,
|
||||
params: { id, mode: "dark" },
|
||||
selectedId: id,
|
||||
})),
|
||||
];
|
||||
const contenders = [...choices.slice(round), ...choices.slice(0, round)];
|
||||
const selections = await withConcurrentPreferenceSnapshots(contenders.length, () =>
|
||||
Promise.all(contenders.map(({ method, params }) => invoke(method, params))),
|
||||
);
|
||||
const winners = selections.flatMap((result, index) => (result.ok ? [index] : []));
|
||||
expect(winners).toHaveLength(1);
|
||||
const winnerIndex = expectDefined(winners[0], "winning theme mutation index");
|
||||
const winner = expectDefined(contenders[winnerIndex], "winning theme mutation");
|
||||
expected["ui.theme"] = winner.selectedId;
|
||||
expected["ui.themeMode"] = "dark";
|
||||
if (winner.method === "themes.import") {
|
||||
expected[`ui.themeDefinition.${winner.params.id}`] = winner.params.definition;
|
||||
}
|
||||
for (const [index, result] of selections.entries()) {
|
||||
expect(result).toMatchObject(
|
||||
index === winnerIndex
|
||||
? {
|
||||
ok: true,
|
||||
payload: { current: { id: winner.selectedId, mode: "dark" }, application: "saved" },
|
||||
}
|
||||
: { ok: false, error: { message: expect.stringContaining("Appearance changed") } },
|
||||
);
|
||||
}
|
||||
expect(getUserPreferences(requesterProfileId)).toEqual(expected);
|
||||
expect(getUserPreferences(otherProfileId)).toEqual({});
|
||||
expect(await invoke("themes.list")).toMatchObject({
|
||||
ok: true,
|
||||
payload: {
|
||||
current: { id: winner.selectedId, mode: "dark" },
|
||||
themes: expect.arrayContaining(
|
||||
ids.map((id) => expect.objectContaining({ id: `user/${id}` })),
|
||||
),
|
||||
},
|
||||
});
|
||||
expect(await invoke("themes.get")).toMatchObject({
|
||||
ok: true,
|
||||
payload: {
|
||||
current: { id: winner.selectedId, mode: "dark" },
|
||||
theme: { id: winner.selectedId },
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("reads and writes through a merged profile alias and notifies both identities only", async () => {
|
||||
const unrelatedId = ensureProfileForEmail("unrelated@example.test").id;
|
||||
linkEmail("requester@example.test", otherProfileId);
|
||||
const clients = [
|
||||
client(requesterProfileId),
|
||||
{ ...client(otherProfileId), connId: "canonical-browser" },
|
||||
{ ...client(unrelatedId), connId: "unrelated-browser" },
|
||||
];
|
||||
const broadcastToConnIds = vi.fn();
|
||||
const definition = createThemeDefinitionFixture();
|
||||
expect(
|
||||
await invoke(
|
||||
"themes.import",
|
||||
{ id: "merged", definition, apply: true, mode: "dark" },
|
||||
{
|
||||
context: {
|
||||
broadcastToConnIds,
|
||||
getClientConnIds: (filter) =>
|
||||
new Set(
|
||||
clients.filter((entry) => !filter || filter(entry)).map((entry) => entry.connId!),
|
||||
),
|
||||
},
|
||||
},
|
||||
),
|
||||
).toMatchObject({
|
||||
ok: true,
|
||||
payload: { current: { id: "user/merged" }, application: "saved" },
|
||||
});
|
||||
expect(getUserPreferences(requesterProfileId)).toEqual({});
|
||||
expect(getUserPreferences(otherProfileId)).toEqual({
|
||||
"ui.themeDefinition.merged": definition,
|
||||
"ui.theme": "user/merged",
|
||||
"ui.themeMode": "dark",
|
||||
});
|
||||
expect(getUserPreferences(unrelatedId)).toEqual({});
|
||||
for (const profileId of [requesterProfileId, otherProfileId]) {
|
||||
expect(await invoke("themes.get", {}, { client: client(profileId) })).toMatchObject({
|
||||
ok: true,
|
||||
payload: { current: { id: "user/merged", mode: "dark" }, definition },
|
||||
});
|
||||
}
|
||||
expect(broadcastToConnIds).toHaveBeenCalledExactlyOnceWith(
|
||||
"users.prefs.changed",
|
||||
{
|
||||
profileId: otherProfileId,
|
||||
keys: ["ui.themeDefinition.merged", "ui.theme", "ui.themeMode"],
|
||||
},
|
||||
new Set(["requester-browser", "canonical-browser"]),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not redirect a prepared import when its profile merges before the write", async () => {
|
||||
expect(setUserPreferences(requesterProfileId, { "ui.theme": "claw" }).ok).toBe(true);
|
||||
const targetPreferences = { "ui.theme": "tide", "ui.themeMode": "light" };
|
||||
expect(setUserPreferences(otherProfileId, targetPreferences).ok).toBe(true);
|
||||
const readPreferences = userPreferences.getCanonicalUserPreferences;
|
||||
vi.spyOn(userPreferences, "getCanonicalUserPreferences").mockImplementationOnce(
|
||||
async (...args) => {
|
||||
const snapshot = await readPreferences(...args);
|
||||
linkEmail("requester@example.test", otherProfileId);
|
||||
return snapshot;
|
||||
},
|
||||
);
|
||||
const broadcastToConnIds = vi.fn();
|
||||
expect(
|
||||
await invoke(
|
||||
"themes.import",
|
||||
{ id: "stale", definition: createThemeDefinitionFixture(), apply: true },
|
||||
{
|
||||
context: { broadcastToConnIds, getClientConnIds: () => new Set(["requester-browser"]) },
|
||||
},
|
||||
),
|
||||
).toMatchObject({ ok: false, error: { message: expect.stringContaining("profile changed") } });
|
||||
expect(getUserPreferences(requesterProfileId)).toEqual({});
|
||||
expect(getUserPreferences(otherProfileId)).toEqual(targetPreferences);
|
||||
expect(broadcastToConnIds).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rolls back an import at the profile key limit while allowing replacement at capacity", async () => {
|
||||
const expected: Record<string, unknown> = {
|
||||
...Object.fromEntries(
|
||||
Array.from({ length: 125 }, (_, index) => [`retained-${index}`, index]),
|
||||
),
|
||||
"ui.theme": "claw",
|
||||
"ui.themeMode": "dark",
|
||||
};
|
||||
const entries = Object.entries(expected);
|
||||
for (let offset = 0; offset < entries.length; offset += 32) {
|
||||
expect(
|
||||
setUserPreferences(
|
||||
requesterProfileId,
|
||||
Object.fromEntries(entries.slice(offset, offset + 32)),
|
||||
).ok,
|
||||
).toBe(true);
|
||||
}
|
||||
const broadcastToConnIds = vi.fn();
|
||||
const options = {
|
||||
context: { broadcastToConnIds, getClientConnIds: () => new Set(["requester-browser"]) },
|
||||
};
|
||||
const definition = createThemeDefinitionFixture();
|
||||
expect(
|
||||
await invoke("themes.import", { id: "last", definition, apply: true, mode: "dark" }, options),
|
||||
).toMatchObject({ ok: true });
|
||||
Object.assign(expected, { "ui.themeDefinition.last": definition, "ui.theme": "user/last" });
|
||||
expect(Object.keys(getUserPreferences(requesterProfileId))).toHaveLength(128);
|
||||
expect(getUserPreferences(requesterProfileId)).toEqual(expected);
|
||||
expect(
|
||||
await invoke("themes.import", { id: "overflow", definition, apply: true }, options),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: { message: expect.stringContaining("profile-key-limit") },
|
||||
});
|
||||
expect(getUserPreferences(requesterProfileId)).toEqual(expected);
|
||||
expect(broadcastToConnIds).toHaveBeenCalledTimes(1);
|
||||
const replacement = createThemeDefinitionFixture({ name: "Updated at capacity" });
|
||||
expect(
|
||||
await invoke("themes.import", { id: "last", definition: replacement, apply: true }, options),
|
||||
).toMatchObject({ ok: true });
|
||||
expected["ui.themeDefinition.last"] = replacement;
|
||||
expect(getUserPreferences(requesterProfileId)).toEqual(expected);
|
||||
expect(getUserPreferences(otherProfileId)).toEqual({});
|
||||
expect(broadcastToConnIds).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("retains a missing plugin selection and resumes it when the catalog is republished", async () => {
|
||||
pluginThemes.push(pluginTheme());
|
||||
expect(await invoke("themes.set", { id: "space-pack/xenovessel", mode: "dark" })).toMatchObject(
|
||||
|
|
@ -562,39 +802,44 @@ describe("theme RPC", () => {
|
|||
expect(getUserPreferences(otherProfileId)).toEqual({});
|
||||
});
|
||||
|
||||
it.each(["select", "mode-only"])(
|
||||
"refuses a plugin palette replaced before commit during %s",
|
||||
async (action) => {
|
||||
const installed = pluginTheme();
|
||||
pluginThemes.push(installed);
|
||||
const original = {
|
||||
"ui.theme": action === "select" ? "claw" : installed.id,
|
||||
"ui.themeMode": "system",
|
||||
"ui.accent": "#aabbcc",
|
||||
it.each(
|
||||
["select", "mode-only"].flatMap((action) =>
|
||||
["replaced", "unavailable"].map((change) => ({ action, change })),
|
||||
),
|
||||
)("refuses a plugin palette $change before commit during $action", async ({ action, change }) => {
|
||||
const installed = pluginTheme();
|
||||
pluginThemes.push(installed);
|
||||
const original = {
|
||||
"ui.theme": action === "select" ? "claw" : installed.id,
|
||||
"ui.themeMode": "system",
|
||||
"ui.accent": "#aabbcc",
|
||||
};
|
||||
expect(setUserPreferences(requesterProfileId, original).ok).toBe(true);
|
||||
let reachedCommit = false;
|
||||
beforeWorkerCommit(() => {
|
||||
reachedCommit = true;
|
||||
if (change === "unavailable") {
|
||||
pluginThemes.length = 0;
|
||||
return;
|
||||
}
|
||||
pluginThemes[0] = {
|
||||
...installed,
|
||||
modes: ["light"],
|
||||
definition: {
|
||||
name: installed.name,
|
||||
description: "Replacement light-only palette",
|
||||
light: createThemePaletteFixture(),
|
||||
},
|
||||
};
|
||||
expect(setUserPreferences(requesterProfileId, original).ok).toBe(true);
|
||||
let reachedCommit = false;
|
||||
beforeWorkerCommit(() => {
|
||||
reachedCommit = true;
|
||||
pluginThemes[0] = {
|
||||
...installed,
|
||||
modes: ["light"],
|
||||
definition: {
|
||||
name: installed.name,
|
||||
description: "Replacement light-only palette",
|
||||
light: createThemePaletteFixture(),
|
||||
},
|
||||
};
|
||||
});
|
||||
const params = action === "select" ? { id: installed.id, mode: "dark" } : { mode: "dark" };
|
||||
expect(await invoke("themes.set", params)).toMatchObject({
|
||||
ok: false,
|
||||
error: { message: expect.stringContaining("theme plugin changed") },
|
||||
});
|
||||
expect(reachedCommit).toBe(true);
|
||||
expect(getUserPreferences(requesterProfileId)).toEqual(original);
|
||||
},
|
||||
);
|
||||
});
|
||||
const params = action === "select" ? { id: installed.id, mode: "dark" } : { mode: "dark" };
|
||||
expect(await invoke("themes.set", params)).toMatchObject({
|
||||
ok: false,
|
||||
error: { message: expect.stringContaining("theme plugin changed") },
|
||||
});
|
||||
expect(reachedCommit).toBe(true);
|
||||
expect(getUserPreferences(requesterProfileId)).toEqual(original);
|
||||
});
|
||||
|
||||
it("rolls back both import and selection when the live run retires at the commit boundary", async () => {
|
||||
const identity = runtimeIdentity(requesterProfileId);
|
||||
|
|
|
|||
|
|
@ -189,7 +189,7 @@ export function createApplicationTheme(
|
|||
catalogRequested = true;
|
||||
void loadCatalog();
|
||||
}
|
||||
return catalog?.snapshot ?? catalogLoadError;
|
||||
return catalog?.snapshot(settings.theme) ?? catalogLoadError;
|
||||
},
|
||||
get settings() {
|
||||
return settings;
|
||||
|
|
|
|||
|
|
@ -2,7 +2,10 @@
|
|||
|
||||
import { expectDefined } from "@openclaw/normalization-core";
|
||||
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
||||
import type { ThemesListResult } from "../../../packages/gateway-protocol/src/schema/themes.ts";
|
||||
import type {
|
||||
ThemesGetResult,
|
||||
ThemesListResult,
|
||||
} from "../../../packages/gateway-protocol/src/schema/themes.ts";
|
||||
import {
|
||||
BUILTIN_THEMES,
|
||||
type ThemeDescriptor,
|
||||
|
|
@ -214,6 +217,296 @@ it("retries a failed selected palette only after an explicit catalog retry", asy
|
|||
}
|
||||
});
|
||||
|
||||
it("does not report a previous palette failure after a local theme switch", async () => {
|
||||
const replacement: ThemeDescriptor = {
|
||||
...descriptor,
|
||||
id: "space-pack/afterglow",
|
||||
name: "Afterglow",
|
||||
};
|
||||
const replacementDefinition = createThemeDefinitionFixture({
|
||||
name: replacement.name,
|
||||
description: replacement.description,
|
||||
dark: createThemePaletteFixture({ background: "#332211" }),
|
||||
});
|
||||
const selected: ThemesGetResult = {
|
||||
theme: expectDefined(BUILTIN_THEMES[0], "default built-in theme"),
|
||||
current: { id: "claw", mode: "system", scope: "profile", overrides: {} },
|
||||
};
|
||||
const retired = createDeferred<ThemesGetResult>();
|
||||
const { gateway, current } = createGatewayStoreTestStore();
|
||||
const applicationTheme = createApplicationTheme(loadSettings(), gateway);
|
||||
gateway.start();
|
||||
let paletteReads = 0;
|
||||
current().request.mockImplementation((method, params) => {
|
||||
if (method === "themes.list") {
|
||||
return Promise.resolve({
|
||||
...selected,
|
||||
themes: [...BUILTIN_THEMES, descriptor, replacement],
|
||||
} satisfies ThemesListResult);
|
||||
}
|
||||
if (method === "themes.get") {
|
||||
paletteReads += 1;
|
||||
if (paletteReads === 1) {
|
||||
expect(params).toEqual({ id: descriptor.id });
|
||||
return retired.promise;
|
||||
}
|
||||
expect(params).toEqual({ id: replacement.id });
|
||||
return Promise.resolve({
|
||||
current: selected.current,
|
||||
theme: replacement,
|
||||
definition: replacementDefinition,
|
||||
} satisfies ThemesGetResult);
|
||||
}
|
||||
return Promise.reject(new Error(`Unexpected request ${method}`));
|
||||
});
|
||||
current().opts.onHello?.({
|
||||
...GATEWAY_STORE_TEST_HELLO,
|
||||
auth: { role: "operator", scopes: ["operator.read"] },
|
||||
snapshot: { presence: [{ instanceId: current().instanceId, user: { id: "read-only" } }] },
|
||||
});
|
||||
try {
|
||||
await vi.waitFor(() =>
|
||||
expect(current().request).toHaveBeenCalledWith("themes.get", { id: descriptor.id }),
|
||||
);
|
||||
patchSettings({ theme: replacement.id });
|
||||
await vi.waitFor(() => expect(document.documentElement.dataset.themeId).toBe(replacement.id));
|
||||
expect(applicationTheme.settings.theme).toBe(replacement.id);
|
||||
expect(document.getElementById("openclaw-custom-theme")?.textContent).toContain(
|
||||
"--bg: #332211;",
|
||||
);
|
||||
|
||||
retired.reject(new Error("Previous palette is unavailable"));
|
||||
await retired.promise.catch(() => undefined);
|
||||
expect(document.documentElement.dataset.themeId).toBe(replacement.id);
|
||||
expect(applicationTheme.catalog?.error).toBeNull();
|
||||
expect(paletteReads).toBe(2);
|
||||
} finally {
|
||||
applicationTheme.dispose();
|
||||
gateway.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps a local palette selected during catalog refresh and reloads later versions", async () => {
|
||||
patchSettings({ theme: "claw" });
|
||||
const response: ThemesListResult = {
|
||||
themes: [...BUILTIN_THEMES, descriptor],
|
||||
theme: expectDefined(BUILTIN_THEMES[0], "default built-in theme"),
|
||||
current: { id: "claw", mode: "system", scope: "profile", overrides: {} },
|
||||
};
|
||||
const refreshing = createDeferred<ThemesListResult>();
|
||||
const { gateway, current } = createGatewayStoreTestStore();
|
||||
const applicationTheme = createApplicationTheme(loadSettings(), gateway);
|
||||
gateway.start();
|
||||
let lists = 0;
|
||||
let palette = definition;
|
||||
let paletteResponse: Promise<ThemesGetResult> | undefined;
|
||||
let paletteReads = 0;
|
||||
current().request.mockImplementation((method) => {
|
||||
if (method === "themes.list") {
|
||||
lists += 1;
|
||||
return lists === 2 ? refreshing.promise : Promise.resolve(response);
|
||||
}
|
||||
if (method === "themes.get") {
|
||||
paletteReads += 1;
|
||||
return paletteResponse ?? Promise.resolve({ ...catalog(palette), current: response.current });
|
||||
}
|
||||
if (method === "plugins.uiDescriptors") {
|
||||
return Promise.resolve({ ok: true, generation: 1, descriptors: [], methods: [] });
|
||||
}
|
||||
return Promise.reject(new Error(`Unexpected request ${method}`));
|
||||
});
|
||||
current().opts.onHello?.({ ...GATEWAY_STORE_TEST_HELLO });
|
||||
try {
|
||||
await vi.waitFor(() => expect(applicationTheme.catalog?.themes).toContainEqual(descriptor));
|
||||
current().opts.onEvent?.(createGatewayEvent("plugins.changed", { generation: 1 }));
|
||||
await vi.waitFor(() => expect(lists).toBe(2));
|
||||
patchSettings({ theme: descriptor.id });
|
||||
await vi.waitFor(() => expect(document.documentElement.dataset.themeId).toBe(descriptor.id));
|
||||
|
||||
refreshing.resolve(response);
|
||||
await refreshing.promise;
|
||||
await vi.waitFor(() => expect(document.documentElement.dataset.themeId).toBe(descriptor.id));
|
||||
expect(applicationTheme.settings.theme).toBe(descriptor.id);
|
||||
|
||||
palette = createThemeDefinitionFixture({
|
||||
dark: createThemePaletteFixture({ background: "#442244" }),
|
||||
});
|
||||
const delayed = createDeferred<ThemesGetResult>();
|
||||
paletteResponse = delayed.promise;
|
||||
current().opts.onEvent?.(createGatewayEvent("plugins.changed", { generation: 2 }));
|
||||
await vi.waitFor(() => expect(paletteReads).toBe(2));
|
||||
expect(document.documentElement.dataset.themeId).toBe(descriptor.id);
|
||||
expect(document.getElementById("openclaw-custom-theme")?.textContent).toContain(
|
||||
"--bg: #111122;",
|
||||
);
|
||||
delayed.resolve({ ...catalog(palette), current: response.current });
|
||||
await delayed.promise;
|
||||
await vi.waitFor(() =>
|
||||
expect(document.getElementById("openclaw-custom-theme")?.textContent).toContain(
|
||||
"--bg: #442244;",
|
||||
),
|
||||
);
|
||||
|
||||
const failed = createDeferred<ThemesGetResult>();
|
||||
paletteResponse = failed.promise;
|
||||
current().opts.onEvent?.(createGatewayEvent("plugins.changed", { generation: 3 }));
|
||||
await vi.waitFor(() => expect(paletteReads).toBe(3));
|
||||
failed.reject(new Error("Palette refresh temporarily failed"));
|
||||
await failed.promise.catch(() => undefined);
|
||||
expect(document.documentElement.dataset.themeId).toBe(descriptor.id);
|
||||
expect(document.getElementById("openclaw-custom-theme")?.textContent).toContain(
|
||||
"--bg: #442244;",
|
||||
);
|
||||
expect(applicationTheme.catalog?.error).toBe("Palette refresh temporarily failed");
|
||||
patchSettings({ textScale: 110 });
|
||||
expect(paletteReads).toBe(3);
|
||||
|
||||
paletteResponse = undefined;
|
||||
palette = createThemeDefinitionFixture({
|
||||
dark: createThemePaletteFixture({ background: "#553355" }),
|
||||
});
|
||||
expectDefined(applicationTheme.retryCatalog, "theme catalog retry")();
|
||||
await vi.waitFor(() =>
|
||||
expect(document.getElementById("openclaw-custom-theme")?.textContent).toContain(
|
||||
"--bg: #553355;",
|
||||
),
|
||||
);
|
||||
expect(applicationTheme.catalog?.error).toBeNull();
|
||||
} finally {
|
||||
applicationTheme.dispose();
|
||||
gateway.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it.each(["success", "failure"] as const)(
|
||||
"ignores a late palette %s after its plugin disappears during refresh",
|
||||
async (outcome) => {
|
||||
patchSettings({ theme: "claw" });
|
||||
const response: ThemesListResult = {
|
||||
themes: [...BUILTIN_THEMES, descriptor],
|
||||
theme: expectDefined(BUILTIN_THEMES[0], "default built-in theme"),
|
||||
current: { id: "claw", mode: "system", scope: "profile", overrides: {} },
|
||||
};
|
||||
const refreshing = createDeferred<ThemesListResult>();
|
||||
const retired = createDeferred<ThemesGetResult>();
|
||||
const { gateway, current } = createGatewayStoreTestStore();
|
||||
const applicationTheme = createApplicationTheme(loadSettings(), gateway);
|
||||
gateway.start();
|
||||
let lists = 0;
|
||||
current().request.mockImplementation((method) => {
|
||||
if (method === "themes.list") {
|
||||
return ++lists === 1 ? Promise.resolve(response) : refreshing.promise;
|
||||
}
|
||||
if (method === "themes.get") {
|
||||
return retired.promise;
|
||||
}
|
||||
if (method === "plugins.uiDescriptors") {
|
||||
return Promise.resolve({ ok: true, generation: 1, descriptors: [], methods: [] });
|
||||
}
|
||||
return Promise.reject(new Error(`Unexpected request ${method}`));
|
||||
});
|
||||
current().opts.onHello?.({ ...GATEWAY_STORE_TEST_HELLO });
|
||||
try {
|
||||
await vi.waitFor(() => expect(applicationTheme.catalog?.themes).toContainEqual(descriptor));
|
||||
current().opts.onEvent?.(createGatewayEvent("plugins.changed", { generation: 1 }));
|
||||
await vi.waitFor(() => expect(lists).toBe(2));
|
||||
patchSettings({ theme: descriptor.id });
|
||||
await vi.waitFor(() =>
|
||||
expect(current().request).toHaveBeenCalledWith("themes.get", { id: descriptor.id }),
|
||||
);
|
||||
refreshing.resolve({ ...response, themes: [...BUILTIN_THEMES] });
|
||||
await refreshing.promise;
|
||||
expect(applicationTheme.catalog?.themes).toEqual(BUILTIN_THEMES);
|
||||
|
||||
if (outcome === "success") {
|
||||
retired.resolve({ ...catalog(), current: response.current });
|
||||
} else {
|
||||
retired.reject(new Error("Removed palette is unavailable"));
|
||||
}
|
||||
await retired.promise.catch(() => undefined);
|
||||
expect(applicationTheme.settings.theme).toBe(descriptor.id);
|
||||
expect(document.documentElement.dataset.themeId).toBe("claw");
|
||||
expect(document.getElementById("openclaw-custom-theme")).toBeNull();
|
||||
expect(applicationTheme.catalog?.error).toBeNull();
|
||||
} finally {
|
||||
applicationTheme.dispose();
|
||||
gateway.stop();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["profile", "client"] as const)(
|
||||
"discards a late personal palette after the requesting %s changes",
|
||||
async (boundary) => {
|
||||
const personal: ThemeDescriptor = {
|
||||
id: "user/personal",
|
||||
name: "Personal",
|
||||
description: "A profile-owned palette.",
|
||||
source: "user",
|
||||
modes: ["dark"],
|
||||
};
|
||||
patchSettings({ theme: personal.id });
|
||||
const response: ThemesListResult = {
|
||||
themes: [...BUILTIN_THEMES, personal],
|
||||
theme: expectDefined(BUILTIN_THEMES[0], "default built-in theme"),
|
||||
current: { id: "claw", mode: "system", scope: "profile", overrides: {} },
|
||||
};
|
||||
const retired = createDeferred<ThemesGetResult>();
|
||||
const { gateway, current } = createGatewayStoreTestStore();
|
||||
const applicationTheme = createApplicationTheme(loadSettings(), gateway);
|
||||
gateway.start();
|
||||
current().request.mockImplementation((method) =>
|
||||
method === "themes.get" ? retired.promise : Promise.resolve(response),
|
||||
);
|
||||
current().opts.onHello?.({
|
||||
...GATEWAY_STORE_TEST_HELLO,
|
||||
snapshot: { presence: [{ instanceId: current().instanceId, user: { id: "first" } }] },
|
||||
});
|
||||
try {
|
||||
await vi.waitFor(() =>
|
||||
expect(current().request).toHaveBeenCalledWith("themes.get", { id: personal.id }),
|
||||
);
|
||||
if (boundary === "client") {
|
||||
gateway.connect();
|
||||
}
|
||||
current().request.mockResolvedValue({
|
||||
...response,
|
||||
theme: personal,
|
||||
definition: createThemeDefinitionFixture({
|
||||
dark: createThemePaletteFixture({ background: "#443355" }),
|
||||
}),
|
||||
current: { ...response.current, id: personal.id },
|
||||
} satisfies ThemesListResult);
|
||||
if (boundary === "profile") {
|
||||
current().opts.onEvent?.(
|
||||
createGatewayEvent("presence", {
|
||||
presence: [{ instanceId: current().instanceId, user: { id: "second" } }],
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
current().opts.onHello?.({
|
||||
...GATEWAY_STORE_TEST_HELLO,
|
||||
snapshot: { presence: [{ instanceId: current().instanceId, user: { id: "first" } }] },
|
||||
});
|
||||
}
|
||||
await vi.waitFor(() =>
|
||||
expect(document.getElementById("openclaw-custom-theme")?.textContent).toContain(
|
||||
"--bg: #443355;",
|
||||
),
|
||||
);
|
||||
retired.resolve({ ...response, theme: personal, definition });
|
||||
await retired.promise;
|
||||
expect(document.getElementById("openclaw-custom-theme")?.textContent).toContain(
|
||||
"--bg: #443355;",
|
||||
);
|
||||
expect(applicationTheme.catalog?.error).toBeNull();
|
||||
} finally {
|
||||
applicationTheme.dispose();
|
||||
gateway.stop();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["Appearance discovery", "profile selection"] as const)(
|
||||
"leaves built-in presentation quiet until %s needs the catalog",
|
||||
async (trigger) => {
|
||||
|
|
|
|||
|
|
@ -33,7 +33,8 @@ export function createThemeCatalog(gateway: ApplicationGateway, onChange: () =>
|
|||
let ownerConnected = gateway.snapshot.phase === "connected";
|
||||
let generation = 0;
|
||||
let disposed = false;
|
||||
const definitions = new Map<string, CatalogTheme>();
|
||||
const definitions = new Map<string, { generation: number; theme: CatalogTheme }>();
|
||||
const definitionErrors = new Map<string, string>();
|
||||
const requested = new Set<string>();
|
||||
|
||||
const remainsCurrent = (request: number) =>
|
||||
|
|
@ -48,11 +49,15 @@ export function createThemeCatalog(gateway: ApplicationGateway, onChange: () =>
|
|||
const light = definition?.light ?? definition?.dark;
|
||||
const dark = definition?.dark ?? definition?.light;
|
||||
if (definition && light && dark) {
|
||||
definitionErrors.delete(result.theme.id);
|
||||
definitions.set(result.theme.id, {
|
||||
mode: !definition.light ? "dark" : !definition.dark ? "light" : undefined,
|
||||
palette: {
|
||||
light: normalizeThemePalette("light", light, undefined),
|
||||
dark: normalizeThemePalette("dark", dark, undefined),
|
||||
generation,
|
||||
theme: {
|
||||
mode: !definition.light ? "dark" : !definition.dark ? "light" : undefined,
|
||||
palette: {
|
||||
light: normalizeThemePalette("light", light, undefined),
|
||||
dark: normalizeThemePalette("dark", dark, undefined),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -67,12 +72,23 @@ export function createThemeCatalog(gateway: ApplicationGateway, onChange: () =>
|
|||
ownerProfile = gateway.snapshot.selfUser?.id;
|
||||
const request = ++generation;
|
||||
requested.clear();
|
||||
definitionErrors.clear();
|
||||
try {
|
||||
const result = await client.request<ThemesListResult>("themes.list", {});
|
||||
if (!remainsCurrent(request)) {
|
||||
return;
|
||||
}
|
||||
definitions.clear();
|
||||
const available = new Set<string>(result.themes.map((theme) => theme.id));
|
||||
for (const id of definitions.keys()) {
|
||||
if (!available.has(id)) {
|
||||
definitions.delete(id);
|
||||
}
|
||||
}
|
||||
for (const id of definitionErrors.keys()) {
|
||||
if (!available.has(id)) {
|
||||
definitionErrors.delete(id);
|
||||
}
|
||||
}
|
||||
rememberDefinition(result);
|
||||
snapshot = { themes: result.themes, error: null, unavailableId: result.current.requestedId };
|
||||
onChange();
|
||||
|
|
@ -89,7 +105,7 @@ export function createThemeCatalog(gateway: ApplicationGateway, onChange: () =>
|
|||
if (
|
||||
id === "custom" ||
|
||||
isBuiltinThemeId(id) ||
|
||||
definitions.has(id) ||
|
||||
definitions.get(id)?.generation === generation ||
|
||||
requested.has(id) ||
|
||||
!snapshot.themes.some((theme) => theme.id === id) ||
|
||||
gateway.snapshot.phase !== "connected" ||
|
||||
|
|
@ -99,20 +115,19 @@ export function createThemeCatalog(gateway: ApplicationGateway, onChange: () =>
|
|||
}
|
||||
requested.add(id);
|
||||
const request = generation;
|
||||
const remainsAvailable = () =>
|
||||
remainsCurrent(request) && snapshot.themes.some((theme) => theme.id === id);
|
||||
void client.request<ThemesGetResult>("themes.get", { id }).then(
|
||||
(result) => {
|
||||
if (!remainsCurrent(request)) {
|
||||
if (!remainsAvailable()) {
|
||||
return;
|
||||
}
|
||||
rememberDefinition(result);
|
||||
onChange();
|
||||
},
|
||||
(error: unknown) => {
|
||||
if (remainsCurrent(request)) {
|
||||
snapshot = {
|
||||
...snapshot,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
if (remainsAvailable()) {
|
||||
definitionErrors.set(id, error instanceof Error ? error.message : String(error));
|
||||
onChange();
|
||||
}
|
||||
},
|
||||
|
|
@ -121,6 +136,7 @@ export function createThemeCatalog(gateway: ApplicationGateway, onChange: () =>
|
|||
|
||||
const clear = () => {
|
||||
definitions.clear();
|
||||
definitionErrors.clear();
|
||||
requested.clear();
|
||||
snapshot = { themes: [], error: null };
|
||||
onChange();
|
||||
|
|
@ -177,12 +193,13 @@ export function createThemeCatalog(gateway: ApplicationGateway, onChange: () =>
|
|||
}
|
||||
|
||||
return {
|
||||
get snapshot() {
|
||||
return snapshot;
|
||||
snapshot(theme: ThemeName): ThemeCatalogSnapshot {
|
||||
const error = snapshot.error ?? definitionErrors.get(theme) ?? null;
|
||||
return error === snapshot.error ? snapshot : { ...snapshot, error };
|
||||
},
|
||||
theme(id: ThemeName) {
|
||||
ensureDefinition(id);
|
||||
return definitions.get(id);
|
||||
return definitions.get(id)?.theme;
|
||||
},
|
||||
refresh,
|
||||
dispose() {
|
||||
|
|
@ -191,6 +208,7 @@ export function createThemeCatalog(gateway: ApplicationGateway, onChange: () =>
|
|||
stopGateway();
|
||||
stopEvents();
|
||||
definitions.clear();
|
||||
definitionErrors.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -203,7 +203,11 @@ export function renderAppearanceSection(
|
|||
: t("configView.appearance.importHint"),
|
||||
},
|
||||
];
|
||||
const presentedTheme = themeOptions.find((option) => option.id === props.theme) ?? {
|
||||
const selectedTheme = themeOptions.find((option) => option.id === props.theme);
|
||||
const themeUnavailable =
|
||||
props.themeCatalog?.unavailableId === props.theme ||
|
||||
(props.theme.includes("/") && Boolean(props.themeCatalog?.themes.length) && !selectedTheme);
|
||||
const presentedTheme = selectedTheme ?? {
|
||||
id: UI_APPEARANCE_DEFAULTS.theme,
|
||||
label: t("configView.themes.claw.label"),
|
||||
};
|
||||
|
|
@ -257,7 +261,7 @@ export function renderAppearanceSection(
|
|||
${themeProvenance}
|
||||
</p>
|
||||
${
|
||||
props.themeCatalog?.unavailableId === props.theme
|
||||
themeUnavailable
|
||||
? html`<p class="settings-section__desc" role="status">
|
||||
${t("configView.appearance.themeUnavailable", { id: props.theme })}
|
||||
</p>`
|
||||
|
|
|
|||
|
|
@ -45,41 +45,44 @@ it("offers plugin and personal themes from the shared catalog with their descrip
|
|||
);
|
||||
});
|
||||
|
||||
it("shows Claw presentation while preserving an unavailable saved plugin selection", async () => {
|
||||
const missingTheme = "space-pack/xenovessel";
|
||||
const { container, props } = renderConfigView({
|
||||
activeSection: "__appearance__",
|
||||
includeSections: ["__appearance__"],
|
||||
theme: missingTheme,
|
||||
themeOverridden: true,
|
||||
themeProvenance: "profile",
|
||||
themeCatalog: {
|
||||
error: null,
|
||||
themes: [...BUILTIN_THEMES],
|
||||
unavailableId: missingTheme,
|
||||
},
|
||||
});
|
||||
await updatePickers(container);
|
||||
expect(container.textContent).toContain(
|
||||
`${missingTheme} is unavailable. Using Claw until the theme becomes available again.`,
|
||||
);
|
||||
const claw = container.querySelector<HTMLButtonElement>('[data-theme-id="claw"]');
|
||||
expect(claw?.getAttribute("aria-pressed")).toBe("true");
|
||||
expect(container.querySelector('[data-accent-preset="default"]')?.classList).toContain(
|
||||
"settings-accent-theme--claw",
|
||||
);
|
||||
for (const slot of ["ui", "chat"]) {
|
||||
const picker = container
|
||||
.querySelector(`#settings-font-${slot}`)
|
||||
?.closest("openclaw-select-picker");
|
||||
expect(picker?.querySelector('[role="option"][data-value="theme"]')?.textContent).toContain(
|
||||
"Claw · Instrument Sans",
|
||||
it.each(["profile", "device-local"] as const)(
|
||||
"shows Claw presentation while preserving an unavailable %s plugin selection",
|
||||
async (provenance) => {
|
||||
const missingTheme = "space-pack/xenovessel";
|
||||
const { container, props } = renderConfigView({
|
||||
activeSection: "__appearance__",
|
||||
includeSections: ["__appearance__"],
|
||||
theme: missingTheme,
|
||||
themeOverridden: true,
|
||||
themeProvenance: provenance,
|
||||
themeCatalog: {
|
||||
error: null,
|
||||
themes: [...BUILTIN_THEMES],
|
||||
...(provenance === "profile" ? { unavailableId: missingTheme } : {}),
|
||||
},
|
||||
});
|
||||
await updatePickers(container);
|
||||
expect(container.textContent).toContain(
|
||||
`${missingTheme} is unavailable. Using Claw until the theme becomes available again.`,
|
||||
);
|
||||
}
|
||||
expect(props.theme).toBe(missingTheme);
|
||||
claw?.click();
|
||||
expect(props.setTheme).toHaveBeenCalledWith("claw", { element: claw });
|
||||
});
|
||||
const claw = container.querySelector<HTMLButtonElement>('[data-theme-id="claw"]');
|
||||
expect(claw?.getAttribute("aria-pressed")).toBe("true");
|
||||
expect(container.querySelector('[data-accent-preset="default"]')?.classList).toContain(
|
||||
"settings-accent-theme--claw",
|
||||
);
|
||||
for (const slot of ["ui", "chat"]) {
|
||||
const picker = container
|
||||
.querySelector(`#settings-font-${slot}`)
|
||||
?.closest("openclaw-select-picker");
|
||||
expect(picker?.querySelector('[role="option"][data-value="theme"]')?.textContent).toContain(
|
||||
"Claw · Instrument Sans",
|
||||
);
|
||||
}
|
||||
expect(props.theme).toBe(missingTheme);
|
||||
claw?.click();
|
||||
expect(props.setTheme).toHaveBeenCalledWith("claw", { element: claw });
|
||||
},
|
||||
);
|
||||
|
||||
it("offers an explicit retry when the theme catalog cannot load", () => {
|
||||
const onRetryThemeCatalog = vi.fn();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue