From 46d932c37949e0ba19a132b7be3476851c2f7993 Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Sat, 2 May 2026 02:43:10 +0530 Subject: [PATCH 01/13] refactor(dashboard): unify log-level color map in status.ts --- .../src/features/jobs/components/job-logs-tab.tsx | 11 ++--------- .../src/features/logs/components/log-stream.tsx | 11 ++--------- dashboard/src/lib/status.ts | 14 ++++++++++++++ 3 files changed, 18 insertions(+), 18 deletions(-) diff --git a/dashboard/src/features/jobs/components/job-logs-tab.tsx b/dashboard/src/features/jobs/components/job-logs-tab.tsx index 6877766..fe158b2 100644 --- a/dashboard/src/features/jobs/components/job-logs-tab.tsx +++ b/dashboard/src/features/jobs/components/job-logs-tab.tsx @@ -2,6 +2,7 @@ import { ScrollText } from "lucide-react"; import { EmptyState, ErrorState, Skeleton } from "@/components/ui"; import type { TaskLog } from "@/lib/api-types"; import { cn } from "@/lib/cn"; +import { logLevelClass } from "@/lib/status"; import { formatAbsolute } from "@/lib/time"; interface JobLogsTabProps { @@ -11,14 +12,6 @@ interface JobLogsTabProps { onRetry: () => void; } -const LEVEL_STYLE: Record = { - error: "text-danger", - warning: "text-warning", - warn: "text-warning", - info: "text-info", - debug: "text-[var(--fg-subtle)]", -}; - export function JobLogsTab({ logs, loading, error, onRetry }: JobLogsTabProps) { if (error) { return ; @@ -36,7 +29,7 @@ export function JobLogsTab({ logs, loading, error, onRetry }: JobLogsTabProps) {
    {logs.map((log, i) => { - const levelClass = LEVEL_STYLE[log.level.toLowerCase()] ?? "text-[var(--fg-muted)]"; + const levelClass = logLevelClass(log.level); const key = `${log.logged_at}-${log.level}-${i}`; return (
  • diff --git a/dashboard/src/features/logs/components/log-stream.tsx b/dashboard/src/features/logs/components/log-stream.tsx index 2431bfd..c05265a 100644 --- a/dashboard/src/features/logs/components/log-stream.tsx +++ b/dashboard/src/features/logs/components/log-stream.tsx @@ -4,6 +4,7 @@ import { useEffect, useRef, useState } from "react"; import { EmptyState, ErrorState, Skeleton } from "@/components/ui"; import type { TaskLog } from "@/lib/api-types"; import { cn } from "@/lib/cn"; +import { logLevelClass } from "@/lib/status"; import { formatAbsolute } from "@/lib/time"; interface LogStreamProps { @@ -14,14 +15,6 @@ interface LogStreamProps { className?: string; } -const LEVEL_TONE: Record = { - error: "text-danger", - warning: "text-warning", - warn: "text-warning", - info: "text-info", - debug: "text-[var(--fg-subtle)]", -}; - const ROW_ESTIMATE = 28; const AUTO_SCROLL_THRESHOLD_PX = 40; @@ -88,7 +81,7 @@ export function LogStream({ logs, loading, error, onRetry, className }: LogStrea {virtualizer.getVirtualItems().map((item) => { const log = logs[item.index]; if (!log) return null; - const levelClass = LEVEL_TONE[log.level.toLowerCase()] ?? "text-[var(--fg-muted)]"; + const levelClass = logLevelClass(log.level); return (
    = { + error: "text-danger", + warning: "text-warning", + warn: "text-warning", + info: "text-info", + debug: "text-[var(--fg-subtle)]", +}; + +const LOG_LEVEL_FALLBACK = "text-[var(--fg-muted)]"; + +export function logLevelClass(level: string): string { + return LOG_LEVEL_CLASS[level.toLowerCase()] ?? LOG_LEVEL_FALLBACK; +} From cd189cd278eacc79bbe5692def5707412370284b Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Sat, 2 May 2026 02:45:19 +0530 Subject: [PATCH 02/13] fix(dashboard): debounce filters via refs without lint suppression --- .../features/jobs/components/job-filters.tsx | 36 ++++++++++--------- 1 file changed, 19 insertions(+), 17 deletions(-) diff --git a/dashboard/src/features/jobs/components/job-filters.tsx b/dashboard/src/features/jobs/components/job-filters.tsx index 72ee5f9..b882036 100644 --- a/dashboard/src/features/jobs/components/job-filters.tsx +++ b/dashboard/src/features/jobs/components/job-filters.tsx @@ -1,5 +1,5 @@ import { X } from "lucide-react"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { Button, Input, @@ -62,32 +62,34 @@ export function JobFiltersBar({ filters, onChange, className }: JobFiltersBarPro const debouncedMetadata = useDebouncedValue(local.metadata, 300); const debouncedError = useDebouncedValue(local.error, 300); + // Hold the latest filters/onChange in refs so the propagation effect re-runs + // only when debounced values settle. Listing them as deps would re-fire the + // effect on every parent render and defeat the debounce. + const filtersRef = useRef(filters); + const onChangeRef = useRef(onChange); useEffect(() => { + filtersRef.current = filters; + onChangeRef.current = onChange; + }); + + useEffect(() => { + const current = filtersRef.current; const next: JobFilters = { - ...filters, + ...current, queue: debouncedQueue || undefined, task: debouncedTask || undefined, metadata: debouncedMetadata || undefined, error: debouncedError || undefined, }; if ( - next.queue !== filters.queue || - next.task !== filters.task || - next.metadata !== filters.metadata || - next.error !== filters.error + next.queue !== current.queue || + next.task !== current.task || + next.metadata !== current.metadata || + next.error !== current.error ) { - onChange(next); + onChangeRef.current(next); } - // eslint-disable-next-line react-hooks/exhaustive-deps -- intentional: propagate only debounced values - }, [ - debouncedQueue, - debouncedTask, - debouncedMetadata, - debouncedError, - filters.task, - filters, - onChange, - ]); + }, [debouncedQueue, debouncedTask, debouncedMetadata, debouncedError]); const activeCount = countActiveFilters(filters); From a56cdffd5038df7488f379200d52e902af6110be Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Sat, 2 May 2026 02:45:58 +0530 Subject: [PATCH 03/13] chore(dashboard): promote useExhaustiveDependencies to error --- dashboard/biome.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dashboard/biome.json b/dashboard/biome.json index 0a95199..429508f 100644 --- a/dashboard/biome.json +++ b/dashboard/biome.json @@ -12,7 +12,7 @@ "noForEach": "off" }, "correctness": { - "useExhaustiveDependencies": "warn" + "useExhaustiveDependencies": "error" }, "style": { "noNonNullAssertion": "off" From fe0db3368e5d2a2ea93989afe838ba48bab8cb92 Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Sat, 2 May 2026 02:47:21 +0530 Subject: [PATCH 04/13] refactor(dashboard): share formatAxisTime across metric charts --- dashboard/src/features/metrics/components/latency-chart.tsx | 5 +---- .../src/features/metrics/components/throughput-chart.tsx | 5 +---- dashboard/src/features/metrics/utils.ts | 3 +++ 3 files changed, 5 insertions(+), 8 deletions(-) create mode 100644 dashboard/src/features/metrics/utils.ts diff --git a/dashboard/src/features/metrics/components/latency-chart.tsx b/dashboard/src/features/metrics/components/latency-chart.tsx index 447c51f..d01fb2d 100644 --- a/dashboard/src/features/metrics/components/latency-chart.tsx +++ b/dashboard/src/features/metrics/components/latency-chart.tsx @@ -10,6 +10,7 @@ import { } from "recharts"; import { Card, CardContent, CardHeader, CardTitle, Skeleton } from "@/components/ui"; import type { TimeseriesBucket } from "@/lib/api-types"; +import { formatAxisTime } from "../utils"; interface LatencyChartProps { buckets: TimeseriesBucket[] | undefined; @@ -99,7 +100,3 @@ function LatencyTooltip({
    ); } - -function formatAxisTime(value: number): string { - return new Date(value).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); -} diff --git a/dashboard/src/features/metrics/components/throughput-chart.tsx b/dashboard/src/features/metrics/components/throughput-chart.tsx index 915e4dc..0fe06b6 100644 --- a/dashboard/src/features/metrics/components/throughput-chart.tsx +++ b/dashboard/src/features/metrics/components/throughput-chart.tsx @@ -11,6 +11,7 @@ import { } from "recharts"; import { Card, CardContent, CardHeader, CardTitle, Skeleton } from "@/components/ui"; import type { TimeseriesBucket } from "@/lib/api-types"; +import { formatAxisTime } from "../utils"; interface ThroughputChartProps { buckets: TimeseriesBucket[] | undefined; @@ -128,7 +129,3 @@ function ChartTooltip({
); } - -function formatAxisTime(value: number): string { - return new Date(value).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); -} diff --git a/dashboard/src/features/metrics/utils.ts b/dashboard/src/features/metrics/utils.ts new file mode 100644 index 0000000..8cc1ea9 --- /dev/null +++ b/dashboard/src/features/metrics/utils.ts @@ -0,0 +1,3 @@ +export function formatAxisTime(value: number): string { + return new Date(value).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); +} From 08a2e162d3668e4a41e0fc9b599b1bf7fcf65c57 Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Sat, 2 May 2026 02:49:15 +0530 Subject: [PATCH 05/13] refactor(dashboard): extract job DAG layout into pure module --- .../features/jobs/components/dag-layout.ts | 121 ++++++++++++++++++ .../features/jobs/components/job-dag-tab.tsx | 96 +------------- 2 files changed, 123 insertions(+), 94 deletions(-) create mode 100644 dashboard/src/features/jobs/components/dag-layout.ts diff --git a/dashboard/src/features/jobs/components/dag-layout.ts b/dashboard/src/features/jobs/components/dag-layout.ts new file mode 100644 index 0000000..60b2b87 --- /dev/null +++ b/dashboard/src/features/jobs/components/dag-layout.ts @@ -0,0 +1,121 @@ +import type { DagData, DagNode } from "@/lib/api-types"; + +export const NODE_WIDTH = 180; +export const NODE_HEIGHT = 58; +export const LAYER_GAP_X = 80; +export const NODE_GAP_Y = 24; +export const PADDING = 32; + +export interface LaidOutNode extends DagNode { + x: number; + y: number; + layer: number; +} + +export interface LayoutResult { + nodes: LaidOutNode[]; + width: number; + height: number; +} + +/** + * Lay out DAG nodes in BFS layers from sources (nodes with no inbound edges). + * Each layer's nodes are centered vertically so the graph is readable even for + * lopsided fan-outs. Cycles are handled defensively by seeding layer 0 with + * the first node when no source exists. + */ +export function layout(data: DagData): LayoutResult { + const { nodes, edges } = data; + if (nodes.length === 0) return { nodes: [], width: 0, height: 0 }; + + const layerOf = assignLayers(nodes, edges); + const layers = groupByLayer(nodes, layerOf); + const sortedLayers = [...layers.entries()].sort(([a], [b]) => a - b); + + const { width, height } = canvasSize(sortedLayers); + const laidOut = positionNodes(nodes, sortedLayers, height); + + return { nodes: laidOut, width, height }; +} + +function assignLayers(nodes: DagNode[], edges: DagData["edges"]): Map { + const incoming = new Map(); + const outgoing = new Map(); + for (const n of nodes) incoming.set(n.id, 0); + for (const e of edges) { + incoming.set(e.to, (incoming.get(e.to) ?? 0) + 1); + const arr = outgoing.get(e.from); + if (arr) arr.push(e.to); + else outgoing.set(e.from, [e.to]); + } + + const layerOf = new Map(); + const queue: string[] = []; + for (const n of nodes) { + if ((incoming.get(n.id) ?? 0) === 0) { + layerOf.set(n.id, 0); + queue.push(n.id); + } + } + if (queue.length === 0) { + layerOf.set(nodes[0]!.id, 0); + queue.push(nodes[0]!.id); + } + + while (queue.length > 0) { + const id = queue.shift()!; + const depth = layerOf.get(id) ?? 0; + for (const child of outgoing.get(id) ?? []) { + const nextDepth = depth + 1; + if ((layerOf.get(child) ?? -1) < nextDepth) { + layerOf.set(child, nextDepth); + queue.push(child); + } + } + } + + return layerOf; +} + +function groupByLayer(nodes: DagNode[], layerOf: Map): Map { + const layers = new Map(); + for (const n of nodes) { + const layer = layerOf.get(n.id) ?? 0; + const bucket = layers.get(layer) ?? []; + bucket.push(n.id); + layers.set(layer, bucket); + } + return layers; +} + +function canvasSize(sortedLayers: [number, string[]][]): { width: number; height: number } { + const tallestLayerSize = sortedLayers.reduce((max, [, ids]) => Math.max(max, ids.length), 0); + const height = PADDING * 2 + tallestLayerSize * NODE_HEIGHT + (tallestLayerSize - 1) * NODE_GAP_Y; + const width = + PADDING * 2 + sortedLayers.length * NODE_WIDTH + (sortedLayers.length - 1) * LAYER_GAP_X; + return { width, height }; +} + +function positionNodes( + nodes: DagNode[], + sortedLayers: [number, string[]][], + canvasHeight: number, +): LaidOutNode[] { + const byId = new Map(nodes.map((n) => [n.id, n])); + const laidOut: LaidOutNode[] = []; + for (const [layer, ids] of sortedLayers) { + const layerHeight = ids.length * NODE_HEIGHT + (ids.length - 1) * NODE_GAP_Y; + const startY = (canvasHeight - layerHeight) / 2; + ids.forEach((id, i) => { + const base = byId.get(id); + if (!base) return; + laidOut.push({ + ...base, + layer, + x: PADDING + layer * (NODE_WIDTH + LAYER_GAP_X), + y: startY + i * (NODE_HEIGHT + NODE_GAP_Y), + }); + }); + } + return laidOut; +} diff --git a/dashboard/src/features/jobs/components/job-dag-tab.tsx b/dashboard/src/features/jobs/components/job-dag-tab.tsx index cfe578b..77ffe81 100644 --- a/dashboard/src/features/jobs/components/job-dag-tab.tsx +++ b/dashboard/src/features/jobs/components/job-dag-tab.tsx @@ -2,8 +2,9 @@ import { Link } from "@tanstack/react-router"; import { Workflow } from "lucide-react"; import { useMemo } from "react"; import { EmptyState, ErrorState, Skeleton } from "@/components/ui"; -import type { DagData, DagEdge, DagNode, JobStatus } from "@/lib/api-types"; +import type { DagData, DagEdge, JobStatus } from "@/lib/api-types"; import { JOB_STATUS_LABEL } from "@/lib/status"; +import { type LaidOutNode, layout, NODE_HEIGHT, NODE_WIDTH } from "./dag-layout"; interface JobDagTabProps { dag: DagData | undefined; @@ -30,99 +31,6 @@ const STATUS_STROKE: Record = { cancelled: "var(--color-warning)", }; -const NODE_WIDTH = 180; -const NODE_HEIGHT = 58; -const LAYER_GAP_X = 80; -const NODE_GAP_Y = 24; -const PADDING = 32; - -interface LaidOutNode extends DagNode { - x: number; - y: number; - layer: number; -} - -/** - * Lay out DAG nodes in BFS layers from sources (nodes with no inbound edges). - * Each layer's nodes are centered vertically so the graph is readable even - * for lopsided fan-outs. Returns positioned nodes plus canvas dimensions. - */ -function layout(data: DagData): { nodes: LaidOutNode[]; width: number; height: number } { - const nodes = data.nodes; - const edges = data.edges; - if (nodes.length === 0) return { nodes: [], width: 0, height: 0 }; - - const incoming = new Map(); - const outgoing = new Map(); - for (const n of nodes) incoming.set(n.id, 0); - for (const e of edges) { - incoming.set(e.to, (incoming.get(e.to) ?? 0) + 1); - const arr = outgoing.get(e.from); - if (arr) arr.push(e.to); - else outgoing.set(e.from, [e.to]); - } - - const layerOf = new Map(); - const queue: string[] = []; - for (const n of nodes) { - if ((incoming.get(n.id) ?? 0) === 0) { - layerOf.set(n.id, 0); - queue.push(n.id); - } - } - // Handle cycles defensively: any node we haven't ranked sits on layer 0. - if (queue.length === 0 && nodes.length > 0) { - layerOf.set(nodes[0]!.id, 0); - queue.push(nodes[0]!.id); - } - - while (queue.length > 0) { - const id = queue.shift()!; - const depth = layerOf.get(id) ?? 0; - for (const child of outgoing.get(id) ?? []) { - const nextDepth = depth + 1; - if ((layerOf.get(child) ?? -1) < nextDepth) { - layerOf.set(child, nextDepth); - queue.push(child); - } - } - } - - const layers = new Map(); - for (const n of nodes) { - const layer = layerOf.get(n.id) ?? 0; - const bucket = layers.get(layer) ?? []; - bucket.push(n.id); - layers.set(layer, bucket); - } - - const sortedLayers = [...layers.entries()].sort(([a], [b]) => a - b); - const tallestLayerSize = sortedLayers.reduce((max, [, ids]) => Math.max(max, ids.length), 0); - const canvasHeight = - PADDING * 2 + tallestLayerSize * NODE_HEIGHT + (tallestLayerSize - 1) * NODE_GAP_Y; - const canvasWidth = - PADDING * 2 + sortedLayers.length * NODE_WIDTH + (sortedLayers.length - 1) * LAYER_GAP_X; - - const laidOut: LaidOutNode[] = []; - const byId = new Map(nodes.map((n) => [n.id, n])); - for (const [layer, ids] of sortedLayers) { - const layerHeight = ids.length * NODE_HEIGHT + (ids.length - 1) * NODE_GAP_Y; - const startY = (canvasHeight - layerHeight) / 2; - ids.forEach((id, i) => { - const base = byId.get(id); - if (!base) return; - laidOut.push({ - ...base, - layer, - x: PADDING + layer * (NODE_WIDTH + LAYER_GAP_X), - y: startY + i * (NODE_HEIGHT + NODE_GAP_Y), - }); - }); - } - - return { nodes: laidOut, width: canvasWidth, height: canvasHeight }; -} - export function JobDagTab({ dag, loading, error, onRetry }: JobDagTabProps) { const layoutResult = useMemo( () => (dag ? layout(dag) : { nodes: [], width: 0, height: 0 }), From e95fd9a15582770d5b3b034b2c9b6cd403e24bb8 Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Sat, 2 May 2026 02:50:27 +0530 Subject: [PATCH 06/13] test(dashboard): cover api-client url builder and parse --- dashboard/src/lib/api-client.test.ts | 164 +++++++++++++++++++++++++++ 1 file changed, 164 insertions(+) create mode 100644 dashboard/src/lib/api-client.test.ts diff --git a/dashboard/src/lib/api-client.test.ts b/dashboard/src/lib/api-client.test.ts new file mode 100644 index 0000000..920bc96 --- /dev/null +++ b/dashboard/src/lib/api-client.test.ts @@ -0,0 +1,164 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { ApiError, api } from "./api-client"; + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); +} + +function textResponse(body: string, status = 200): Response { + return new Response(body, { + status, + headers: { "content-type": "text/plain" }, + }); +} + +describe("api.get URL building", () => { + beforeEach(() => { + vi.spyOn(globalThis, "fetch").mockResolvedValue(jsonResponse({ ok: true })); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("hits the path verbatim when no params", async () => { + await api.get("/api/jobs"); + expect(globalThis.fetch).toHaveBeenCalledWith( + "/api/jobs", + expect.objectContaining({ method: "GET" }), + ); + }); + + it("encodes params and skips null/undefined/empty-string", async () => { + await api.get("/api/jobs", { + params: { + status: "running", + limit: 25, + bool: true, + skipNull: null, + skipUndefined: undefined, + skipEmpty: "", + }, + }); + const [url] = vi.mocked(globalThis.fetch).mock.calls[0]!; + expect(url).toBe("/api/jobs?status=running&limit=25&bool=true"); + }); + + it("escapes special characters in query values", async () => { + await api.get("/api/jobs", { params: { task: "send email & sms" } }); + const [url] = vi.mocked(globalThis.fetch).mock.calls[0]!; + expect(url).toBe("/api/jobs?task=send+email+%26+sms"); + }); + + it("forwards Accept and custom headers", async () => { + await api.get("/api/jobs", { headers: { "X-Trace": "abc" } }); + const [, init] = vi.mocked(globalThis.fetch).mock.calls[0]!; + expect(init?.headers).toMatchObject({ Accept: "application/json", "X-Trace": "abc" }); + }); + + it("passes the abort signal through", async () => { + const controller = new AbortController(); + await api.get("/api/jobs", { signal: controller.signal }); + const [, init] = vi.mocked(globalThis.fetch).mock.calls[0]!; + expect(init?.signal).toBe(controller.signal); + }); +}); + +describe("api response parsing", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("parses JSON when content-type is application/json", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue(jsonResponse({ items: [1, 2] })); + const result = await api.get<{ items: number[] }>("/api/x"); + expect(result).toEqual({ items: [1, 2] }); + }); + + it("falls back to text when content-type is not JSON", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue(textResponse("hello")); + const result = await api.get("/api/x"); + expect(result).toBe("hello"); + }); + + it("throws ApiError with structured message on non-OK JSON", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue( + jsonResponse({ error: "queue not found" }, 404), + ); + await expect(api.get("/api/missing")).rejects.toMatchObject({ + name: "ApiError", + status: 404, + message: "queue not found", + body: { error: "queue not found" }, + }); + }); + + it("throws ApiError with status fallback when JSON has no error field", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue(jsonResponse({ note: "oops" }, 500)); + await expect(api.get("/api/x")).rejects.toMatchObject({ + name: "ApiError", + status: 500, + message: "Request failed with status 500", + }); + }); + + it("throws ApiError on non-OK text response", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue(textResponse("bad gateway", 502)); + await expect(api.get("/api/x")).rejects.toMatchObject({ + name: "ApiError", + status: 502, + message: "Request failed with status 502", + body: "bad gateway", + }); + }); +}); + +describe("api write methods", () => { + beforeEach(() => { + vi.spyOn(globalThis, "fetch").mockResolvedValue(jsonResponse({ ok: true })); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("post serializes JSON body and sets content-type", async () => { + await api.post("/api/jobs", { task: "send_email" }); + const [, init] = vi.mocked(globalThis.fetch).mock.calls[0]!; + expect(init?.method).toBe("POST"); + expect(init?.body).toBe(JSON.stringify({ task: "send_email" })); + expect(init?.headers).toMatchObject({ "Content-Type": "application/json" }); + }); + + it("post omits body and content-type when body is undefined", async () => { + await api.post("/api/queues/default/pause"); + const [, init] = vi.mocked(globalThis.fetch).mock.calls[0]!; + expect(init?.body).toBeUndefined(); + expect(init?.headers).not.toHaveProperty("Content-Type"); + }); + + it("put sends PUT method", async () => { + await api.put("/api/settings", { theme: "dark" }); + const [, init] = vi.mocked(globalThis.fetch).mock.calls[0]!; + expect(init?.method).toBe("PUT"); + }); + + it("delete sends DELETE method", async () => { + await api.delete("/api/jobs/abc"); + const [, init] = vi.mocked(globalThis.fetch).mock.calls[0]!; + expect(init?.method).toBe("DELETE"); + }); +}); + +describe("ApiError", () => { + it("preserves message, status, and body", () => { + const err = new ApiError("nope", 418, { teapot: true }); + expect(err.message).toBe("nope"); + expect(err.status).toBe(418); + expect(err.body).toEqual({ teapot: true }); + expect(err).toBeInstanceOf(Error); + }); +}); From ef4586a196ff249af51c5fd77fbb8ae84c8e169e Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Sat, 2 May 2026 02:51:31 +0530 Subject: [PATCH 07/13] test(dashboard): cover errors.ts network detection helpers --- dashboard/src/lib/errors.test.ts | 68 ++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 dashboard/src/lib/errors.test.ts diff --git a/dashboard/src/lib/errors.test.ts b/dashboard/src/lib/errors.test.ts new file mode 100644 index 0000000..822742f --- /dev/null +++ b/dashboard/src/lib/errors.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; +import { ApiError } from "./api-client"; +import { isBackendUnreachable, isNetworkError, isServerUnavailable } from "./errors"; + +describe("isNetworkError", () => { + it("matches Chrome's 'Failed to fetch'", () => { + expect(isNetworkError(new TypeError("Failed to fetch"))).toBe(true); + }); + + it("matches Firefox's 'NetworkError when attempting to fetch resource'", () => { + expect(isNetworkError(new TypeError("NetworkError when attempting to fetch resource."))).toBe( + true, + ); + }); + + it("matches Safari's 'Load failed'", () => { + expect(isNetworkError(new TypeError("Load failed"))).toBe(true); + }); + + it("rejects unrelated TypeErrors", () => { + expect(isNetworkError(new TypeError("Cannot read property 'foo' of undefined"))).toBe(false); + }); + + it("rejects non-Error inputs", () => { + expect(isNetworkError(null)).toBe(false); + expect(isNetworkError(undefined)).toBe(false); + expect(isNetworkError("Failed to fetch")).toBe(false); + expect(isNetworkError(new Error("Failed to fetch"))).toBe(false); + }); +}); + +describe("isServerUnavailable", () => { + it("returns true for 502/503/504 ApiErrors", () => { + expect(isServerUnavailable(new ApiError("bad gateway", 502, null))).toBe(true); + expect(isServerUnavailable(new ApiError("unavailable", 503, null))).toBe(true); + expect(isServerUnavailable(new ApiError("timeout", 504, null))).toBe(true); + }); + + it("returns false for other ApiError statuses", () => { + expect(isServerUnavailable(new ApiError("not found", 404, null))).toBe(false); + expect(isServerUnavailable(new ApiError("server error", 500, null))).toBe(false); + expect(isServerUnavailable(new ApiError("teapot", 418, null))).toBe(false); + }); + + it("returns false for non-ApiError inputs", () => { + expect(isServerUnavailable(new Error("oops"))).toBe(false); + expect(isServerUnavailable(new TypeError("Failed to fetch"))).toBe(false); + expect(isServerUnavailable(null)).toBe(false); + }); +}); + +describe("isBackendUnreachable", () => { + it("is true for network errors", () => { + expect(isBackendUnreachable(new TypeError("Failed to fetch"))).toBe(true); + }); + + it("is true for 503 ApiErrors", () => { + expect(isBackendUnreachable(new ApiError("down", 503, null))).toBe(true); + }); + + it("is false for 4xx ApiErrors", () => { + expect(isBackendUnreachable(new ApiError("forbidden", 403, null))).toBe(false); + }); + + it("is false for plain errors", () => { + expect(isBackendUnreachable(new Error("some other failure"))).toBe(false); + }); +}); From abaec42a494cf8544df1ffcc0e6d95d76a55a0df Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Sat, 2 May 2026 02:52:30 +0530 Subject: [PATCH 08/13] test(dashboard): cover settings derived helpers --- .../src/features/settings/derived.test.ts | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 dashboard/src/features/settings/derived.test.ts diff --git a/dashboard/src/features/settings/derived.test.ts b/dashboard/src/features/settings/derived.test.ts new file mode 100644 index 0000000..1609feb --- /dev/null +++ b/dashboard/src/features/settings/derived.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from "vitest"; +import { applyJobContext, parseExternalLinks } from "./derived"; + +describe("parseExternalLinks", () => { + it("returns [] for undefined or empty input", () => { + expect(parseExternalLinks(undefined)).toEqual([]); + expect(parseExternalLinks("")).toEqual([]); + }); + + it("returns [] for invalid JSON", () => { + expect(parseExternalLinks("not json")).toEqual([]); + expect(parseExternalLinks("{")).toEqual([]); + }); + + it("returns [] when JSON is not an array", () => { + expect(parseExternalLinks('{"label":"x","url":"y"}')).toEqual([]); + expect(parseExternalLinks("null")).toEqual([]); + expect(parseExternalLinks("42")).toEqual([]); + }); + + it("parses well-formed array entries", () => { + const raw = JSON.stringify([ + { label: "Docs", url: "https://docs.example.com" }, + { label: "Repo", url: "https://github.com/example" }, + ]); + expect(parseExternalLinks(raw)).toEqual([ + { label: "Docs", url: "https://docs.example.com" }, + { label: "Repo", url: "https://github.com/example" }, + ]); + }); + + it("filters out entries missing label or url", () => { + const raw = JSON.stringify([ + { label: "Docs", url: "https://docs.example.com" }, + { label: "Missing url" }, + { url: "https://nolabel.example.com" }, + { label: 42, url: "https://wrong-type.example.com" }, + null, + "string", + ]); + expect(parseExternalLinks(raw)).toEqual([{ label: "Docs", url: "https://docs.example.com" }]); + }); + + it("strips extra fields, keeping only label and url", () => { + const raw = JSON.stringify([{ label: "Docs", url: "/d", danger: "