mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 03:45:46 -06:00
f8ba65636c
* test(control-ui): add --operator-scopes flag to the mock dev server
* feat(control-ui): simplified settings experience for non-admin operators
Non-admin browsers previously saw every settings page, many of which
dead-ended or rendered enabled controls whose RPCs fail with
'missing scope: operator.admin'.
- config.schema drops from operator.admin to operator.read: the schema is a
static document describing options whose values are already readable via
read-scoped config.get; admin-only schema only broke read-only settings
rendering (Automation/Infrastructure/AI Agents/Communications showed
'Schema unavailable. Use Raw.').
- Settings sidebar and settings search hide admin-only routes (custodian,
labs, updates, automation, infrastructure, mcp, security, secrets,
cloud-workers, communications, ai-agents, model-setup) for non-admin
viewers; legacy gateways without advertised scopes keep the full UI.
- Channels, Devices, Worktrees, Memory Import, Profile gate their mutation
controls on actual scopes with 'Browsing only…' notices instead of
enabled-but-failing buttons; Devices no longer fires device.pair.list /
exec.approvals.get without the scopes to call them (kills the two red
error callouts on page load).
- Scope-upgrade banner: dismissing it in the guidance phase (no in-app
upgrade path) now hides it fully instead of leaving a permanent chip.
- Config write coordinator surfaces scope refusals as a visible
admin-required error instead of silently resolving false.
* test(control-ui): advertise config.schema in the mock dev gateway
ensureSchemaLoaded now checks method advertisement + scope before loading
the schema; the mock harness must advertise config.schema like a real
gateway does or schema-driven settings pages render empty in the mock.
* fix(control-ui): close the worktree create draft on scope downgrade
* perf(doctor): isolate memory health artifact
Doctor lint loaded the broad Memory Core API barrel only to register health checks and read isolated check IDs. That synchronously pulled the full memory public graph into the first lint run, consuming most of the 120-second test budget.
Load a dedicated doctor-health public artifact instead and verify it is packaged. The bisect boundary was 9de3ca5fc9 (#125571); because that commit only adds upgrade-test assets, it exposed a pre-existing runner-sensitive cost rather than introducing the expensive import path.
* test(control-ui): restore device lifecycle test boundary
* perf(control-ui): lazy-load settings sidebar
* fix(ui): recheck access after confirmations
* fix(control-ui): gate presence-driven device reloads on pairing access
The presence connectivity-change path still called device.pair.list without
operator.pairing, the same invariant the pair-event and poller paths already
guard; a limited browser got a doomed RPC on every connectivity change.
* fix(control-ui): fail open on schema loads for legacy scope-less gateways
canCallGatewayMethod hardened to strict advertisement+scope checks (#125478),
which made the new ensureSchemaLoaded gate silently skip config.schema for
legacy hellos without advertised scopes or a method list. Schema loads now
skip only on a definitive denial (method advertised absent, or advertised
scopes without operator.read), reusing the fail-open hasOperatorReadAccess
semantics the rest of the non-admin UI uses; regression test pins the
legacy snapshot path.
* test(control-ui): split schema-access coverage into its own file
runtime-config-capability.test.ts crossed the max-lines cap; the legacy
fail-open regression and its denial counterpart move to a colocated
schema-access test file.
* fix(scripts): keep mapped Vitest lanes at their measured no-output floor
The codex extension shard legitimately works in silence beyond 300s under
the default reporter (measured 61s import + 293s testing at ~95% CPU); the
CI-wide OPENCLAW_VITEST_NO_OUTPUT_TIMEOUT_MS=300000 env override shrank the
lane below that and the watchdog killed healthy runs, flipping with
incidental flake output (#125825). Per-config entries in
VITEST_CONFIG_NO_OUTPUT_TIMEOUT_MS now act as measured silence floors: a
global env value may widen a mapped lane's window but no longer shrinks it;
unmapped configs and the explicit '0' disable keep env verbatim. Adds the
codex extension lane to the map at the extra-long tier (same class as the
discord entry from #123025).
155 lines
6.2 KiB
TypeScript
155 lines
6.2 KiB
TypeScript
// Control UI tests cover sidebar entry customization behavior.
|
|
import { describe, expect, it } from "vitest";
|
|
import {
|
|
DEFAULT_SIDEBAR_ENTRIES,
|
|
SIDEBAR_NAV_ROUTES,
|
|
isSessionsHubRoute,
|
|
isSettingsNavigationRoute,
|
|
normalizeSidebarEntries,
|
|
parseSidebarEntry,
|
|
serializeSidebarEntry,
|
|
settingsNavigationOwnerRoute,
|
|
sidebarMoreRoutes,
|
|
visibleSettingsNavigationGroups,
|
|
} from "./app-navigation.ts";
|
|
import { readGatewayOperatorAccess } from "./app/operator-access.ts";
|
|
|
|
const settingsGroups = visibleSettingsNavigationGroups(true);
|
|
const settingsRoutes = settingsGroups.flatMap((group) => group.routes);
|
|
|
|
describe("sidebar entries", () => {
|
|
it("keeps operational destinations visible by default", () => {
|
|
expect(DEFAULT_SIDEBAR_ENTRIES).toEqual(["route:cron", "route:plugins"]);
|
|
});
|
|
|
|
it("drops retired routes from persisted entries", () => {
|
|
expect(normalizeSidebarEntries(["route:overview", "route:usage"])).toEqual(["route:usage"]);
|
|
});
|
|
|
|
it("treats worktrees as a sessions hub tab without its own pin", () => {
|
|
expect(isSessionsHubRoute("sessions")).toBe(true);
|
|
expect(isSessionsHubRoute("worktrees")).toBe(true);
|
|
expect(isSessionsHubRoute("chat")).toBe(false);
|
|
expect(normalizeSidebarEntries(["route:worktrees", "route:usage"])).toEqual(["route:usage"]);
|
|
});
|
|
|
|
it("preserves the shipped Workboard placement slot outside customizable routes", () => {
|
|
expect(normalizeSidebarEntries(["route:workboard", "workboard:ops"])).toEqual([
|
|
"route:workboard",
|
|
"workboard:ops",
|
|
]);
|
|
expect(sidebarMoreRoutes([])).not.toContain("workboard");
|
|
});
|
|
|
|
it("recognizes every settings navigation route", () => {
|
|
expect(settingsRoutes.every((routeId) => isSettingsNavigationRoute(routeId))).toBe(true);
|
|
});
|
|
|
|
it("places Updates in the System group immediately before About", () => {
|
|
const system = settingsGroups.find((group) => group.labelKey === "nav.settingsGroupSystem");
|
|
expect(system?.routes.slice(-2)).toEqual(["updates", "about"]);
|
|
});
|
|
|
|
it("places team secrets between Privacy & Security and Approvals", () => {
|
|
const security = settingsGroups.find((group) => group.labelKey === "nav.settingsGroupSecurity");
|
|
expect(security?.routes).toEqual(["security", "secrets", "approvals"]);
|
|
});
|
|
|
|
it("keeps model setup as a settings subpage without a sidebar entry", () => {
|
|
expect(isSettingsNavigationRoute("model-setup")).toBe(true);
|
|
expect(settingsNavigationOwnerRoute("model-setup")).toBe("model-providers");
|
|
});
|
|
|
|
it("keeps Agent Defaults routed as an Agents subpage without a sidebar entry", () => {
|
|
expect(isSettingsNavigationRoute("ai-agents")).toBe(true);
|
|
expect(settingsNavigationOwnerRoute("ai-agents")).toBe("agents");
|
|
});
|
|
|
|
it("filters admin-only settings while preserving legacy fail-open visibility", () => {
|
|
const nonAdminRoutes = visibleSettingsNavigationGroups(false).flatMap((group) => group.routes);
|
|
expect(nonAdminRoutes).toContain("approvals");
|
|
expect(nonAdminRoutes).toContain("channels");
|
|
expect(nonAdminRoutes).not.toContain("security");
|
|
expect(nonAdminRoutes).not.toContain("communications");
|
|
|
|
const legacyCanAdmin = readGatewayOperatorAccess({
|
|
hello: { auth: { role: "operator" } },
|
|
} as Parameters<typeof readGatewayOperatorAccess>[0]).canAdmin;
|
|
expect(legacyCanAdmin).toBe(true);
|
|
expect(visibleSettingsNavigationGroups(legacyCanAdmin)).toEqual(
|
|
visibleSettingsNavigationGroups(true),
|
|
);
|
|
});
|
|
|
|
it("drops stale device pins", () => {
|
|
expect(normalizeSidebarEntries(["route:nodes", "route:usage"])).toEqual(["route:usage"]);
|
|
});
|
|
|
|
it("keeps the apps promo page available in More", () => {
|
|
expect(sidebarMoreRoutes(DEFAULT_SIDEBAR_ENTRIES)).toContain("apps");
|
|
expect(isSettingsNavigationRoute("apps")).toBe(false);
|
|
});
|
|
|
|
it("keeps Portals available in More", () => {
|
|
expect(sidebarMoreRoutes(DEFAULT_SIDEBAR_ENTRIES)).toContain("portals");
|
|
expect(isSettingsNavigationRoute("portals")).toBe(false);
|
|
});
|
|
|
|
it("keeps the plugin manager in customizable workspace routes", () => {
|
|
expect(normalizeSidebarEntries(["route:plugins", "route:usage", "route:plugins"])).toEqual([
|
|
"route:plugins",
|
|
"route:usage",
|
|
]);
|
|
expect(sidebarMoreRoutes(["route:usage", "session:agent:main:test"])).toContain("plugins");
|
|
});
|
|
|
|
it("round-trips route, Workboard, and session entries", () => {
|
|
expect(parseSidebarEntry("route:usage")).toEqual({ type: "route", route: "usage" });
|
|
expect(parseSidebarEntry("session:agent:main:test")).toEqual({
|
|
type: "session",
|
|
key: "agent:main:test",
|
|
});
|
|
expect(parseSidebarEntry("workboard:ops")).toEqual({ type: "workboard", boardId: "ops" });
|
|
expect(serializeSidebarEntry({ type: "route", route: "plugins" })).toBe("route:plugins");
|
|
expect(serializeSidebarEntry({ type: "session", key: "agent:main:test" })).toBe(
|
|
"session:agent:main:test",
|
|
);
|
|
expect(serializeSidebarEntry({ type: "workboard", boardId: "ops" })).toBe("workboard:ops");
|
|
});
|
|
|
|
it("normalizes persisted entries, dropping malformed and duplicate values", () => {
|
|
expect(
|
|
normalizeSidebarEntries([
|
|
"route:usage",
|
|
"session:agent:main:test",
|
|
"route:tasks",
|
|
"route:usage",
|
|
"route:worktrees",
|
|
"session:",
|
|
"usage",
|
|
7,
|
|
]),
|
|
).toEqual(["route:usage", "session:agent:main:test", "route:tasks"]);
|
|
expect(normalizeSidebarEntries([])).toEqual([]);
|
|
});
|
|
|
|
it("recognizes OpenClaw settings and drops stale sidebar pins", () => {
|
|
expect(isSettingsNavigationRoute("custodian")).toBe(true);
|
|
expect(normalizeSidebarEntries(["route:custodian", "route:usage"])).toEqual(["route:usage"]);
|
|
});
|
|
|
|
it("falls back to null for non-list values so callers use defaults", () => {
|
|
expect(normalizeSidebarEntries(undefined)).toBeNull();
|
|
expect(normalizeSidebarEntries({ usage: true })).toBeNull();
|
|
expect(normalizeSidebarEntries("route:usage")).toBeNull();
|
|
});
|
|
|
|
it("puts every hidden nav route into the More section", () => {
|
|
const entries = ["route:tasks", "session:agent:main:test", "route:usage"] as const;
|
|
const more = sidebarMoreRoutes(entries);
|
|
expect(more).not.toContain("tasks");
|
|
expect(more).not.toContain("usage");
|
|
expect(new Set(["tasks", "usage", ...more])).toEqual(new Set(SIDEBAR_NAV_ROUTES));
|
|
});
|
|
});
|