test: merge more ui render hotspots

This commit is contained in:
Peter Steinberger
2026-04-17 19:10:11 +01:00
parent 3a1e469732
commit d155d578eb
7 changed files with 224 additions and 312 deletions

View File

@@ -39,72 +39,6 @@ function expectConfirmedGatewayChange(app: ReturnType<typeof mountApp>) {
}
describe("control UI routing", () => {
it("renders the dreaming view on the /dreaming route", async () => {
const app = mountApp("/dreaming");
app.dreamingStatus = {
enabled: true,
timezone: "Europe/Madrid",
verboseLogging: false,
storageMode: "inline",
separateReports: false,
shortTermCount: 2,
recallSignalCount: 1,
dailySignalCount: 1,
groundedSignalCount: 0,
totalSignalCount: 2,
phaseSignalCount: 0,
lightPhaseHitCount: 0,
remPhaseHitCount: 0,
promotedTotal: 1,
promotedToday: 1,
shortTermEntries: [],
signalEntries: [],
promotedEntries: [],
phases: {
light: { enabled: true, cron: "", managedCronPresent: false, lookbackDays: 7, limit: 20 },
deep: {
enabled: true,
cron: "",
managedCronPresent: false,
limit: 20,
minScore: 0.75,
minRecallCount: 3,
minUniqueQueries: 2,
recencyHalfLifeDays: 7,
},
rem: {
enabled: true,
cron: "",
managedCronPresent: false,
lookbackDays: 7,
limit: 20,
minPatternStrength: 0.6,
},
},
};
app.dreamDiaryPath = "DREAMS.md";
app.dreamDiaryContent = [
"# Dream Diary",
"",
"<!-- openclaw:dreaming:diary:start -->",
"",
"---",
"",
"*January 1, 2026*",
"",
"What Happened",
"1. Stable operator rule surfaced.",
"",
"<!-- openclaw:dreaming:diary:end -->",
].join("\n");
app.requestUpdate();
await app.updateComplete;
expect(app.tab).toBe("dreams");
expect(app.querySelector(".dreams__tab")).not.toBeNull();
expect(app.querySelector(".dreams__lobster")).not.toBeNull();
});
it("renders responsive navigation shell, drawer, and collapsed states", async () => {
const app = mountApp("/chat");
await app.updateComplete;
@@ -127,6 +61,19 @@ describe("control UI routing", () => {
expect(app.querySelector(".sidebar-brand__logo")).not.toBeNull();
expect(app.querySelector(".sidebar-brand__copy")).not.toBeNull();
app.hello = {
ok: true,
server: { version: "1.2.3" },
} as never;
app.requestUpdate();
await app.updateComplete;
const version = app.querySelector<HTMLElement>(".sidebar-version");
const statusDot = app.querySelector<HTMLElement>(".sidebar-version__status");
expect(version).not.toBeNull();
expect(statusDot).not.toBeNull();
expect(statusDot?.getAttribute("aria-label")).toContain("Online");
app.applySettings({ ...app.settings, navWidth: 360 });
await app.updateComplete;
@@ -274,24 +221,6 @@ describe("control UI routing", () => {
expect(shell?.classList.contains("shell--chat-focus")).toBe(true);
});
it("shows one online status dot next to the sidebar version", async () => {
const app = mountApp("/chat");
await app.updateComplete;
app.hello = {
ok: true,
server: { version: "1.2.3" },
} as never;
app.requestUpdate();
await app.updateComplete;
const version = app.querySelector<HTMLElement>(".sidebar-version");
const statusDot = app.querySelector<HTMLElement>(".sidebar-version__status");
expect(version).not.toBeNull();
expect(statusDot).not.toBeNull();
expect(statusDot?.getAttribute("aria-label")).toContain("Online");
});
it("auto-scrolls chat history to the latest message", async () => {
vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => {
queueMicrotask(() => callback(performance.now()));

View File

@@ -144,15 +144,12 @@ describe("renderAgents", () => {
);
await Promise.resolve();
const skillsTab = Array.from(container.querySelectorAll<HTMLButtonElement>(".agent-tab")).find(
let skillsTab = Array.from(container.querySelectorAll<HTMLButtonElement>(".agent-tab")).find(
(button) => button.textContent?.includes("Skills"),
);
expect(skillsTab?.textContent?.trim()).toBe("Skills");
});
it("shows the selected agent's skills count when the report matches", async () => {
const container = document.createElement("div");
render(
renderAgents(
createProps({
@@ -173,7 +170,7 @@ describe("renderAgents", () => {
);
await Promise.resolve();
const skillsTab = Array.from(container.querySelectorAll<HTMLButtonElement>(".agent-tab")).find(
skillsTab = Array.from(container.querySelectorAll<HTMLButtonElement>(".agent-tab")).find(
(button) => button.textContent?.includes("Skills"),
);

View File

@@ -6,7 +6,7 @@ import { getSafeLocalStorage } from "../../local-storage.ts";
import { resetAssistantAttachmentAvailabilityCacheForTest } from "../chat/grouped-render.ts";
import { normalizeMessage } from "../chat/message-normalizer.ts";
import type { SessionsListResult } from "../types.ts";
import { renderChat, type ChatProps } from "./chat.ts";
import { getContextNoticeViewModel, renderChat, type ChatProps } from "./chat.ts";
function createSessions(): SessionsListResult {
return {
@@ -162,16 +162,19 @@ describe("chat view", () => {
container,
);
renderWithSession({
key: "main",
kind: "direct",
updatedAt: null,
inputTokens: 757_300,
totalTokens: 46_000,
contextTokens: 200_000,
});
expect(container.textContent).not.toContain("context used");
expect(container.textContent).not.toContain("757.3k / 200k");
expect(
getContextNoticeViewModel(
{
key: "main",
kind: "direct",
updatedAt: null,
inputTokens: 757_300,
totalTokens: 46_000,
contextTokens: 200_000,
},
200_000,
),
).toBeNull();
renderWithSession({
key: "main",
@@ -201,25 +204,31 @@ describe("chat view", () => {
document.documentElement.style.removeProperty("--warn");
document.documentElement.style.removeProperty("--danger");
renderWithSession({
key: "main",
kind: "direct",
updatedAt: null,
inputTokens: 500_000,
contextTokens: 200_000,
});
expect(container.textContent).not.toContain("context used");
renderWithSession({
key: "main",
kind: "direct",
updatedAt: null,
totalTokens: 190_000,
totalTokensFresh: false,
contextTokens: 200_000,
});
expect(container.textContent).not.toContain("context used");
expect(container.textContent).not.toContain("190k / 200k");
expect(
getContextNoticeViewModel(
{
key: "main",
kind: "direct",
updatedAt: null,
inputTokens: 500_000,
contextTokens: 200_000,
},
200_000,
),
).toBeNull();
expect(
getContextNoticeViewModel(
{
key: "main",
kind: "direct",
updatedAt: null,
totalTokens: 190_000,
totalTokensFresh: false,
contextTokens: 200_000,
},
200_000,
),
).toBeNull();
});
it("uses the assistant avatar URL or bundled logo fallbacks", () => {

View File

@@ -529,21 +529,26 @@ function getThemeNoticeColors() {
return cachedThemeNoticeColors;
}
function renderContextNotice(
export function getContextNoticeViewModel(
session: GatewaySessionRow | undefined,
defaultContextTokens: number | null,
) {
): {
pct: number;
detail: string;
color: string;
bg: string;
} | null {
if (session?.totalTokensFresh === false) {
return nothing;
return null;
}
const used = session?.totalTokens ?? 0;
const limit = session?.contextTokens ?? defaultContextTokens ?? 0;
if (!used || !limit) {
return nothing;
return null;
}
const ratio = used / limit;
if (ratio < 0.85) {
return nothing;
return null;
}
const pct = Math.min(Math.round(ratio * 100), 100);
// Read theme semantic tokens so color tracks the active theme (Dash, dark, light …)
@@ -558,8 +563,28 @@ function renderContextNotice(
const color = `rgb(${r}, ${g}, ${b})`;
const bgOpacity = 0.08 + 0.08 * t;
const bg = `rgba(${r}, ${g}, ${b}, ${bgOpacity})`;
return {
pct,
detail: `${formatTokensCompact(used)} / ${formatTokensCompact(limit)}`,
color,
bg,
};
}
function renderContextNotice(
session: GatewaySessionRow | undefined,
defaultContextTokens: number | null,
) {
const model = getContextNoticeViewModel(session, defaultContextTokens);
if (!model) {
return nothing;
}
return html`
<div class="context-notice" role="status" style="--ctx-color:${color};--ctx-bg:${bg}">
<div
class="context-notice"
role="status"
style="--ctx-color:${model.color};--ctx-bg:${model.bg}"
>
<svg
class="context-notice__icon"
width="16"
@@ -575,10 +600,8 @@ function renderContextNotice(
<line x1="12" y1="9" x2="12" y2="13" />
<line x1="12" y1="17" x2="12.01" y2="17" />
</svg>
<span>${pct}% context used</span>
<span class="context-notice__detail"
>${formatTokensCompact(used)} / ${formatTokensCompact(limit)}</span
>
<span>${model.pct}% context used</span>
<span class="context-notice__detail">${model.detail}</span>
</div>
`;
}

View File

@@ -361,43 +361,46 @@ describe("config view", () => {
expect(onRawChange).toHaveBeenCalledWith(textarea.value);
});
it("renders structured SecretRef values as read-only text inputs without stringifying", () => {
it("renders structured SecretRef values without stringifying", () => {
const onFormPatch = vi.fn();
const { container } = renderConfigView({
schema: {
type: "object",
properties: {
channels: {
type: "object",
properties: {
discord: {
type: "object",
properties: {
token: { type: "string" },
},
const secretRefSchema = {
type: "object" as const,
properties: {
channels: {
type: "object" as const,
properties: {
discord: {
type: "object" as const,
properties: {
token: { type: "string" as const },
},
},
},
},
},
};
const secretRefValue = {
channels: {
discord: {
token: { source: "env", provider: "default", id: "__OPENCLAW_REDACTED__" },
},
},
};
const secretRefOriginalValue = {
channels: {
discord: {
token: { source: "env", provider: "default", id: "DISCORD_BOT_TOKEN" },
},
},
};
const { container } = renderConfigView({
schema: secretRefSchema,
uiHints: {
"channels.discord.token": { sensitive: true },
},
formMode: "form",
formValue: {
channels: {
discord: {
token: { source: "env", provider: "default", id: "__OPENCLAW_REDACTED__" },
},
},
},
originalValue: {
channels: {
discord: {
token: { source: "env", provider: "default", id: "DISCORD_BOT_TOKEN" },
},
},
},
formValue: secretRefValue,
originalValue: secretRefOriginalValue,
onFormPatch,
});
@@ -415,50 +418,27 @@ describe("config view", () => {
input.dispatchEvent(new Event("input", { bubbles: true }));
input.dispatchEvent(new Event("change", { bubbles: true }));
expect(onFormPatch).not.toHaveBeenCalled();
});
it("uses a file-edit placeholder for structured SecretRefs when raw mode is unavailable", () => {
const { container } = renderConfigView({
rawAvailable: false,
formMode: "raw",
schema: {
type: "object",
properties: {
channels: {
type: "object",
properties: {
discord: {
type: "object",
properties: {
token: { type: "string" },
},
},
},
},
render(
renderConfig({
...baseProps(),
rawAvailable: false,
formMode: "raw",
schema: secretRefSchema,
uiHints: {
"channels.discord.token": { sensitive: true },
},
},
uiHints: {
"channels.discord.token": { sensitive: true },
},
formValue: {
channels: {
discord: {
token: { source: "env", provider: "default", id: "__OPENCLAW_REDACTED__" },
},
},
},
originalValue: {
channels: {
discord: {
token: { source: "env", provider: "default", id: "DISCORD_BOT_TOKEN" },
},
},
},
});
formValue: secretRefValue,
originalValue: secretRefOriginalValue,
}),
container,
);
const input = container.querySelector<HTMLInputElement>(".cfg-input");
expect(input).not.toBeNull();
expect(input?.placeholder).toBe("Structured value (SecretRef) - edit the config file directly");
const rawUnavailableInput = container.querySelector<HTMLInputElement>(".cfg-input");
expect(rawUnavailableInput).not.toBeNull();
expect(rawUnavailableInput?.placeholder).toBe(
"Structured value (SecretRef) - edit the config file directly",
);
});
it("keeps malformed non-SecretRef object values editable when raw mode is unavailable", () => {

View File

@@ -84,10 +84,30 @@ function getButtonByText(container: Element, text: string) {
}
describe("cron view", () => {
it("shows all-job history mode and toggles the run status filter", () => {
it("shows all-job history mode and wires run/job filters", () => {
const container = document.createElement("div");
const onRunsFiltersChange = vi.fn();
render(renderCron(createProps({ onRunsFiltersChange })), container);
const onJobsFiltersChange = vi.fn();
const onJobsFiltersReset = vi.fn();
render(
renderCron(
createProps({
onRunsFiltersChange,
onJobsFiltersChange,
runsScope: "all",
runs: [
{
ts: Date.now(),
jobId: "job-1",
status: "ok",
summary: "done",
nextRunAtMs: Date.now() - 13 * 60_000,
},
],
}),
),
container,
);
expect(container.textContent).toContain("Latest runs across all jobs.");
expect(container.textContent).toContain("Status");
@@ -107,118 +127,9 @@ describe("cron view", () => {
statusOk.dispatchEvent(new Event("change", { bubbles: true }));
expect(onRunsFiltersChange).toHaveBeenCalledWith({ cronRunsStatuses: ["ok"] });
});
it("marks the selected job and routes row/history clicks to run history", () => {
const container = document.createElement("div");
const onLoadRuns = vi.fn();
const job = createJob("job-1");
render(
renderCron(
createProps({
jobs: [job],
runsJobId: "job-1",
runsScope: "job",
onLoadRuns,
}),
),
container,
);
const selected = container.querySelector(".list-item-selected");
expect(selected).not.toBeNull();
const row = container.querySelector(".list-item-clickable");
expect(row).not.toBeNull();
row?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(onLoadRuns).toHaveBeenCalledWith("job-1");
const historyButton = Array.from(container.querySelectorAll("button")).find(
(btn) => btn.textContent?.trim() === "History",
);
expect(historyButton).not.toBeUndefined();
historyButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(onLoadRuns).toHaveBeenCalledTimes(2);
expect(onLoadRuns).toHaveBeenNthCalledWith(1, "job-1");
expect(onLoadRuns).toHaveBeenNthCalledWith(2, "job-1");
});
it("shows selected job run history sorted newest first with chat links", () => {
const container = document.createElement("div");
const job = createJob("job-1");
render(
renderCron(
createProps({
basePath: "/ui",
jobs: [job],
runsJobId: "job-1",
runsScope: "job",
runs: [
{ ts: 1, jobId: "job-1", status: "ok", summary: "older run" },
{
ts: 2,
jobId: "job-1",
status: "ok",
summary: "newer run",
sessionKey: "agent:main:cron:job-1:run:abc",
},
],
}),
),
container,
);
const link = container.querySelector("a.session-link");
expect(link).not.toBeNull();
expect(link?.getAttribute("href")).toContain(
"/ui/chat?session=agent%3Amain%3Acron%3Ajob-1%3Arun%3Aabc",
);
expect(container.textContent).toContain("Latest runs for Daily ping.");
const cards = Array.from(container.querySelectorAll(".card"));
const runHistoryCard = cards.find(
(card) => card.querySelector(".card-title")?.textContent?.trim() === "Run history",
);
expect(runHistoryCard).not.toBeUndefined();
const summaries = Array.from(
runHistoryCard?.querySelectorAll(".list-item .list-sub") ?? [],
).map((el) => (el.textContent ?? "").trim());
expect(summaries[0]).toBe("newer run");
expect(summaries[1]).toBe("older run");
});
it("labels past nextRunAtMs as due instead of next", () => {
const container = document.createElement("div");
render(
renderCron(
createProps({
runsScope: "all",
runs: [
{
ts: Date.now(),
jobId: "job-1",
status: "ok",
summary: "done",
nextRunAtMs: Date.now() - 13 * 60_000,
},
],
}),
),
container,
);
expect(container.textContent).toContain("Due");
expect(container.textContent).not.toContain("Next 13");
});
it("wires jobs filter changes and reset", () => {
const container = document.createElement("div");
const onJobsFiltersChange = vi.fn();
const onJobsFiltersReset = vi.fn();
render(renderCron(createProps({ onJobsFiltersChange })), container);
const scheduleSelect = container.querySelector(
'select[data-test-id="cron-jobs-schedule-filter"]',
@@ -261,6 +172,72 @@ describe("cron view", () => {
expect(onJobsFiltersReset).toHaveBeenCalledTimes(1);
});
it("marks the selected job, routes history clicks, and sorts runs newest first", () => {
const container = document.createElement("div");
const onLoadRuns = vi.fn();
const job = createJob("job-1");
render(
renderCron(
createProps({
basePath: "/ui",
jobs: [job],
runsJobId: "job-1",
runsScope: "job",
runs: [
{ ts: 1, jobId: "job-1", status: "ok", summary: "older run" },
{
ts: 2,
jobId: "job-1",
status: "ok",
summary: "newer run",
sessionKey: "agent:main:cron:job-1:run:abc",
},
],
onLoadRuns,
}),
),
container,
);
const selected = container.querySelector(".list-item-selected");
expect(selected).not.toBeNull();
const row = container.querySelector(".list-item-clickable");
expect(row).not.toBeNull();
row?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(onLoadRuns).toHaveBeenCalledWith("job-1");
const historyButton = Array.from(container.querySelectorAll("button")).find(
(btn) => btn.textContent?.trim() === "History",
);
expect(historyButton).not.toBeUndefined();
historyButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(onLoadRuns).toHaveBeenCalledTimes(2);
expect(onLoadRuns).toHaveBeenNthCalledWith(1, "job-1");
expect(onLoadRuns).toHaveBeenNthCalledWith(2, "job-1");
const link = container.querySelector("a.session-link");
expect(link).not.toBeNull();
expect(link?.getAttribute("href")).toContain(
"/ui/chat?session=agent%3Amain%3Acron%3Ajob-1%3Arun%3Aabc",
);
expect(container.textContent).toContain("Latest runs for Daily ping.");
const cards = Array.from(container.querySelectorAll(".card"));
const runHistoryCard = cards.find(
(card) => card.querySelector(".card-title")?.textContent?.trim() === "Run history",
);
expect(runHistoryCard).not.toBeUndefined();
const summaries = Array.from(
runHistoryCard?.querySelectorAll(".list-item .list-sub") ?? [],
).map((el) => (el.textContent ?? "").trim());
expect(summaries[0]).toBe("newer run");
expect(summaries[1]).toBe("older run");
});
it("renders supported delivery options and normalizes stale announce selection", () => {
const container = document.createElement("div");
render(

View File

@@ -65,7 +65,7 @@ function buildProps(result: SessionsListResult): SessionsProps {
}
describe("sessions view", () => {
it("keeps explicit and unknown session setting values selectable", async () => {
it("keeps session selects stable and deselects only the current page", async () => {
const container = document.createElement("div");
render(
renderSessions(
@@ -95,13 +95,10 @@ describe("sessions view", () => {
expect(
Array.from(reasoning?.options ?? []).some((option) => option.value === "custom-mode"),
).toBe(true);
});
it("deselects only the current page from the header checkbox", async () => {
const onSelectPage = vi.fn();
const onDeselectPage = vi.fn();
const onDeselectAll = vi.fn();
const container = document.createElement("div");
render(
renderSessions({
...buildProps(