mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-13 18:32:12 +03:00
fix(dashboard): canvas polish batch (#12392)
Seven pointwise fixes on the Orchestration Canvas, each covered by a test: 1. Debounce x chip race: every chip/clear write in OrchestrationToolbar now cancels the pending search timer first. Left armed, it fired ~300ms later with a setParams closed over the pre-chip query string and silently reverted the chip. 2. The search input carries an aria-label (searchPlaceholder) — the placeholder alone is not an accessible name. 3. parseCsvSet trims each token, so `?state=running, failed` parses like the unpadded form instead of dropping the padded value. 4. toggleCsv was duplicated in the toolbar and the page client; both now import the single definition from the new model/urlParams.ts (pure, never mutates its inputs). 5. AgentsTab tells "nothing running" apart from "the filter matched nothing": with an active filter and no work node it renders noMatches + a clear-filters button instead of the setup CTAs, which would be wrong advice there. 6. Particle cap: orchestrationToFlow stamps `particles` on every edge and turns it off above PARTICLE_EDGE_CAP (40) simultaneously active edges — StatusEdge then renders the colored stroke without its 3 SMIL particles per edge. 7. The drawer's error banner clears when an action succeeds, so a recovered failure does not stay on screen. Only `noMatches` is added to en.json here; the other locales are task B4. Refs #12392
This commit is contained in:
@@ -12,6 +12,7 @@ import { HistoryTab } from "./tabs/HistoryTab";
|
||||
import { OrchestrationDrawer } from "./drawer/OrchestrationDrawer";
|
||||
import { OrchestrationToolbar } from "./OrchestrationToolbar";
|
||||
import { collectProviderKeys, filterSnapshot } from "./model/filterSnapshot";
|
||||
import { parseCsvSet, toggleCsv } from "./model/urlParams";
|
||||
import type { OrchFilter } from "./model/filterSnapshot";
|
||||
import { ORCH_STATES } from "./model/orchestrationTypes";
|
||||
import type { OrchSource, OrchState } from "./model/orchestrationTypes";
|
||||
@@ -22,25 +23,6 @@ type Tab = (typeof TABS)[number];
|
||||
const VALID_STATES: ReadonlySet<OrchState> = new Set(ORCH_STATES);
|
||||
const VALID_SOURCES: ReadonlySet<OrchSource> = new Set(["cloud-agent", "a2a", "conductor"]);
|
||||
|
||||
/** CSV → Set, dropping empty/invalid entries (`valid` omitted accepts any non-empty token). */
|
||||
function parseCsvSet<T extends string>(raw: string | null, valid?: ReadonlySet<T>): Set<T> {
|
||||
const out = new Set<T>();
|
||||
if (!raw) return out;
|
||||
for (const v of raw.split(",")) {
|
||||
if (!v) continue;
|
||||
if (!valid || valid.has(v as T)) out.add(v as T);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Toggle `value` in `current`, returning the next CSV (or `null` to drop the param). */
|
||||
function toggleCsv<T extends string>(current: ReadonlySet<T>, value: T): string | null {
|
||||
const next = new Set(current);
|
||||
if (next.has(value)) next.delete(value);
|
||||
else next.add(value);
|
||||
return next.size > 0 ? [...next].sort().join(",") : null;
|
||||
}
|
||||
|
||||
const TAB_KEY: Record<Tab, string> = {
|
||||
agents: "tabAgents",
|
||||
routing: "tabRouting",
|
||||
@@ -133,6 +115,10 @@ export default function OrchestrationPageClient() {
|
||||
[collapsed, setParams]
|
||||
);
|
||||
const closeDrawer = useCallback(() => setParams({ node: null }), [setParams]);
|
||||
const clearFilters = useCallback(
|
||||
() => setParams({ q: null, state: null, source: null, provider: null }),
|
||||
[setParams]
|
||||
);
|
||||
|
||||
const selectedNode = nodeId ? (snapshot.nodes.find((n) => n.id === nodeId) ?? null) : null;
|
||||
const onNodeClick = (id: string) =>
|
||||
@@ -166,6 +152,8 @@ export default function OrchestrationPageClient() {
|
||||
onToggleCompleted={setShowCompleted}
|
||||
collapsed={collapsed}
|
||||
onToggleCollapse={onToggleCollapse}
|
||||
filter={filter}
|
||||
onClearFilters={clearFilters}
|
||||
/>
|
||||
)}
|
||||
{tab === "routing" && (
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { isEmptyFilter } from "./model/filterSnapshot";
|
||||
import { toggleCsv } from "./model/urlParams";
|
||||
import type { OrchFilter } from "./model/filterSnapshot";
|
||||
import { ORCH_STATES } from "./model/orchestrationTypes";
|
||||
import type { OrchSource, OrchState } from "./model/orchestrationTypes";
|
||||
@@ -30,14 +31,6 @@ const SOURCE_KEY: Record<(typeof SOURCES)[number], string> = {
|
||||
|
||||
const SEARCH_DEBOUNCE_MS = 300;
|
||||
|
||||
/** Toggle `value` in `current`, returning the next CSV (or `null` to drop the param). */
|
||||
function toggleCsv<T extends string>(current: ReadonlySet<T>, value: T): string | null {
|
||||
const next = new Set(current);
|
||||
if (next.has(value)) next.delete(value);
|
||||
else next.add(value);
|
||||
return next.size > 0 ? [...next].sort().join(",") : null;
|
||||
}
|
||||
|
||||
const chipClass = (active: boolean) =>
|
||||
`text-[10px] px-2 py-0.5 rounded-full border whitespace-nowrap ${
|
||||
active ? "border-primary bg-primary/10 text-primary" : "border-border text-muted"
|
||||
@@ -99,16 +92,31 @@ export function OrchestrationToolbar({
|
||||
[]
|
||||
);
|
||||
|
||||
const cancelPendingSearch = () => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
};
|
||||
|
||||
const handleSearchChange = (v: string) => {
|
||||
setText(v);
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
cancelPendingSearch();
|
||||
timerRef.current = setTimeout(() => setParams({ q: v || null }), SEARCH_DEBOUNCE_MS);
|
||||
};
|
||||
|
||||
/**
|
||||
* Every chip write goes through here so a debounce timer armed by a keystroke moments
|
||||
* earlier is dropped first. Left pending it would fire ~300ms later with a `setParams`
|
||||
* closed over the PRE-chip search params and silently revert the chip the user just
|
||||
* clicked (the writer rebuilds the whole query string from `params`).
|
||||
*/
|
||||
const patchParams = (patch: Record<string, string | null>) => {
|
||||
cancelPendingSearch();
|
||||
setParams(patch);
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
setText("");
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
setParams({ q: null, state: null, source: null, provider: null });
|
||||
patchParams({ q: null, state: null, source: null, provider: null });
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -118,6 +126,7 @@ export function OrchestrationToolbar({
|
||||
value={text}
|
||||
onChange={(e) => handleSearchChange(e.target.value)}
|
||||
placeholder={t("searchPlaceholder")}
|
||||
aria-label={t("searchPlaceholder")}
|
||||
className="text-xs px-2 py-1 rounded border border-border bg-transparent min-w-[160px]"
|
||||
/>
|
||||
<ChipGroup
|
||||
@@ -125,14 +134,14 @@ export function OrchestrationToolbar({
|
||||
values={ORCH_STATES}
|
||||
active={filter.states}
|
||||
renderLabel={(s) => t(STATE_KEY[s])}
|
||||
onToggle={(s) => setParams({ state: toggleCsv(filter.states, s) })}
|
||||
onToggle={(s) => patchParams({ state: toggleCsv(filter.states, s) })}
|
||||
/>
|
||||
<ChipGroup
|
||||
label={t("filterSources")}
|
||||
values={SOURCES}
|
||||
active={filter.sources}
|
||||
renderLabel={(s) => t(SOURCE_KEY[s])}
|
||||
onToggle={(s) => setParams({ source: toggleCsv(filter.sources, s) })}
|
||||
onToggle={(s) => patchParams({ source: toggleCsv(filter.sources, s) })}
|
||||
/>
|
||||
{providerKeys.length > 0 && (
|
||||
<ChipGroup
|
||||
@@ -140,7 +149,7 @@ export function OrchestrationToolbar({
|
||||
values={providerKeys}
|
||||
active={filter.providers}
|
||||
renderLabel={(p) => p}
|
||||
onToggle={(p) => setParams({ provider: toggleCsv(filter.providers, p) })}
|
||||
onToggle={(p) => patchParams({ provider: toggleCsv(filter.providers, p) })}
|
||||
/>
|
||||
)}
|
||||
{!isEmptyFilter(filter) && (
|
||||
|
||||
@@ -273,7 +273,8 @@ async function jsonRpcErrorCode(res: {
|
||||
|
||||
async function performAction(
|
||||
req: { url: string; init: RequestInit } | null,
|
||||
setActionError: (text: string) => void
|
||||
setActionError: (text: string) => void,
|
||||
clearError: () => void
|
||||
): Promise<boolean> {
|
||||
if (!req) return false;
|
||||
try {
|
||||
@@ -283,6 +284,10 @@ async function performAction(
|
||||
const code = await jsonRpcErrorCode(res);
|
||||
if (code !== undefined) throw new Error(`RPC ${code}`);
|
||||
}
|
||||
// The banner is not sticky: a retry (or any later action) that works clears whatever
|
||||
// detail/action error was on screen, so the drawer never shows a failure the operator
|
||||
// already recovered from.
|
||||
clearError();
|
||||
return true;
|
||||
} catch (err) {
|
||||
setActionError(toSafeErrorText(err));
|
||||
@@ -310,7 +315,7 @@ export function useDrawerDetail(node: OrchNode | null) {
|
||||
if (busy) return false;
|
||||
setBusy(true);
|
||||
try {
|
||||
return await performAction(req, setActionError);
|
||||
return await performAction(req, setActionError, () => setErrorState(null));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
|
||||
@@ -17,6 +17,13 @@ interface StatusEdgeData {
|
||||
state?: OrchState;
|
||||
active?: boolean;
|
||||
mirror?: boolean;
|
||||
/**
|
||||
* Particle budget gate, set by `orchestrationToFlow` (see `PARTICLE_EDGE_CAP`). `false`
|
||||
* means the canvas has too many simultaneously active edges to animate them all — the edge
|
||||
* still renders its active stroke, just without the 3 SMIL particles. Absent/`true` keeps
|
||||
* the animation (so a caller that never sets it behaves exactly as before).
|
||||
*/
|
||||
particles?: boolean;
|
||||
}
|
||||
|
||||
/** Mesma precedência do edgeStyle da v1: failed > active > succeeded > idle. */
|
||||
@@ -52,6 +59,7 @@ function StatusEdgeImpl(props: EdgeProps) {
|
||||
style={{ ...s, ...(data.mirror ? { strokeDasharray: "6 4" } : {}) }}
|
||||
/>
|
||||
{data.active &&
|
||||
data.particles !== false &&
|
||||
Array.from({ length: PARTICLES }, (_, i) => (
|
||||
<ellipse
|
||||
key={i}
|
||||
|
||||
@@ -11,6 +11,13 @@ const LAYER_Y: Record<OrchNodeKind, number> = {
|
||||
};
|
||||
const X_GAP = 260;
|
||||
|
||||
/**
|
||||
* Maximum simultaneously ACTIVE edges that still get the StatusEdge particle stream. Each
|
||||
* animated edge runs 3 SMIL `<animateMotion>` particles, so a busy canvas would otherwise pay
|
||||
* hundreds of concurrent animations; past the cap every edge renders as a plain colored stroke.
|
||||
*/
|
||||
export const PARTICLE_EDGE_CAP = 40;
|
||||
|
||||
export interface OrchestrationToFlowOptions {
|
||||
collapsed?: ReadonlySet<OrchSource>;
|
||||
}
|
||||
@@ -65,12 +72,18 @@ export function orchestrationToFlow(
|
||||
>,
|
||||
};
|
||||
});
|
||||
const particles = visibleEdges.filter((e) => e.active).length <= PARTICLE_EDGE_CAP;
|
||||
const edges: Edge[] = visibleEdges.map((e) => ({
|
||||
id: e.id,
|
||||
source: e.from,
|
||||
target: e.to,
|
||||
type: "status",
|
||||
data: { state: stateOf.get(e.to), active: e.active, mirror: e.kind === "mirror" },
|
||||
data: {
|
||||
state: stateOf.get(e.to),
|
||||
active: e.active,
|
||||
mirror: e.kind === "mirror",
|
||||
particles,
|
||||
},
|
||||
}));
|
||||
|
||||
const workIdsKey = visibleNodes
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* CSV query-param helpers shared by the Orchestration page client (which parses `?state=` /
|
||||
* `?source=` / `?provider=` / `?collapsed=` out of the URL) and the toolbar (which toggles them
|
||||
* back in). Both used to carry their own private copy of `toggleCsv`; a single definition keeps
|
||||
* the round-trip (parse → toggle → parse) consistent. Pure — never mutates its inputs.
|
||||
*/
|
||||
|
||||
/**
|
||||
* CSV → Set. Each token is trimmed and empty tokens are dropped, so a hand-edited or
|
||||
* shared URL like `?state=running, failed` parses the same as `?state=running,failed`.
|
||||
* With `valid`, tokens outside that set are discarded (an unknown state chip in the URL
|
||||
* must not survive into the filter).
|
||||
*/
|
||||
export function parseCsvSet<T extends string>(raw: string | null, valid?: ReadonlySet<T>): Set<T> {
|
||||
const out = new Set<T>();
|
||||
if (!raw) return out;
|
||||
for (const token of raw.split(",")) {
|
||||
const v = token.trim();
|
||||
if (!v) continue;
|
||||
if (!valid || valid.has(v as T)) out.add(v as T);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle `value` in `current`, returning the next CSV — or `null` when the list becomes
|
||||
* empty, so the caller's `setParams` drops the param from the URL instead of leaving `?state=`.
|
||||
*/
|
||||
export function toggleCsv<T extends string>(current: ReadonlySet<T>, value: T): string | null {
|
||||
const next = new Set(current);
|
||||
if (next.has(value)) next.delete(value);
|
||||
else next.add(value);
|
||||
return next.size > 0 ? [...next].sort().join(",") : null;
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import { useTranslations } from "next-intl";
|
||||
import Link from "next/link";
|
||||
import { FlowCanvas } from "@/shared/components/flow/FlowCanvas";
|
||||
import { orchestrationToFlow } from "../model/orchestrationToFlow";
|
||||
import { EMPTY_FILTER, isEmptyFilter } from "../model/filterSnapshot";
|
||||
import type { OrchFilter } from "../model/filterSnapshot";
|
||||
import type { OrchNode, OrchSnapshot, OrchSource } from "../model/orchestrationTypes";
|
||||
import { OrchestratorNode } from "../nodes/OrchestratorNode";
|
||||
import { SourceNode } from "../nodes/SourceNode";
|
||||
@@ -34,6 +36,8 @@ export function AgentsTab({
|
||||
onToggleCompleted,
|
||||
collapsed = EMPTY_COLLAPSED,
|
||||
onToggleCollapse,
|
||||
filter = EMPTY_FILTER,
|
||||
onClearFilters,
|
||||
}: {
|
||||
snapshot: OrchSnapshot;
|
||||
onNodeClick: (orchNodeId: string) => void;
|
||||
@@ -41,6 +45,8 @@ export function AgentsTab({
|
||||
onToggleCompleted: (v: boolean) => void;
|
||||
collapsed?: ReadonlySet<OrchSource>;
|
||||
onToggleCollapse?: (s: OrchSource) => void;
|
||||
filter?: OrchFilter;
|
||||
onClearFilters?: () => void;
|
||||
}) {
|
||||
const t = useTranslations("orchestration");
|
||||
const { nodes, edges, fitKey } = useMemo(
|
||||
@@ -58,6 +64,21 @@ export function AgentsTab({
|
||||
onNodeClick(node.id);
|
||||
};
|
||||
|
||||
// An empty canvas means two very different things. With no filter active it is "nothing is
|
||||
// running" and the setup CTAs are the right next step; under an ACTIVE filter the runs may
|
||||
// well exist and simply not match, so pointing the operator at the setup pages would be wrong
|
||||
// advice — offer to clear the filter instead.
|
||||
if (!hasWork && !isEmptyFilter(filter)) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-3 text-muted">
|
||||
<p className="text-sm">{t("noMatches")}</p>
|
||||
<button type="button" className="text-xs underline" onClick={() => onClearFilters?.()}>
|
||||
{t("clearFilters")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!hasWork) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-3 text-muted">
|
||||
|
||||
@@ -14021,6 +14021,7 @@
|
||||
"stateCancelled": "Cancelled",
|
||||
"overflowMore": "+{count} more",
|
||||
"emptyTitle": "Nothing running yet",
|
||||
"noMatches": "No runs match these filters",
|
||||
"emptyCloudAgentCta": "Set up Cloud Agents",
|
||||
"emptyA2ACta": "Open A2A",
|
||||
"emptyConductorCta": "Open Conductor",
|
||||
|
||||
@@ -339,6 +339,55 @@ describe("OrchestrationDrawer", () => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
// Task B3.7 — a stale error banner must not survive an action that then worked.
|
||||
it("clears the error banner when a retried action succeeds", async () => {
|
||||
let failNext = true;
|
||||
const fetchMock = vi.fn((_url: string, init?: RequestInit) => {
|
||||
if (init?.method === "POST") {
|
||||
const ok = !failNext;
|
||||
failNext = false;
|
||||
return Promise.resolve({
|
||||
ok,
|
||||
status: ok ? 200 : 500,
|
||||
json: () => Promise.resolve({}),
|
||||
});
|
||||
}
|
||||
return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({ data: {} }) });
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const node = {
|
||||
id: "cloud-agent:t1",
|
||||
kind: "work",
|
||||
source: "cloud-agent",
|
||||
state: "waiting_approval",
|
||||
label: "x",
|
||||
};
|
||||
const { c, cleanup } = render(
|
||||
<OrchestrationDrawer node={node as never} onClose={() => {}} onActionDone={() => {}} />
|
||||
);
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
const approve = () =>
|
||||
Array.from(c.querySelectorAll("button")).find((b) =>
|
||||
b.textContent?.includes("actionApprove")
|
||||
) as HTMLButtonElement;
|
||||
|
||||
await act(async () => {
|
||||
approve().click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(c.textContent).toContain("actionFailed");
|
||||
|
||||
await act(async () => {
|
||||
approve().click();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(c.textContent).not.toContain("actionFailed");
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("disables approve/cancel while an action promise is pending, and re-enables once it settles", async () => {
|
||||
let resolvePost: ((v: unknown) => void) | undefined;
|
||||
const fetchMock = vi.fn((_url: string, init?: RequestInit) => {
|
||||
|
||||
@@ -246,4 +246,33 @@ describe("StatusEdge", () => {
|
||||
expect(c.querySelectorAll("ellipse.orch-edge-particle").length).toBe(0);
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("data.particles === false renders the stroke only, even while active (particle cap)", () => {
|
||||
const props = {
|
||||
...baseProps,
|
||||
data: { state: "running", active: true, mirror: false, particles: false },
|
||||
};
|
||||
const { c, cleanup } = render(
|
||||
<svg>
|
||||
<StatusEdge {...(props as unknown as EdgeProps)} />
|
||||
</svg>
|
||||
);
|
||||
expect(c.querySelectorAll("ellipse.orch-edge-particle").length).toBe(0);
|
||||
expect(c.querySelector("path")).toBeTruthy();
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("data.particles === true keeps the particles (explicit opt-in from orchestrationToFlow)", () => {
|
||||
const props = {
|
||||
...baseProps,
|
||||
data: { state: "running", active: true, mirror: false, particles: true },
|
||||
};
|
||||
const { c, cleanup } = render(
|
||||
<svg>
|
||||
<StatusEdge {...(props as unknown as EdgeProps)} />
|
||||
</svg>
|
||||
);
|
||||
expect(c.querySelectorAll("ellipse.orch-edge-particle").length).toBe(3);
|
||||
cleanup();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -94,6 +94,13 @@ function render(el: React.ReactElement) {
|
||||
},
|
||||
};
|
||||
}
|
||||
/** Types into a controlled input the way React 19 sees it (native value setter + input event). */
|
||||
function typeInto(input: HTMLInputElement, value: string) {
|
||||
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")!.set!;
|
||||
setter.call(input, value);
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
replaceMock.mockClear();
|
||||
@@ -280,9 +287,7 @@ describe("OrchestrationPageClient", () => {
|
||||
searchState.current = "tab=agents&node=cloud-agent:1";
|
||||
const { c, cleanup } = render(<OrchestrationPageClient />);
|
||||
// Sanity: the page-level drawer is up before switching, open on the selected node.
|
||||
expect((drawerCalls.at(-1) as { node: { id: string } | null }).node?.id).toBe(
|
||||
"cloud-agent:1"
|
||||
);
|
||||
expect((drawerCalls.at(-1) as { node: { id: string } | null }).node?.id).toBe("cloud-agent:1");
|
||||
|
||||
const historyTabButton = Array.from(c.querySelectorAll('[role="tab"]')).find(
|
||||
(el) => el.textContent === "tabHistory"
|
||||
@@ -308,4 +313,94 @@ describe("OrchestrationPageClient", () => {
|
||||
expect(drawerCalls.length).toBe(drawerCallsBefore);
|
||||
cleanup();
|
||||
});
|
||||
|
||||
// Task B3.5 — AgentsTab can only tell "nothing running" from "filter matched nothing" if the
|
||||
// page hands it the parsed filter plus a way to clear it.
|
||||
it("hands AgentsTab the parsed filter and an onClearFilters that resets q/state/source/provider", () => {
|
||||
searchState.current = "tab=agents&q=login&state=running";
|
||||
const { cleanup } = render(<OrchestrationPageClient />);
|
||||
const props = agentsTabCalls.at(-1) as {
|
||||
filter: { q: string; states: ReadonlySet<string> };
|
||||
onClearFilters: () => void;
|
||||
};
|
||||
expect(props.filter.q).toBe("login");
|
||||
expect([...props.filter.states]).toEqual(["running"]);
|
||||
act(() => {
|
||||
props.onClearFilters();
|
||||
});
|
||||
expect(replaceMock).toHaveBeenCalledTimes(1);
|
||||
const [url] = replaceMock.mock.calls[0];
|
||||
expect(url).not.toContain("q=");
|
||||
expect(url).not.toContain("state=");
|
||||
cleanup();
|
||||
});
|
||||
|
||||
// Task B3.3 — a padded CSV param (`?state=running, failed`) must parse, not silently drop.
|
||||
it("parses CSV params with surrounding whitespace", () => {
|
||||
searchState.current = "tab=agents&state=running,%20failed";
|
||||
const { cleanup } = render(<OrchestrationPageClient />);
|
||||
const props = agentsTabCalls.at(-1) as { filter: { states: ReadonlySet<string> } };
|
||||
expect([...props.filter.states].sort()).toEqual(["failed", "running"]);
|
||||
cleanup();
|
||||
});
|
||||
|
||||
// Task B3.2 — the search box needs its own accessible name; the placeholder alone is not one.
|
||||
it("the search input carries an aria-label from i18n", () => {
|
||||
searchState.current = "tab=agents";
|
||||
const { c, cleanup } = render(<OrchestrationPageClient />);
|
||||
const input = c.querySelector('input[type="search"]') as HTMLInputElement;
|
||||
expect(input).toBeTruthy();
|
||||
expect(input.getAttribute("aria-label")).toBe("searchPlaceholder");
|
||||
cleanup();
|
||||
});
|
||||
|
||||
// Task B3.1 — typing then clicking a chip: the pending debounce timer must be dropped, or it
|
||||
// fires 300ms later against the pre-chip params and silently reverts the chip.
|
||||
it("clicking a chip cancels the pending search debounce instead of letting it overwrite the URL", () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
searchState.current = "tab=agents";
|
||||
const { c, cleanup } = render(<OrchestrationPageClient />);
|
||||
const input = c.querySelector('input[type="search"]') as HTMLInputElement;
|
||||
act(() => {
|
||||
typeInto(input, "log");
|
||||
});
|
||||
expect(replaceMock).toHaveBeenCalledTimes(0);
|
||||
|
||||
const runningChip = Array.from(c.querySelectorAll("button")).find(
|
||||
(el) => el.textContent === "stateRunning"
|
||||
) as HTMLButtonElement;
|
||||
act(() => {
|
||||
runningChip.click();
|
||||
});
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1000);
|
||||
});
|
||||
expect(replaceMock).toHaveBeenCalledTimes(1);
|
||||
expect(replaceMock.mock.calls[0][0]).toContain("state=running");
|
||||
cleanup();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("the debounced search still writes ?q= when no chip interrupts it", () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
searchState.current = "tab=agents";
|
||||
const { c, cleanup } = render(<OrchestrationPageClient />);
|
||||
const input = c.querySelector('input[type="search"]') as HTMLInputElement;
|
||||
act(() => {
|
||||
typeInto(input, "login");
|
||||
});
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1000);
|
||||
});
|
||||
expect(replaceMock).toHaveBeenCalledTimes(1);
|
||||
expect(replaceMock.mock.calls[0][0]).toContain("q=login");
|
||||
cleanup();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -212,6 +212,94 @@ describe("AgentsTab", () => {
|
||||
expect(onToggleCompleted).toHaveBeenCalledWith(true);
|
||||
cleanup();
|
||||
});
|
||||
|
||||
// Task B3.5 — an empty canvas under an ACTIVE filter is "your filter matched nothing",
|
||||
// not "you have nothing configured": the setup CTAs would be wrong advice there.
|
||||
const emptySnap = {
|
||||
nodes: [{ id: "orchestrator", kind: "orchestrator", label: "OmniRoute" }],
|
||||
edges: [],
|
||||
sources: [],
|
||||
generatedAt: "x",
|
||||
};
|
||||
const activeFilter = {
|
||||
q: "login",
|
||||
states: new Set<string>(),
|
||||
sources: new Set<string>(),
|
||||
providers: new Set<string>(),
|
||||
};
|
||||
|
||||
it("no work node AND an active filter → noMatches + a clear-filters button, no setup CTAs", () => {
|
||||
const onClearFilters = vi.fn();
|
||||
const { c, cleanup } = render(
|
||||
<AgentsTab
|
||||
snapshot={emptySnap as never}
|
||||
onNodeClick={() => {}}
|
||||
showCompleted={false}
|
||||
onToggleCompleted={() => {}}
|
||||
filter={activeFilter as never}
|
||||
onClearFilters={onClearFilters}
|
||||
/>
|
||||
);
|
||||
expect(c.textContent).toContain("noMatches");
|
||||
expect(c.textContent).not.toContain("emptyTitle");
|
||||
expect(c.textContent).not.toContain("emptyCloudAgentCta");
|
||||
const clearButton = Array.from(c.querySelectorAll("button")).find(
|
||||
(el) => el.textContent === "clearFilters"
|
||||
) as HTMLButtonElement;
|
||||
expect(clearButton).toBeTruthy();
|
||||
act(() => {
|
||||
clearButton.click();
|
||||
});
|
||||
expect(onClearFilters).toHaveBeenCalledTimes(1);
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("no work node and an EMPTY filter still shows the configuration CTAs", () => {
|
||||
const { c, cleanup } = render(
|
||||
<AgentsTab
|
||||
snapshot={emptySnap as never}
|
||||
onNodeClick={() => {}}
|
||||
showCompleted={false}
|
||||
onToggleCompleted={() => {}}
|
||||
filter={
|
||||
{
|
||||
q: "",
|
||||
states: new Set(),
|
||||
sources: new Set(),
|
||||
providers: new Set(),
|
||||
} as never
|
||||
}
|
||||
onClearFilters={() => {}}
|
||||
/>
|
||||
);
|
||||
expect(c.textContent).toContain("emptyTitle");
|
||||
expect(c.textContent).toContain("emptyCloudAgentCta");
|
||||
expect(c.textContent).not.toContain("noMatches");
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("an active filter that still matches work nodes renders the canvas, not the empty state", () => {
|
||||
const withWork = {
|
||||
...emptySnap,
|
||||
nodes: [
|
||||
...emptySnap.nodes,
|
||||
{ id: "a2a:1", kind: "work", source: "a2a", state: "running", label: "login flow" },
|
||||
],
|
||||
};
|
||||
const { c, cleanup } = render(
|
||||
<AgentsTab
|
||||
snapshot={withWork as never}
|
||||
onNodeClick={() => {}}
|
||||
showCompleted={false}
|
||||
onToggleCompleted={() => {}}
|
||||
filter={activeFilter as never}
|
||||
onClearFilters={() => {}}
|
||||
/>
|
||||
);
|
||||
expect(c.querySelector('[data-testid="flow-canvas"]')).toBeTruthy();
|
||||
expect(c.textContent).not.toContain("noMatches");
|
||||
cleanup();
|
||||
});
|
||||
});
|
||||
|
||||
describe("OverviewTab", () => {
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
/** Run: node --import tsx/esm --test tests/unit/ui/orchestrationToFlow.test.ts */
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { orchestrationToFlow } from "../../../src/app/(dashboard)/dashboard/orchestration/model/orchestrationToFlow.ts";
|
||||
import {
|
||||
PARTICLE_EDGE_CAP,
|
||||
orchestrationToFlow,
|
||||
} from "../../../src/app/(dashboard)/dashboard/orchestration/model/orchestrationToFlow.ts";
|
||||
import type { OrchSnapshot } from "../../../src/app/(dashboard)/dashboard/orchestration/model/orchestrationTypes.ts";
|
||||
|
||||
const snap: OrchSnapshot = {
|
||||
@@ -65,17 +68,27 @@ describe("orchestrationToFlow", () => {
|
||||
assert.equal(ys.get("source:a2a"), 150);
|
||||
assert.equal(ys.get("a2a:t1"), 320);
|
||||
});
|
||||
it('edges carry type "status" and data.{state,active,mirror}; no animated/style leak', () => {
|
||||
it('edges carry type "status" and data.{state,active,mirror,particles}; no animated/style leak', () => {
|
||||
const { edges } = orchestrationToFlow(snap);
|
||||
const activeEdge = edges.find((e) => e.id === "e2");
|
||||
assert.equal(activeEdge?.type, "status");
|
||||
assert.deepEqual(activeEdge?.data, { state: "running", active: true, mirror: false });
|
||||
assert.deepEqual(activeEdge?.data, {
|
||||
state: "running",
|
||||
active: true,
|
||||
mirror: false,
|
||||
particles: true,
|
||||
});
|
||||
assert.equal((activeEdge as { animated?: boolean }).animated, undefined);
|
||||
assert.equal((activeEdge as { style?: unknown }).style, undefined);
|
||||
|
||||
const edgeToFailed = edges.find((e) => e.id === "e3");
|
||||
assert.equal(edgeToFailed?.type, "status");
|
||||
assert.deepEqual(edgeToFailed?.data, { state: "failed", active: false, mirror: false });
|
||||
assert.deepEqual(edgeToFailed?.data, {
|
||||
state: "failed",
|
||||
active: false,
|
||||
mirror: false,
|
||||
particles: true,
|
||||
});
|
||||
});
|
||||
it("mirror edges carry data.mirror === true", () => {
|
||||
const mirrorSnap: OrchSnapshot = {
|
||||
@@ -146,3 +159,56 @@ describe("orchestrationToFlow", () => {
|
||||
assert.notEqual(k1, k2);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Particle cap (task B3.6): above PARTICLE_EDGE_CAP simultaneously active edges the canvas
|
||||
* would run 3 SMIL particles per edge, so `orchestrationToFlow` tells StatusEdge to render the
|
||||
* plain stroke instead (`data.particles === false`).
|
||||
*/
|
||||
function busySnapshot(activeEdges: number): OrchSnapshot {
|
||||
const nodes: OrchSnapshot["nodes"] = [
|
||||
{ id: "orchestrator", kind: "orchestrator", label: "OmniRoute" },
|
||||
{ id: "source:a2a", kind: "source", source: "a2a", label: "A2A" },
|
||||
];
|
||||
const edges: OrchSnapshot["edges"] = [];
|
||||
for (let i = 0; i < activeEdges; i++) {
|
||||
const id = `a2a:t${String(i).padStart(3, "0")}`;
|
||||
nodes.push({ id, kind: "work", source: "a2a", state: "running", label: id });
|
||||
edges.push({ id: `e${i}`, from: "source:a2a", to: id, kind: "owns", active: true });
|
||||
}
|
||||
return { nodes, edges, sources: [], generatedAt: "2026-09-07T00:00:00Z" };
|
||||
}
|
||||
|
||||
describe("orchestrationToFlow — particle cap", () => {
|
||||
it("PARTICLE_EDGE_CAP is 40", () => {
|
||||
assert.equal(PARTICLE_EDGE_CAP, 40);
|
||||
});
|
||||
|
||||
it("keeps particles on at exactly the cap", () => {
|
||||
const { edges } = orchestrationToFlow(busySnapshot(PARTICLE_EDGE_CAP));
|
||||
assert.equal(edges.length, PARTICLE_EDGE_CAP);
|
||||
assert.ok(edges.every((e) => (e.data as { particles?: boolean }).particles === true));
|
||||
});
|
||||
|
||||
it("turns particles off for every edge once the cap is exceeded", () => {
|
||||
const { edges } = orchestrationToFlow(busySnapshot(PARTICLE_EDGE_CAP + 1));
|
||||
assert.ok(edges.every((e) => (e.data as { particles?: boolean }).particles === false));
|
||||
});
|
||||
|
||||
it("counts only ACTIVE edges — 41 idle edges stay under the cap", () => {
|
||||
const snapWithIdle = busySnapshot(PARTICLE_EDGE_CAP + 1);
|
||||
const allIdle: OrchSnapshot = {
|
||||
...snapWithIdle,
|
||||
edges: snapWithIdle.edges.map((e) => ({ ...e, active: false })),
|
||||
};
|
||||
const { edges } = orchestrationToFlow(allIdle);
|
||||
assert.ok(edges.every((e) => (e.data as { particles?: boolean }).particles === true));
|
||||
});
|
||||
|
||||
it("counts VISIBLE active edges only — collapsing the source drops it back under the cap", () => {
|
||||
const { edges } = orchestrationToFlow(busySnapshot(PARTICLE_EDGE_CAP + 1), {
|
||||
collapsed: new Set(["a2a"]),
|
||||
});
|
||||
assert.equal(edges.length, 0);
|
||||
});
|
||||
});
|
||||
|
||||
61
tests/unit/ui/orchestrationUrlParams.test.ts
Normal file
61
tests/unit/ui/orchestrationUrlParams.test.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
/** Run: node --import tsx/esm --test tests/unit/ui/orchestrationUrlParams.test.ts */
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
parseCsvSet,
|
||||
toggleCsv,
|
||||
} from "../../../src/app/(dashboard)/dashboard/orchestration/model/urlParams.ts";
|
||||
|
||||
describe("parseCsvSet", () => {
|
||||
it("returns an empty set for null/empty input", () => {
|
||||
assert.deepEqual([...parseCsvSet(null)], []);
|
||||
assert.deepEqual([...parseCsvSet("")], []);
|
||||
});
|
||||
|
||||
it("trims each token so ' a2a, conductor ' parses like 'a2a,conductor'", () => {
|
||||
assert.deepEqual([...parseCsvSet(" a2a, conductor ")].sort(), ["a2a", "conductor"]);
|
||||
});
|
||||
|
||||
it("drops empty tokens produced by stray commas/whitespace", () => {
|
||||
assert.deepEqual([...parseCsvSet("a2a,, ,conductor,")].sort(), ["a2a", "conductor"]);
|
||||
});
|
||||
|
||||
it("drops values outside `valid` (after trimming, so a padded valid token survives)", () => {
|
||||
const valid = new Set(["running", "failed"]);
|
||||
assert.deepEqual([...parseCsvSet(" running , bogus ", valid)], ["running"]);
|
||||
});
|
||||
|
||||
it("accepts any non-empty token when `valid` is omitted", () => {
|
||||
assert.deepEqual([...parseCsvSet("devin, jules", undefined)].sort(), ["devin", "jules"]);
|
||||
});
|
||||
|
||||
it("does not mutate the `valid` set it is given", () => {
|
||||
const valid = new Set(["running"]);
|
||||
parseCsvSet("running,bogus", valid);
|
||||
assert.deepEqual([...valid], ["running"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("toggleCsv", () => {
|
||||
it("adds a missing value and returns a sorted CSV", () => {
|
||||
assert.equal(toggleCsv(new Set(["running"]), "failed"), "failed,running");
|
||||
});
|
||||
|
||||
it("removes a present value", () => {
|
||||
assert.equal(toggleCsv(new Set(["failed", "running"]), "failed"), "running");
|
||||
});
|
||||
|
||||
it("returns null when the list becomes empty (so the param leaves the URL)", () => {
|
||||
assert.equal(toggleCsv(new Set(["running"]), "running"), null);
|
||||
});
|
||||
|
||||
it("returns the single value when toggling into an empty set", () => {
|
||||
assert.equal(toggleCsv(new Set<string>(), "a2a"), "a2a");
|
||||
});
|
||||
|
||||
it("does not mutate the input set", () => {
|
||||
const current = new Set(["running"]);
|
||||
toggleCsv(current, "failed");
|
||||
assert.deepEqual([...current], ["running"]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user