From d155d578ebde238cd58005940e129964d092c182 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 17 Apr 2026 19:10:11 +0100 Subject: [PATCH] test: merge more ui render hotspots --- ui/src/ui/navigation.browser.test.ts | 97 ++---------- ui/src/ui/views/agents.test.ts | 7 +- ui/src/ui/views/chat.test.ts | 69 +++++---- ui/src/ui/views/chat.ts | 43 ++++-- ui/src/ui/views/config.browser.test.ts | 116 ++++++-------- ui/src/ui/views/cron.test.ts | 199 +++++++++++-------------- ui/src/ui/views/sessions.test.ts | 5 +- 7 files changed, 224 insertions(+), 312 deletions(-) diff --git a/ui/src/ui/navigation.browser.test.ts b/ui/src/ui/navigation.browser.test.ts index bd47711dea5..046921f6b8a 100644 --- a/ui/src/ui/navigation.browser.test.ts +++ b/ui/src/ui/navigation.browser.test.ts @@ -39,72 +39,6 @@ function expectConfirmedGatewayChange(app: ReturnType) { } 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", - "", - "", - "", - "---", - "", - "*January 1, 2026*", - "", - "What Happened", - "1. Stable operator rule surfaced.", - "", - "", - ].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(".sidebar-version"); + const statusDot = app.querySelector(".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(".sidebar-version"); - const statusDot = app.querySelector(".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())); diff --git a/ui/src/ui/views/agents.test.ts b/ui/src/ui/views/agents.test.ts index 0735790ee7a..c48fb3bda09 100644 --- a/ui/src/ui/views/agents.test.ts +++ b/ui/src/ui/views/agents.test.ts @@ -144,15 +144,12 @@ describe("renderAgents", () => { ); await Promise.resolve(); - const skillsTab = Array.from(container.querySelectorAll(".agent-tab")).find( + let skillsTab = Array.from(container.querySelectorAll(".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(".agent-tab")).find( + skillsTab = Array.from(container.querySelectorAll(".agent-tab")).find( (button) => button.textContent?.includes("Skills"), ); diff --git a/ui/src/ui/views/chat.test.ts b/ui/src/ui/views/chat.test.ts index 1d548d62535..90eb7856974 100644 --- a/ui/src/ui/views/chat.test.ts +++ b/ui/src/ui/views/chat.test.ts @@ -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", () => { diff --git a/ui/src/ui/views/chat.ts b/ui/src/ui/views/chat.ts index e8ea6cc40ac..8a1e18698dd 100644 --- a/ui/src/ui/views/chat.ts +++ b/ui/src/ui/views/chat.ts @@ -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` -
+
- ${pct}% context used - ${formatTokensCompact(used)} / ${formatTokensCompact(limit)} + ${model.pct}% context used + ${model.detail}
`; } diff --git a/ui/src/ui/views/config.browser.test.ts b/ui/src/ui/views/config.browser.test.ts index 86e30008dee..63d88830ed2 100644 --- a/ui/src/ui/views/config.browser.test.ts +++ b/ui/src/ui/views/config.browser.test.ts @@ -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(".cfg-input"); - expect(input).not.toBeNull(); - expect(input?.placeholder).toBe("Structured value (SecretRef) - edit the config file directly"); + const rawUnavailableInput = container.querySelector(".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", () => { diff --git a/ui/src/ui/views/cron.test.ts b/ui/src/ui/views/cron.test.ts index 4e9b80d8d00..12a32a124ff 100644 --- a/ui/src/ui/views/cron.test.ts +++ b/ui/src/ui/views/cron.test.ts @@ -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( diff --git a/ui/src/ui/views/sessions.test.ts b/ui/src/ui/views/sessions.test.ts index 6ecbf1911b9..7d629c079a9 100644 --- a/ui/src/ui/views/sessions.test.ts +++ b/ui/src/ui/views/sessions.test.ts @@ -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(