fix(FN-2444): polish node dashboard UX and discovery core handling
- Update node management UI components to use shared form/button classes, semantic color tokens, and accessible interactions - Refine mesh topology rendering and related dashboard tests for improved readability and behavior coverage - Adjust discovery routes to reuse injected centralCore instances without unnecessary init/close cycles - Add/extend tests for shared CentralCore discovery flows and model settings scope save behavior
This commit is contained in:
@@ -137,6 +137,7 @@ export function AddNodeModal({ isOpen, onClose, onSubmit, addToast }: AddNodeMod
|
||||
<label className="add-node-modal__field">
|
||||
<span>Name</span>
|
||||
<input
|
||||
className="input"
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
@@ -145,7 +146,7 @@ export function AddNodeModal({ isOpen, onClose, onSubmit, addToast }: AddNodeMod
|
||||
aria-invalid={Boolean(errors.name)}
|
||||
autoFocus
|
||||
/>
|
||||
{errors.name && <span className="add-node-modal__error">{errors.name}</span>}
|
||||
{errors.name && <span className="form-error add-node-modal__error">{errors.name}</span>}
|
||||
</label>
|
||||
|
||||
<div className="add-node-modal__type-toggle">
|
||||
@@ -175,6 +176,7 @@ export function AddNodeModal({ isOpen, onClose, onSubmit, addToast }: AddNodeMod
|
||||
<label className="add-node-modal__field">
|
||||
<span>URL</span>
|
||||
<input
|
||||
className="input"
|
||||
type="text"
|
||||
value={url}
|
||||
onChange={(event) => setUrl(event.target.value)}
|
||||
@@ -182,12 +184,13 @@ export function AddNodeModal({ isOpen, onClose, onSubmit, addToast }: AddNodeMod
|
||||
disabled={isSubmitting}
|
||||
aria-invalid={Boolean(errors.url)}
|
||||
/>
|
||||
{errors.url && <span className="add-node-modal__error">{errors.url}</span>}
|
||||
{errors.url && <span className="form-error add-node-modal__error">{errors.url}</span>}
|
||||
</label>
|
||||
|
||||
<label className="add-node-modal__field">
|
||||
<span>API Key</span>
|
||||
<input
|
||||
className="input"
|
||||
type="password"
|
||||
value={apiKey}
|
||||
onChange={(event) => setApiKey(event.target.value)}
|
||||
@@ -200,6 +203,7 @@ export function AddNodeModal({ isOpen, onClose, onSubmit, addToast }: AddNodeMod
|
||||
<label className="add-node-modal__field">
|
||||
<span>Max Concurrent</span>
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
min={MAX_CONCURRENT_MIN}
|
||||
max={MAX_CONCURRENT_MAX}
|
||||
@@ -209,7 +213,7 @@ export function AddNodeModal({ isOpen, onClose, onSubmit, addToast }: AddNodeMod
|
||||
aria-invalid={Boolean(errors.maxConcurrent)}
|
||||
/>
|
||||
<span className="add-node-modal__hint">Max simultaneous task agents (1–10)</span>
|
||||
{errors.maxConcurrent && <span className="add-node-modal__error">{errors.maxConcurrent}</span>}
|
||||
{errors.maxConcurrent && <span className="form-error add-node-modal__error">{errors.maxConcurrent}</span>}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -179,9 +179,11 @@ export function ConnectNodeModal({ open, onClose, onConnected, addToast, onSubmi
|
||||
</div>
|
||||
|
||||
<div className="modal-body connect-node-form">
|
||||
<label className="connect-node-field">
|
||||
<span>Node Name</span>
|
||||
<div className="form-group connect-node-field">
|
||||
<label htmlFor="connect-node-name">Node Name</label>
|
||||
<input
|
||||
id="connect-node-name"
|
||||
className="input connect-node-field__input"
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
@@ -190,11 +192,13 @@ export function ConnectNodeModal({ open, onClose, onConnected, addToast, onSubmi
|
||||
aria-invalid={Boolean(errors.name)}
|
||||
/>
|
||||
{errors.name && <span className="form-error">{errors.name}</span>}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label className="connect-node-field">
|
||||
<span>Host / IP Address</span>
|
||||
<div className="form-group connect-node-field">
|
||||
<label htmlFor="connect-node-host">Host / IP Address</label>
|
||||
<input
|
||||
id="connect-node-host"
|
||||
className="input connect-node-field__input"
|
||||
type="text"
|
||||
value={host}
|
||||
onChange={(event) => setHost(event.target.value)}
|
||||
@@ -203,11 +207,13 @@ export function ConnectNodeModal({ open, onClose, onConnected, addToast, onSubmi
|
||||
aria-invalid={Boolean(errors.host)}
|
||||
/>
|
||||
{errors.host && <span className="form-error">{errors.host}</span>}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label className="connect-node-field">
|
||||
<span>Port</span>
|
||||
<div className="form-group connect-node-field">
|
||||
<label htmlFor="connect-node-port">Port</label>
|
||||
<input
|
||||
id="connect-node-port"
|
||||
className="input connect-node-field__input"
|
||||
type="number"
|
||||
value={port}
|
||||
onChange={(event) => setPort(event.target.value)}
|
||||
@@ -217,7 +223,7 @@ export function ConnectNodeModal({ open, onClose, onConnected, addToast, onSubmi
|
||||
aria-invalid={Boolean(errors.port)}
|
||||
/>
|
||||
{errors.port && <span className="form-error">{errors.port}</span>}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{constructedUrl && (
|
||||
<div className="connect-node-url-preview">
|
||||
@@ -226,20 +232,24 @@ export function ConnectNodeModal({ open, onClose, onConnected, addToast, onSubmi
|
||||
</div>
|
||||
)}
|
||||
|
||||
<label className="connect-node-field">
|
||||
<span>Auth Key</span>
|
||||
<div className="form-group connect-node-field">
|
||||
<label htmlFor="connect-node-auth-key">Auth Key</label>
|
||||
<input
|
||||
id="connect-node-auth-key"
|
||||
className="input connect-node-field__input"
|
||||
type="password"
|
||||
value={apiKey}
|
||||
onChange={(event) => setApiKey(event.target.value)}
|
||||
placeholder="Optional"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label className="connect-node-field">
|
||||
<span>Max Concurrent</span>
|
||||
<div className="form-group connect-node-field">
|
||||
<label htmlFor="connect-node-max-concurrent">Max Concurrent</label>
|
||||
<input
|
||||
id="connect-node-max-concurrent"
|
||||
className="input connect-node-field__input"
|
||||
type="number"
|
||||
value={maxConcurrent}
|
||||
onChange={(event) => setMaxConcurrent(Number(event.target.value))}
|
||||
@@ -249,7 +259,7 @@ export function ConnectNodeModal({ open, onClose, onConnected, addToast, onSubmi
|
||||
aria-invalid={Boolean(errors.maxConcurrent)}
|
||||
/>
|
||||
{errors.maxConcurrent && <span className="form-error">{errors.maxConcurrent}</span>}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="modal-actions connect-node-actions">
|
||||
|
||||
@@ -57,23 +57,6 @@ function MeshTopologyInner({ nodes, className }: MeshTopologyProps): ReactElemen
|
||||
});
|
||||
}, [remoteNodes, viewBoxSize, centerX, centerY]);
|
||||
|
||||
// Build peer awareness lines (lines between remote nodes that share knowledge)
|
||||
const peerLines = useMemo(() => {
|
||||
// Simple heuristic: show lines between remote nodes that share knowledge
|
||||
const lines: Array<{ x1: number; y1: number; x2: number; y2: number }> = [];
|
||||
|
||||
// Only draw peer lines if we have more than 2 remote nodes
|
||||
if (remotePositions.length > 2) {
|
||||
remotePositions.forEach((rp, index) => {
|
||||
// Draw a subtle line to one other node for visual interest
|
||||
if (index % 2 === 0 && index + 1 < remotePositions.length) {
|
||||
const other = remotePositions[index + 1];
|
||||
lines.push({ x1: rp.x, y1: rp.y, x2: other.x, y2: other.y });
|
||||
}
|
||||
});
|
||||
}
|
||||
return lines;
|
||||
}, [remotePositions]);
|
||||
|
||||
if (nodes.length === 0) {
|
||||
return (
|
||||
@@ -93,18 +76,6 @@ function MeshTopologyInner({ nodes, className }: MeshTopologyProps): ReactElemen
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
aria-label="Node mesh topology visualization"
|
||||
>
|
||||
{/* Peer awareness lines between remote nodes */}
|
||||
{peerLines.map((line, index) => (
|
||||
<line
|
||||
key={`peer-${index}`}
|
||||
className="mesh-topology__peer-line"
|
||||
x1={line.x1}
|
||||
y1={line.y1}
|
||||
x2={line.x2}
|
||||
y2={line.y2}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Lines from local node to remote nodes */}
|
||||
{remotePositions.map((rp) => (
|
||||
<line
|
||||
@@ -133,13 +104,16 @@ function MeshTopologyInner({ nodes, className }: MeshTopologyProps): ReactElemen
|
||||
>
|
||||
{localNode.name.length > 12 ? `${localNode.name.slice(0, 10)}…` : localNode.name}
|
||||
</text>
|
||||
<text
|
||||
className="mesh-topology__node-type"
|
||||
y={-NODE_RADIUS - 4}
|
||||
textAnchor="middle"
|
||||
>
|
||||
{localNode.type === "local" ? "🏠" : "🌐"}
|
||||
</text>
|
||||
<g className="mesh-topology__node-type" transform={`translate(0 ${-NODE_RADIUS - 10})`}>
|
||||
<circle className="mesh-topology__node-type-badge" r="8" />
|
||||
<text
|
||||
className="mesh-topology__node-type-text"
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
>
|
||||
{localNode.type === "local" ? "L" : "R"}
|
||||
</text>
|
||||
</g>
|
||||
</g>
|
||||
)}
|
||||
|
||||
@@ -159,13 +133,16 @@ function MeshTopologyInner({ nodes, className }: MeshTopologyProps): ReactElemen
|
||||
>
|
||||
{rp.node.name.length > 12 ? `${rp.node.name.slice(0, 10)}…` : rp.node.name}
|
||||
</text>
|
||||
<text
|
||||
className="mesh-topology__node-type"
|
||||
y={-NODE_RADIUS - 4}
|
||||
textAnchor="middle"
|
||||
>
|
||||
🌐
|
||||
</text>
|
||||
<g className="mesh-topology__node-type" transform={`translate(0 ${-NODE_RADIUS - 10})`}>
|
||||
<circle className="mesh-topology__node-type-badge" r="8" />
|
||||
<text
|
||||
className="mesh-topology__node-type-text"
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
>
|
||||
{rp.node.type === "local" ? "L" : "R"}
|
||||
</text>
|
||||
</g>
|
||||
</g>
|
||||
))}
|
||||
</svg>
|
||||
@@ -189,6 +166,7 @@ function MeshTopologyInner({ nodes, className }: MeshTopologyProps): ReactElemen
|
||||
<span>Error</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mesh-topology__notice">Peer-to-peer discovery data unavailable.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,15 +20,15 @@ export interface NodeCardProps {
|
||||
}
|
||||
|
||||
const STATUS_CONFIG: Record<NodeInfo["status"], { label: string; color: string; className: string }> = {
|
||||
online: { label: "Online", color: "var(--success)", className: "node-card__status--online" },
|
||||
online: { label: "Online", color: "var(--color-success)", className: "node-card__status--online" },
|
||||
offline: { label: "Offline", color: "var(--color-error)", className: "node-card__status--offline" },
|
||||
connecting: { label: "Connecting", color: "var(--warning)", className: "node-card__status--connecting" },
|
||||
connecting: { label: "Connecting", color: "var(--color-warning)", className: "node-card__status--connecting" },
|
||||
error: { label: "Error", color: "var(--color-error)", className: "node-card__status--error" },
|
||||
};
|
||||
|
||||
const AUTH_SYNC_COLORS: Record<string, string> = {
|
||||
match: "var(--success)",
|
||||
differs: "var(--warning)",
|
||||
match: "var(--color-success)",
|
||||
differs: "var(--color-warning)",
|
||||
"not-synced": "var(--text-muted)",
|
||||
};
|
||||
|
||||
@@ -233,7 +233,7 @@ function NodeCardInner({
|
||||
|
||||
<footer className="node-card__actions">
|
||||
<button
|
||||
className="node-card__action"
|
||||
className="btn btn-sm node-card__action"
|
||||
type="button"
|
||||
onClick={handleHealthCheck}
|
||||
disabled={isLoading}
|
||||
@@ -245,7 +245,7 @@ function NodeCardInner({
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="node-card__action"
|
||||
className="btn btn-sm node-card__action"
|
||||
type="button"
|
||||
onClick={handleEdit}
|
||||
disabled={isLoading}
|
||||
@@ -257,7 +257,7 @@ function NodeCardInner({
|
||||
</button>
|
||||
|
||||
<button
|
||||
className={`node-card__action node-card__action--remove ${removeArmed ? "is-armed" : ""}`}
|
||||
className={`btn btn-sm node-card__action node-card__action--remove ${removeArmed ? "btn-danger is-armed" : ""}`}
|
||||
type="button"
|
||||
onClick={handleRemove}
|
||||
disabled={isLoading}
|
||||
|
||||
@@ -263,7 +263,7 @@ export function NodeDetailModal({
|
||||
<label className="node-detail-modal__field">
|
||||
<span>Name</span>
|
||||
{editMode ? (
|
||||
<input value={name} onChange={(event) => setName(event.target.value)} disabled={isSaving} />
|
||||
<input className="input" value={name} onChange={(event) => setName(event.target.value)} disabled={isSaving} />
|
||||
) : (
|
||||
<strong>{node.name}</strong>
|
||||
)}
|
||||
@@ -283,6 +283,7 @@ export function NodeDetailModal({
|
||||
<span>Max Concurrent</span>
|
||||
{editMode ? (
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
min={1}
|
||||
max={10}
|
||||
@@ -300,7 +301,7 @@ export function NodeDetailModal({
|
||||
<label className="node-detail-modal__field node-detail-modal__field--full">
|
||||
<span>URL</span>
|
||||
{editMode ? (
|
||||
<input value={url} onChange={(event) => setUrl(event.target.value)} disabled={isSaving} />
|
||||
<input className="input" value={url} onChange={(event) => setUrl(event.target.value)} disabled={isSaving} />
|
||||
) : (
|
||||
<strong>{node.url ?? "—"}</strong>
|
||||
)}
|
||||
@@ -310,6 +311,7 @@ export function NodeDetailModal({
|
||||
<span>API Key</span>
|
||||
{editMode ? (
|
||||
<input
|
||||
className="input"
|
||||
type="password"
|
||||
value={apiKey}
|
||||
onChange={(event) => setApiKey(event.target.value)}
|
||||
|
||||
@@ -114,31 +114,21 @@ export function SettingsSyncLog({
|
||||
|
||||
return (
|
||||
<div className="settings-sync-log">
|
||||
<div
|
||||
<button
|
||||
className="settings-sync-log__header"
|
||||
type="button"
|
||||
onClick={handleToggle}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-expanded={isExpanded}
|
||||
data-testid="settings-sync-log-header"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
handleToggle();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ChevronDown
|
||||
size={16}
|
||||
style={{
|
||||
transform: isExpanded ? "rotate(180deg)" : "rotate(0deg)",
|
||||
transition: "transform 0.15s ease",
|
||||
}}
|
||||
className={`settings-sync-log__chevron ${isExpanded ? "settings-sync-log__chevron--expanded" : ""}`}
|
||||
/>
|
||||
<span>
|
||||
{entries.length} {entries.length === 1 ? "entry" : "entries"}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<>
|
||||
|
||||
@@ -64,6 +64,20 @@ describe("MeshTopology", () => {
|
||||
expect(links).toHaveLength(1); // One line from local to remote
|
||||
});
|
||||
|
||||
it("does not render fabricated peer links between remote nodes", () => {
|
||||
const nodes = [
|
||||
makeNode({ id: "local", name: "Local", type: "local" }),
|
||||
makeNode({ id: "remote-1", name: "Remote 1", type: "remote" }),
|
||||
makeNode({ id: "remote-2", name: "Remote 2", type: "remote" }),
|
||||
makeNode({ id: "remote-3", name: "Remote 3", type: "remote" }),
|
||||
];
|
||||
|
||||
render(<MeshTopology nodes={nodes} />);
|
||||
|
||||
expect(document.querySelectorAll(".mesh-topology__peer-line")).toHaveLength(0);
|
||||
expect(screen.getByText("Peer-to-peer discovery data unavailable.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders legend with status colors", () => {
|
||||
render(<MeshTopology nodes={[makeNode()]} />);
|
||||
|
||||
@@ -123,6 +137,19 @@ describe("MeshTopology", () => {
|
||||
expect(circles[0]).toHaveAttribute("fill", expect.stringContaining("var(--color-error"));
|
||||
});
|
||||
|
||||
it("uses consistent node type badges instead of emoji glyphs", () => {
|
||||
const nodes = [
|
||||
makeNode({ id: "local", name: "Local", type: "local" }),
|
||||
makeNode({ id: "remote", name: "Remote", type: "remote" }),
|
||||
];
|
||||
render(<MeshTopology nodes={nodes} />);
|
||||
|
||||
const typeBadges = document.querySelectorAll(".mesh-topology__node-type-badge");
|
||||
expect(typeBadges).toHaveLength(2);
|
||||
expect(screen.queryByText("🏠")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("🌐")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders correct status color for connecting nodes", () => {
|
||||
const nodes = [makeNode({ id: "connecting", name: "Connecting Node", status: "connecting" })];
|
||||
render(<MeshTopology nodes={nodes} />);
|
||||
|
||||
@@ -1063,11 +1063,11 @@ describe("SettingsModal", () => {
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
// Click on "Models" (global models section)
|
||||
fireEvent.click(screen.getByText("Models"));
|
||||
fireEvent.click((await screen.findAllByText("Models"))[0]);
|
||||
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
|
||||
|
||||
// Select a model
|
||||
const trigger = screen.getByLabelText("Default Model");
|
||||
const trigger = await screen.findByLabelText("Default Model");
|
||||
await user.click(trigger);
|
||||
await user.click(screen.getByText("Claude Sonnet 4.5"));
|
||||
|
||||
@@ -1347,11 +1347,11 @@ describe("SettingsModal", () => {
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
// Models section has default model dropdown
|
||||
fireEvent.click(screen.getByText("Models"));
|
||||
fireEvent.click((await screen.findAllByText("Models"))[0]);
|
||||
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
|
||||
|
||||
// Open dropdown and select "Use default"
|
||||
const trigger = screen.getByLabelText("Default Model");
|
||||
const trigger = await screen.findByLabelText("Default Model");
|
||||
await user.click(trigger);
|
||||
|
||||
// Find and click the "Use default" option in the dropdown
|
||||
@@ -2341,7 +2341,7 @@ describe("SettingsModal", () => {
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
fireEvent.click(screen.getByText("Notifications"));
|
||||
fireEvent.click((await screen.findAllByText("Notifications"))[0]);
|
||||
const checkbox = screen.getByLabelText("Enable ntfy.sh notifications");
|
||||
expect(checkbox).toBeTruthy();
|
||||
expect(checkbox.getAttribute("type")).toBe("checkbox");
|
||||
@@ -2606,7 +2606,7 @@ describe("SettingsModal", () => {
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
fireEvent.click(screen.getByText("Notifications"));
|
||||
fireEvent.click((await screen.findAllByText("Notifications"))[0]);
|
||||
expect((screen.getByLabelText("Task completed (in-review)") as HTMLInputElement).checked).toBe(true);
|
||||
expect((screen.getByLabelText("Task merged") as HTMLInputElement).checked).toBe(true);
|
||||
expect((screen.getByLabelText("Task failed") as HTMLInputElement).checked).toBe(true);
|
||||
@@ -2656,7 +2656,7 @@ describe("SettingsModal", () => {
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
fireEvent.click(screen.getByText("Notifications"));
|
||||
fireEvent.click((await screen.findAllByText("Notifications"))[0]);
|
||||
|
||||
// Uncheck all six
|
||||
fireEvent.click(screen.getByLabelText("Task completed (in-review)"));
|
||||
@@ -2726,7 +2726,7 @@ describe("SettingsModal", () => {
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
fireEvent.click(screen.getByText("Node Sync"));
|
||||
fireEvent.click((await screen.findAllByText("Node Sync"))[0]);
|
||||
expect(screen.getByLabelText("Enable automatic settings sync")).toBeTruthy();
|
||||
expect(screen.getByLabelText("Sync model auth credentials")).toBeTruthy();
|
||||
expect(screen.getByLabelText("Sync interval")).toBeTruthy();
|
||||
@@ -3582,7 +3582,7 @@ describe("Prompts section", () => {
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
fireEvent.click(screen.getAllByText("Prompts")[0]);
|
||||
fireEvent.click((await screen.findAllByText("Prompts"))[0]);
|
||||
|
||||
// Templates tab should be active by default
|
||||
expect(screen.getByTestId("tab-templates")).toHaveClass(/active/);
|
||||
@@ -3612,7 +3612,7 @@ describe("Prompts section", () => {
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
fireEvent.click(screen.getAllByText("Prompts")[0]);
|
||||
fireEvent.click((await screen.findAllByText("Prompts"))[0]);
|
||||
|
||||
// Click Overrides tab
|
||||
fireEvent.click(screen.getByTestId("tab-overrides"));
|
||||
@@ -3626,7 +3626,7 @@ describe("Prompts section", () => {
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
fireEvent.click(screen.getAllByText("Prompts")[0]);
|
||||
fireEvent.click((await screen.findAllByText("Prompts"))[0]);
|
||||
|
||||
// Click Overrides tab
|
||||
fireEvent.click(screen.getByTestId("tab-overrides"));
|
||||
@@ -3897,8 +3897,8 @@ describe("Prompts section", () => {
|
||||
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
|
||||
|
||||
// Verify default and fallback model dropdowns
|
||||
expect(screen.getByLabelText("Default Model")).toBeTruthy();
|
||||
expect(screen.getByLabelText("Fallback Model")).toBeTruthy();
|
||||
expect(await screen.findByLabelText("Default Model")).toBeTruthy();
|
||||
expect(await screen.findByLabelText("Fallback Model")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders global baseline lane dropdowns in Models section", async () => {
|
||||
@@ -3922,7 +3922,7 @@ describe("Prompts section", () => {
|
||||
fireEvent.click(screen.getAllByText("Models")[0]);
|
||||
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
|
||||
|
||||
const executionTrigger = screen.getByLabelText("Execution Model");
|
||||
const executionTrigger = await screen.findByLabelText("Execution Model");
|
||||
await user.click(executionTrigger);
|
||||
await user.click(screen.getByText("GPT-4o"));
|
||||
|
||||
@@ -3948,7 +3948,7 @@ describe("Prompts section", () => {
|
||||
fireEvent.click(screen.getAllByText("Models")[0]);
|
||||
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
|
||||
|
||||
const executionTrigger = screen.getByLabelText("Execution Model");
|
||||
const executionTrigger = await screen.findByLabelText("Execution Model");
|
||||
await user.click(executionTrigger);
|
||||
const useDefaultOption = await screen.findByRole("option", { name: /use default/i });
|
||||
await user.click(useDefaultOption);
|
||||
@@ -3965,7 +3965,7 @@ describe("Prompts section", () => {
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
fireEvent.click(screen.getAllByText("Models")[0]);
|
||||
fireEvent.click((await screen.findAllByText("Models"))[0]);
|
||||
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
|
||||
|
||||
expect(screen.getByLabelText("Execution Model").textContent).toContain("Use default");
|
||||
@@ -3978,7 +3978,7 @@ describe("Prompts section", () => {
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
fireEvent.click(screen.getAllByText("Models")[0]);
|
||||
fireEvent.click((await screen.findAllByText("Models"))[0]);
|
||||
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
|
||||
|
||||
// Thinking Effort is a native select
|
||||
@@ -4136,6 +4136,54 @@ describe("Prompts section", () => {
|
||||
expect(globalPayload.planningModelId).toBeUndefined();
|
||||
}
|
||||
}, FN1712_SCOPE_TEST_TIMEOUT_MS);
|
||||
|
||||
it("mixed global and project changes call both endpoints with correct subsets", async () => {
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
// Change global model
|
||||
fireEvent.click(screen.getAllByText("Models")[0]);
|
||||
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
|
||||
|
||||
let user = userEvent.setup();
|
||||
let dropdownBtn = screen.getByRole("button", { name: /default model/i });
|
||||
await user.click(dropdownBtn);
|
||||
let option = await screen.findByRole("option", { name: /gpt-4o/i });
|
||||
await user.click(option);
|
||||
|
||||
// Change project model
|
||||
fireEvent.click(screen.getAllByText("Project Models")[0]);
|
||||
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
|
||||
|
||||
dropdownBtn = screen.getByRole("button", { name: /planning model/i });
|
||||
await user.click(dropdownBtn);
|
||||
option = await screen.findByRole("option", { name: /claude/i });
|
||||
await user.click(option);
|
||||
|
||||
// Save
|
||||
fireEvent.click(screen.getByText("Save"));
|
||||
await waitFor(() => {
|
||||
expect(updateSettings).toHaveBeenCalled();
|
||||
expect(updateGlobalSettings).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const globalCalls = (updateGlobalSettings as ReturnType<typeof vi.fn>).mock.calls;
|
||||
const projectCalls = (updateSettings as ReturnType<typeof vi.fn>).mock.calls;
|
||||
const globalPayload = globalCalls[globalCalls.length - 1][0];
|
||||
const projectPayload = projectCalls[projectCalls.length - 1][0];
|
||||
|
||||
// Verify global payload contains only global keys
|
||||
expect(globalPayload).toHaveProperty("defaultProvider");
|
||||
expect(globalPayload).toHaveProperty("defaultModelId");
|
||||
expect(globalPayload.planningProvider).toBeUndefined();
|
||||
expect(globalPayload.planningModelId).toBeUndefined();
|
||||
|
||||
// Verify project payload contains only project keys
|
||||
expect(projectPayload).toHaveProperty("planningProvider");
|
||||
expect(projectPayload).toHaveProperty("planningModelId");
|
||||
expect(projectPayload.defaultProvider).toBeUndefined();
|
||||
expect(projectPayload.defaultModelId).toBeUndefined();
|
||||
}, FN1712_SCOPE_TEST_TIMEOUT_MS);
|
||||
});
|
||||
|
||||
describe("Reset/clear null-as-delete semantics (FN-1712)", () => {
|
||||
|
||||
@@ -3189,7 +3189,9 @@ describe("TerminalModal — FN-872 real-device keyboard overlap refinement", ()
|
||||
});
|
||||
|
||||
// resize should have been called with xterm dimensions
|
||||
expect(mockResize).toHaveBeenCalledWith(80, 24);
|
||||
await waitFor(() => {
|
||||
expect(mockResize).toHaveBeenCalledWith(80, 24);
|
||||
});
|
||||
});
|
||||
|
||||
it("clears --vv-height when keyboard closes", async () => {
|
||||
|
||||
@@ -44,10 +44,10 @@ describe("nodes-view mobile CSS", () => {
|
||||
expect(block).toContain("flex-wrap: wrap");
|
||||
});
|
||||
|
||||
it("defines .nodes-view-title h2 with 16px font on mobile", () => {
|
||||
it("defines .nodes-view-title h2 with tokenized font size on mobile", () => {
|
||||
expect(mobileMediaBlock).toContain(".nodes-view-title h2");
|
||||
const block = extractRuleBlock(mobileMediaBlock, ".nodes-view-title h2");
|
||||
expect(block).toContain("font-size: 16px");
|
||||
expect(block).toContain("font-size: calc(var(--space-md) + var(--space-xs));");
|
||||
});
|
||||
|
||||
it("defines .nodes-view-title h2 svg with flex-shrink: 0 on mobile", () => {
|
||||
@@ -56,17 +56,17 @@ describe("nodes-view mobile CSS", () => {
|
||||
expect(block).toContain("flex-shrink: 0");
|
||||
});
|
||||
|
||||
it("defines .nodes-view-count with smaller font on mobile", () => {
|
||||
it("defines .nodes-view-count with tokenized smaller font on mobile", () => {
|
||||
expect(mobileMediaBlock).toContain(".nodes-view-count");
|
||||
const block = extractRuleBlock(mobileMediaBlock, ".nodes-view-count");
|
||||
expect(block).toContain("font-size: 12px");
|
||||
expect(block).toContain("font-size: calc(var(--space-sm) + var(--space-xs));");
|
||||
});
|
||||
|
||||
it("defines .nodes-view-close with 36px touch target on mobile", () => {
|
||||
it("defines .nodes-view-close with tokenized touch target on mobile", () => {
|
||||
expect(mobileMediaBlock).toContain(".nodes-view-close");
|
||||
const block = extractRuleBlock(mobileMediaBlock, ".nodes-view-close");
|
||||
expect(block).toContain("min-height: 36px");
|
||||
expect(block).toContain("min-width: 36px");
|
||||
expect(block).toContain("min-height: calc(var(--space-xl) + var(--space-md));");
|
||||
expect(block).toContain("min-width: calc(var(--space-xl) + var(--space-md));");
|
||||
});
|
||||
|
||||
it("defines .nodes-view-actions with full width on mobile", () => {
|
||||
@@ -77,10 +77,10 @@ describe("nodes-view mobile CSS", () => {
|
||||
expect(block).toContain("justify-content: flex-end");
|
||||
});
|
||||
|
||||
it("defines .nodes-view-actions .btn with min-height on mobile", () => {
|
||||
it("defines .nodes-view-actions .btn with tokenized min-height on mobile", () => {
|
||||
expect(mobileMediaBlock).toContain(".nodes-view-actions .btn");
|
||||
const block = extractRuleBlock(mobileMediaBlock, ".nodes-view-actions .btn");
|
||||
expect(block).toContain("min-height: 36px");
|
||||
expect(block).toContain("min-height: calc(var(--space-xl) + var(--space-md));");
|
||||
});
|
||||
|
||||
it("defines .nodes-view-stats with 2-column grid on mobile", () => {
|
||||
@@ -96,16 +96,16 @@ describe("nodes-view mobile CSS", () => {
|
||||
expect(block).toContain("padding: var(--space-xs) var(--space-sm)");
|
||||
});
|
||||
|
||||
it("defines .nodes-view-stat span with smaller font on mobile", () => {
|
||||
it("defines .nodes-view-stat span with tokenized smaller font on mobile", () => {
|
||||
expect(mobileMediaBlock).toContain(".nodes-view-stat span");
|
||||
const block = extractRuleBlock(mobileMediaBlock, ".nodes-view-stat span");
|
||||
expect(block).toContain("font-size: 11px");
|
||||
expect(block).toContain("font-size: calc(var(--space-sm) + var(--space-xs) * 0.75);");
|
||||
});
|
||||
|
||||
it("defines .nodes-view-stat strong with smaller font on mobile", () => {
|
||||
it("defines .nodes-view-stat strong with tokenized smaller font on mobile", () => {
|
||||
expect(mobileMediaBlock).toContain(".nodes-view-stat strong");
|
||||
const block = extractRuleBlock(mobileMediaBlock, ".nodes-view-stat strong");
|
||||
expect(block).toContain("font-size: 14px");
|
||||
expect(block).toContain("font-size: calc(var(--space-md) + var(--space-xs) / 2);");
|
||||
});
|
||||
|
||||
it("defines .nodes-view-empty with compact padding on mobile", () => {
|
||||
@@ -128,9 +128,9 @@ describe("nodes-view mobile CSS", () => {
|
||||
expect(block).toContain("padding: var(--space-sm) 0");
|
||||
});
|
||||
|
||||
it("defines .nodes-view-section-title with smaller font on mobile", () => {
|
||||
it("defines .nodes-view-section-title with tokenized smaller font on mobile", () => {
|
||||
expect(mobileMediaBlock).toContain(".nodes-view-section-title");
|
||||
const block = extractRuleBlock(mobileMediaBlock, ".nodes-view-section-title");
|
||||
expect(block).toContain("font-size: 13px");
|
||||
expect(block).toContain("font-size: calc(var(--space-md) + var(--space-xs) / 4);");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -770,7 +770,9 @@ describe("onboarding flow integration", () => {
|
||||
|
||||
expect(mockMarkOnboardingCompleted).toHaveBeenCalled();
|
||||
expect(mockUpdateGlobalSettings).toHaveBeenCalledWith({ modelOnboardingComplete: true });
|
||||
expect(screen.getByText("Your first task is ready!")).toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Your first task is ready!")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("task creation flow: View Task button navigates and completes onboarding", async () => {
|
||||
|
||||
Reference in New Issue
Block a user