openk8s 1.0.1 → 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -2
- package/src/app/__tests__/app-state.test.ts +376 -0
- package/src/app/__tests__/components/inspector-tokens.test.ts +101 -0
- package/src/app/__tests__/utils.test.ts +358 -0
- package/src/app/app-actions.ts +262 -0
- package/src/app/app-state.ts +4 -262
- package/src/app/app.tsx +22 -170
- package/src/app/components/detail-sections.tsx +131 -0
- package/src/app/components/footer.tsx +52 -0
- package/src/app/components/header.tsx +37 -0
- package/src/app/components/inspector-tokens.ts +93 -0
- package/src/app/components/inspector.tsx +3 -239
- package/src/app/components/resource-rows.tsx +5 -0
- package/src/app/hooks/keyboard/filter-handlers.ts +40 -0
- package/src/app/hooks/keyboard/global-handlers.ts +134 -0
- package/src/app/hooks/keyboard/helm-handlers.ts +104 -0
- package/src/app/hooks/keyboard/logs-handlers.ts +80 -0
- package/src/app/hooks/keyboard/navigation-handlers.ts +103 -0
- package/src/app/hooks/keyboard/overlay-handlers.ts +138 -0
- package/src/app/hooks/keyboard/port-forward-handlers.ts +71 -0
- package/src/app/hooks/keyboard/shell-edit-handlers.ts +253 -0
- package/src/app/hooks/use-app-keyboard.ts +56 -621
- package/src/app/hooks/use-app-side-effects.ts +1 -1
- package/src/app/hooks/use-clipboard.ts +1 -1
- package/src/app/hooks/use-data-fetching.ts +2 -9
- package/src/app/hooks/use-log-stream.ts +1 -1
- package/src/app/hooks/use-port-forward.ts +1 -1
- package/src/app/use-footer-hints.ts +107 -0
- package/src/index.tsx +4 -0
- package/src/lib/k8s/__tests__/k8s-format.test.ts +42 -0
- package/src/lib/k8s/__tests__/resource-detail-builder.test.ts +215 -0
- package/src/lib/k8s/__tests__/resource-parser.test.ts +455 -0
- package/src/lib/k8s/detail-builders/event-builder.ts +21 -0
- package/src/lib/k8s/detail-builders/hpa-cronjob-builder.ts +63 -0
- package/src/lib/k8s/detail-builders/node-builder.ts +41 -0
- package/src/lib/k8s/detail-builders/overview-builder.ts +103 -0
- package/src/lib/k8s/detail-builders/pod-builder.ts +140 -0
- package/src/lib/k8s/detail-builders/rbac-builder.ts +57 -0
- package/src/lib/k8s/resource-detail-builder.ts +22 -502
- package/src/lib/kubectl/__tests__/kubectl-helpers.test.ts +343 -0
- package/src/lib/kubectl/__tests__/metrics-utils.test.ts +84 -0
- package/src/lib/kubectl/__tests__/spawn-utils.test.ts +56 -0
- package/src/lib/kubectl/kubectl-helpers.ts +246 -0
- package/src/lib/kubectl/kubectl-service.ts +77 -565
- package/src/lib/kubectl/kubectl-types.ts +248 -0
- package/src/lib/kubectl/metrics-utils.ts +33 -0
- package/src/lib/kubectl/spawn-utils.ts +27 -0
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
normalizeKind,
|
|
4
|
+
normalizeResourceTarget,
|
|
5
|
+
normalizeLogTarget,
|
|
6
|
+
normalizePortForwardTarget,
|
|
7
|
+
supportsRolloutRestart,
|
|
8
|
+
supportsScale,
|
|
9
|
+
resourceStatus,
|
|
10
|
+
resourceSummary,
|
|
11
|
+
curatedKinds,
|
|
12
|
+
} from "../kubectl-helpers";
|
|
13
|
+
import type { ResourceRef, ResourceKind } from "../../k8s/types";
|
|
14
|
+
import type { ApiResourceItem } from "../kubectl-types";
|
|
15
|
+
|
|
16
|
+
// ── normalizeKind ────────────────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
describe("normalizeKind", () => {
|
|
19
|
+
test("lowercases", () => expect(normalizeKind("Deployment")).toBe("deployment"));
|
|
20
|
+
test("trims whitespace", () => expect(normalizeKind(" Pod ")).toBe("pod"));
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
// ── normalizeResourceTarget ──────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
describe("normalizeResourceTarget", () => {
|
|
26
|
+
test("formats kind/name", () => {
|
|
27
|
+
expect(normalizeResourceTarget({ kind: "Deployment", name: "nginx" })).toBe("deployment/nginx");
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
// ── normalizeLogTarget ───────────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
describe("normalizeLogTarget", () => {
|
|
34
|
+
test("pod target", () => {
|
|
35
|
+
const result = normalizeLogTarget({ kind: "Pod", name: "my-pod" });
|
|
36
|
+
expect(result).toEqual({ target: "pod/my-pod", includeAllPods: false });
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("deployment target with includeAllPods", () => {
|
|
40
|
+
const result = normalizeLogTarget({ kind: "Deployment", name: "web" });
|
|
41
|
+
expect(result).toEqual({ target: "deployment/web", includeAllPods: true });
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("job target with includeAllPods", () => {
|
|
45
|
+
const result = normalizeLogTarget({ kind: "Job", name: "backup" });
|
|
46
|
+
expect(result).toEqual({ target: "job/backup", includeAllPods: true });
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("returns undefined for unsupported kinds", () => {
|
|
50
|
+
expect(normalizeLogTarget({ kind: "ConfigMap", name: "cfg" })).toBeUndefined();
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// ── normalizePortForwardTarget ───────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
describe("normalizePortForwardTarget", () => {
|
|
57
|
+
test("pod target", () => {
|
|
58
|
+
expect(normalizePortForwardTarget({ kind: "Pod", name: "my-pod" })).toBe("pod/my-pod");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("service target", () => {
|
|
62
|
+
expect(normalizePortForwardTarget({ kind: "Service", name: "web" })).toBe("service/web");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("deployment target", () => {
|
|
66
|
+
expect(normalizePortForwardTarget({ kind: "Deployment", name: "app" })).toBe("deployment/app");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("returns undefined for unsupported", () => {
|
|
70
|
+
expect(normalizePortForwardTarget({ kind: "Ingress", name: "ing" })).toBeUndefined();
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// ── supports helpers ─────────────────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
describe("supportsRolloutRestart", () => {
|
|
77
|
+
const makeRef = (kind: string): ResourceRef => ({ kind, name: "test" });
|
|
78
|
+
|
|
79
|
+
test("deployment", () => expect(supportsRolloutRestart(makeRef("Deployment"))).toBe(true));
|
|
80
|
+
test("statefulset", () => expect(supportsRolloutRestart(makeRef("StatefulSet"))).toBe(true));
|
|
81
|
+
test("daemonset", () => expect(supportsRolloutRestart(makeRef("DaemonSet"))).toBe(true));
|
|
82
|
+
test("pod does not support", () => expect(supportsRolloutRestart(makeRef("Pod"))).toBe(false));
|
|
83
|
+
test("service does not support", () => expect(supportsRolloutRestart(makeRef("Service"))).toBe(false));
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe("supportsScale", () => {
|
|
87
|
+
const makeRef = (kind: string): ResourceRef => ({ kind, name: "test" });
|
|
88
|
+
|
|
89
|
+
test("deployment", () => expect(supportsScale(makeRef("Deployment"))).toBe(true));
|
|
90
|
+
test("statefulset", () => expect(supportsScale(makeRef("StatefulSet"))).toBe(true));
|
|
91
|
+
test("replicaset", () => expect(supportsScale(makeRef("ReplicaSet"))).toBe(true));
|
|
92
|
+
test("pod does not support", () => expect(supportsScale(makeRef("Pod"))).toBe(false));
|
|
93
|
+
test("daemonset does not support", () => expect(supportsScale(makeRef("DaemonSet"))).toBe(false));
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// ── resourceStatus ───────────────────────────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
describe("resourceStatus", () => {
|
|
99
|
+
test("events return type field", () => {
|
|
100
|
+
expect(resourceStatus({ kind: "Event", type: "Warning" } as any)).toBe("Warning");
|
|
101
|
+
expect(resourceStatus({ kind: "Event" } as any)).toBe("Normal");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("HPA shows current/max replicas", () => {
|
|
105
|
+
expect(resourceStatus({ kind: "HorizontalPodAutoscaler", spec: { maxReplicas: 5 }, status: { currentReplicas: 3 } } as any)).toBe("3/5 replicas");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("HPA with missing fields", () => {
|
|
109
|
+
expect(resourceStatus({ kind: "HorizontalPodAutoscaler" } as any)).toBe("-/- replicas");
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("CronJob active state", () => {
|
|
113
|
+
expect(resourceStatus({ kind: "CronJob", status: { active: [{}, {}] } } as any)).toBe("active");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("CronJob idle state", () => {
|
|
117
|
+
expect(resourceStatus({ kind: "CronJob", status: {} } as any)).toBe("idle");
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("Node Ready", () => {
|
|
121
|
+
expect(resourceStatus({ kind: "Node", status: { conditions: [{ type: "Ready", status: "True" }] } } as any)).toBe("Ready");
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("Node NotReady", () => {
|
|
125
|
+
expect(resourceStatus({ kind: "Node", status: { conditions: [{ type: "Ready", status: "False" }] } } as any)).toBe("NotReady");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("Node MemoryPressure", () => {
|
|
129
|
+
expect(resourceStatus({ kind: "Node", status: { conditions: [{ type: "MemoryPressure", status: "True" }] } } as any)).toBe("MemoryPressure");
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("Node DiskPressure", () => {
|
|
133
|
+
expect(resourceStatus({ kind: "Node", status: { conditions: [{ type: "DiskPressure", status: "True" }] } } as any)).toBe("DiskPressure");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("RoleBinding shows subject count", () => {
|
|
137
|
+
expect(resourceStatus({ kind: "RoleBinding", subjects: [{}, {}] } as any)).toBe("2 subjects");
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("Phase-based status", () => {
|
|
141
|
+
expect(resourceStatus({ kind: "Pod", status: { phase: "Running" } } as any)).toBe("Running");
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("Ready replicas status", () => {
|
|
145
|
+
expect(resourceStatus({ kind: "Deployment", spec: { replicas: 3 }, status: { readyReplicas: 3 } } as any)).toBe("3/3 ready");
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("Available replicas status", () => {
|
|
149
|
+
expect(resourceStatus({ kind: "Deployment", spec: { replicas: 2 }, status: { availableReplicas: 1 } } as any)).toBe("1/2 available");
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("Non-specific kind with capacity falls back", () => {
|
|
153
|
+
expect(resourceStatus({ kind: "PersistentVolume", status: { capacity: { storage: "10Gi" } } } as any)).toBe("capacity");
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("Default status", () => {
|
|
157
|
+
expect(resourceStatus({ kind: "ConfigMap" } as any)).toBe("-");
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// ── resourceSummary ──────────────────────────────────────────────────────────
|
|
162
|
+
|
|
163
|
+
describe("resourceSummary", () => {
|
|
164
|
+
test("Events show reason and message", () => {
|
|
165
|
+
expect(resourceSummary({ kind: "Event", reason: "Created", message: "Pod created successfully" } as any)).toBe("Created: Pod created successfully");
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("HPA summary", () => {
|
|
169
|
+
expect(resourceSummary({ kind: "HorizontalPodAutoscaler", spec: { minReplicas: 1, maxReplicas: 10, scaleTargetRef: { name: "web" } } } as any)).toBe("min:1 max:10 ref:web");
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("CronJob summary", () => {
|
|
173
|
+
const ts = new Date(Date.now() - 300_000).toISOString();
|
|
174
|
+
const result = resourceSummary({ kind: "CronJob", spec: { schedule: "*/5 * * * *" }, status: { lastScheduleTime: ts } } as any);
|
|
175
|
+
expect(result).toMatch(/5 \* \* \* \* last:/);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("Node summary shows kubelet version", () => {
|
|
179
|
+
expect(resourceSummary({ kind: "Node", status: { nodeInfo: { kubeletVersion: "v1.28" } }, metadata: { labels: { "node-role.kubernetes.io/worker": "true" } } } as any)).toBe("v1.28 roles: worker");
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test("Node summary without role label", () => {
|
|
183
|
+
expect(resourceSummary({ kind: "Node", status: { nodeInfo: { kubeletVersion: "v1.28" } }, metadata: { labels: {} } } as any)).toBe("v1.28 roles: worker");
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test("Role summary shows rule count", () => {
|
|
187
|
+
expect(resourceSummary({ kind: "Role", rules: [{}, {}] } as any)).toBe("2 rules");
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("RoleBinding summary shows roleRef", () => {
|
|
191
|
+
expect(resourceSummary({ kind: "RoleBinding", roleRef: { kind: "Role", name: "admin" } } as any)).toBe("→ Role/admin");
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test("Services show clusterIP", () => {
|
|
195
|
+
expect(resourceSummary({ kind: "Service", spec: { clusterIP: "10.0.0.1" } } as any)).toBe("clusterIP 10.0.0.1");
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test("Pods with nodeName", () => {
|
|
199
|
+
expect(resourceSummary({ kind: "Pod", spec: { nodeName: "node-1" } } as any)).toBe("node node-1");
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test("Default empty summary", () => {
|
|
203
|
+
expect(resourceSummary({ kind: "ConfigMap" } as any)).toBe("");
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
// ── api-resources JSON mapping ────────────────────────────────────────────────
|
|
208
|
+
|
|
209
|
+
describe("api-resources JSON to ResourceKind mapping", () => {
|
|
210
|
+
test("maps core resource (no group) with undefined group", () => {
|
|
211
|
+
const items: ApiResourceItem[] = [
|
|
212
|
+
{ name: "pods", singularName: "pod", namespaced: true, version: "v1", kind: "Pod", verbs: ["list"], shortNames: ["po"] },
|
|
213
|
+
];
|
|
214
|
+
|
|
215
|
+
const kinds = items.map((item) => ({
|
|
216
|
+
name: item.name,
|
|
217
|
+
namespaced: item.namespaced,
|
|
218
|
+
group: item.group || undefined,
|
|
219
|
+
shortNames: item.shortNames ?? [],
|
|
220
|
+
}));
|
|
221
|
+
|
|
222
|
+
expect(kinds).toEqual([
|
|
223
|
+
{ name: "pods", namespaced: true, group: undefined, shortNames: ["po"] },
|
|
224
|
+
]);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test("maps namespaced resource with API group", () => {
|
|
228
|
+
const items: ApiResourceItem[] = [
|
|
229
|
+
{ name: "deployments", singularName: "deployment", namespaced: true, group: "apps", version: "v1", kind: "Deployment", verbs: ["list"], shortNames: ["deploy"] },
|
|
230
|
+
];
|
|
231
|
+
|
|
232
|
+
const kinds = items.map((item) => ({
|
|
233
|
+
name: item.name,
|
|
234
|
+
namespaced: item.namespaced,
|
|
235
|
+
group: item.group || undefined,
|
|
236
|
+
shortNames: item.shortNames ?? [],
|
|
237
|
+
}));
|
|
238
|
+
|
|
239
|
+
expect(kinds).toEqual([
|
|
240
|
+
{ name: "deployments", namespaced: true, group: "apps", shortNames: ["deploy"] },
|
|
241
|
+
]);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test("maps resource without shortNames to empty array", () => {
|
|
245
|
+
const items: ApiResourceItem[] = [
|
|
246
|
+
{ name: "bindings", singularName: "binding", namespaced: true, version: "v1", kind: "Binding", verbs: ["create"] },
|
|
247
|
+
];
|
|
248
|
+
|
|
249
|
+
const kinds = items.map((item) => ({
|
|
250
|
+
name: item.name,
|
|
251
|
+
namespaced: item.namespaced,
|
|
252
|
+
group: item.group || undefined,
|
|
253
|
+
shortNames: item.shortNames ?? [],
|
|
254
|
+
}));
|
|
255
|
+
|
|
256
|
+
expect(kinds).toEqual([
|
|
257
|
+
{ name: "bindings", namespaced: true, group: undefined, shortNames: [] },
|
|
258
|
+
]);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
test("maps cluster-scoped resource", () => {
|
|
262
|
+
const items: ApiResourceItem[] = [
|
|
263
|
+
{ name: "nodes", singularName: "node", namespaced: false, version: "v1", kind: "Node", verbs: ["list"], shortNames: ["no"] },
|
|
264
|
+
];
|
|
265
|
+
|
|
266
|
+
const kinds = items.map((item) => ({
|
|
267
|
+
name: item.name,
|
|
268
|
+
namespaced: item.namespaced,
|
|
269
|
+
group: item.group || undefined,
|
|
270
|
+
shortNames: item.shortNames ?? [],
|
|
271
|
+
}));
|
|
272
|
+
|
|
273
|
+
expect(kinds).toEqual([
|
|
274
|
+
{ name: "nodes", namespaced: false, group: undefined, shortNames: ["no"] },
|
|
275
|
+
]);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
test("passes through to curatedKinds correctly", () => {
|
|
279
|
+
const items: ApiResourceItem[] = [
|
|
280
|
+
{ name: "services", singularName: "service", namespaced: true, version: "v1", kind: "Service", verbs: ["list"], shortNames: ["svc"] },
|
|
281
|
+
{ name: "pods", singularName: "pod", namespaced: true, version: "v1", kind: "Pod", verbs: ["list"], shortNames: ["po"] },
|
|
282
|
+
{ name: "deployments", singularName: "deployment", namespaced: true, group: "apps", version: "v1", kind: "Deployment", verbs: ["list"], shortNames: ["deploy"] },
|
|
283
|
+
{ name: "pods.metrics.k8s.io", singularName: "podmetrics", namespaced: true, group: "metrics.k8s.io", version: "v1beta1", kind: "PodMetrics", verbs: ["get", "list"], shortNames: [] },
|
|
284
|
+
];
|
|
285
|
+
|
|
286
|
+
const kinds = items.map((item) => ({
|
|
287
|
+
name: item.name,
|
|
288
|
+
namespaced: item.namespaced,
|
|
289
|
+
group: item.group || undefined,
|
|
290
|
+
shortNames: item.shortNames ?? [],
|
|
291
|
+
}));
|
|
292
|
+
|
|
293
|
+
const curated = curatedKinds(kinds);
|
|
294
|
+
|
|
295
|
+
// metrics.k8s.io/podmetrics should be filtered out by curatedKinds
|
|
296
|
+
expect(curated.find((k) => k.name === "pods.metrics.k8s.io")).toBeUndefined();
|
|
297
|
+
// core pods should be included
|
|
298
|
+
expect(curated.find((k) => k.name === "pods")).toBeDefined();
|
|
299
|
+
});
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
// ── curatedKinds ─────────────────────────────────────────────────────────────
|
|
303
|
+
|
|
304
|
+
describe("curatedKinds", () => {
|
|
305
|
+
const allKinds: ResourceKind[] = [
|
|
306
|
+
{ name: "services", namespaced: true, shortNames: ["svc"] },
|
|
307
|
+
{ name: "pods", namespaced: true, shortNames: ["po"] },
|
|
308
|
+
{ name: "deployments", namespaced: true, shortNames: ["deploy"] },
|
|
309
|
+
{ name: "configmaps", namespaced: true, shortNames: ["cm"] },
|
|
310
|
+
];
|
|
311
|
+
|
|
312
|
+
test("reorders kinds based on preferred order", () => {
|
|
313
|
+
const result = curatedKinds(allKinds);
|
|
314
|
+
expect(result[0]?.name).toBe("pods");
|
|
315
|
+
expect(result[1]?.name).toBe("deployments");
|
|
316
|
+
expect(result[2]?.name).toBe("services");
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
test("returns empty when input is empty", () => {
|
|
320
|
+
expect(curatedKinds([])).toEqual([]);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
test("includes only matching kinds", () => {
|
|
324
|
+
const kinds: ResourceKind[] = [
|
|
325
|
+
{ name: "pods", namespaced: true, shortNames: ["po"] },
|
|
326
|
+
{ name: "custom", namespaced: true, shortNames: ["cst"] },
|
|
327
|
+
{ name: "deployments", namespaced: true, shortNames: ["deploy"] },
|
|
328
|
+
];
|
|
329
|
+
const result = curatedKinds(kinds);
|
|
330
|
+
expect(result).toHaveLength(2);
|
|
331
|
+
expect(result[0]?.name).toBe("pods");
|
|
332
|
+
expect(result[1]?.name).toBe("deployments");
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
test("falls back to first 20 when no preferred matches", () => {
|
|
336
|
+
const kinds: ResourceKind[] = [
|
|
337
|
+
{ name: "a", namespaced: false, shortNames: [] },
|
|
338
|
+
{ name: "b", namespaced: false, shortNames: [] },
|
|
339
|
+
];
|
|
340
|
+
const result = curatedKinds(kinds);
|
|
341
|
+
expect(result).toHaveLength(2);
|
|
342
|
+
});
|
|
343
|
+
});
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { parseCpuNano, parseMemBytes, formatCpu, formatMem } from "../metrics-utils";
|
|
3
|
+
|
|
4
|
+
// ── parseCpuNano ─────────────────────────────────────────────────────────────
|
|
5
|
+
|
|
6
|
+
describe("parseCpuNano", () => {
|
|
7
|
+
test("nanocores (suffix 'n')", () => {
|
|
8
|
+
expect(parseCpuNano("500000000n")).toBe(500000000);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test("millicores (suffix 'm')", () => {
|
|
12
|
+
expect(parseCpuNano("100m")).toBe(100_000_000);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("whole cores", () => {
|
|
16
|
+
expect(parseCpuNano("2")).toBe(2_000_000_000);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("fractional cores", () => {
|
|
20
|
+
expect(parseCpuNano("0.5")).toBe(500_000_000);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("empty string", () => {
|
|
24
|
+
expect(parseCpuNano("")).toBe(0);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("invalid string", () => {
|
|
28
|
+
expect(parseCpuNano("abc")).toBe(0);
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
// ── parseMemBytes ────────────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
describe("parseMemBytes", () => {
|
|
35
|
+
test("Ki", () => expect(parseMemBytes("512Ki")).toBe(512 * 1024));
|
|
36
|
+
test("Mi", () => expect(parseMemBytes("256Mi")).toBe(256 * 1024 ** 2));
|
|
37
|
+
test("Gi", () => expect(parseMemBytes("2Gi")).toBe(2 * 1024 ** 3));
|
|
38
|
+
test("K (decimal)", () => expect(parseMemBytes("1000K")).toBe(1000 * 1000));
|
|
39
|
+
test("M (decimal)", () => expect(parseMemBytes("10M")).toBe(10 * 1000 ** 2));
|
|
40
|
+
test("G (decimal)", () => expect(parseMemBytes("1G")).toBe(1000 ** 3));
|
|
41
|
+
test("plain bytes", () => expect(parseMemBytes("1024")).toBe(1024));
|
|
42
|
+
test("empty string", () => expect(parseMemBytes("")).toBe(0));
|
|
43
|
+
test("invalid", () => expect(parseMemBytes("abc")).toBe(0));
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// ── formatCpu ────────────────────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
describe("formatCpu", () => {
|
|
49
|
+
test("formats cores", () => {
|
|
50
|
+
expect(formatCpu(2_000_000_000)).toBe("2.00");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("formats millicores", () => {
|
|
54
|
+
expect(formatCpu(100_000_000)).toBe("100m");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("formats small millicores", () => {
|
|
58
|
+
expect(formatCpu(1_000_000)).toBe("1m");
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// ── formatMem ────────────────────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
describe("formatMem", () => {
|
|
65
|
+
test("formats GiB", () => {
|
|
66
|
+
expect(formatMem(2 * 1024 ** 3)).toBe("2.0Gi");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("formats MiB", () => {
|
|
70
|
+
expect(formatMem(256 * 1024 ** 2)).toBe("256Mi");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("formats KiB", () => {
|
|
74
|
+
expect(formatMem(512 * 1024)).toBe("512Ki");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("formats bytes", () => {
|
|
78
|
+
expect(formatMem(500)).toBe("500B");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("formats zero", () => {
|
|
82
|
+
expect(formatMem(0)).toBe("0B");
|
|
83
|
+
});
|
|
84
|
+
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { runCommand, runText, runJson, startPersistentProcess } from "../spawn-utils";
|
|
3
|
+
|
|
4
|
+
describe("runCommand", () => {
|
|
5
|
+
test("successfully runs echo", async () => {
|
|
6
|
+
const result = await runCommand({ command: "echo", args: ["hello"], timeoutMs: 5000 });
|
|
7
|
+
expect(result.stdout.trim()).toBe("hello");
|
|
8
|
+
expect(result.stderr).toBe("");
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test("captures stdout only (no exitCode in result)", async () => {
|
|
12
|
+
const result = await runCommand({ command: "echo", args: ["-n", "hello world"], timeoutMs: 5000 });
|
|
13
|
+
expect(result.stdout).toBe("hello world");
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("throws on non-zero exit", async () => {
|
|
17
|
+
expect(
|
|
18
|
+
runCommand({ command: "sh", args: ["-c", "exit 1"], timeoutMs: 5000 }),
|
|
19
|
+
).rejects.toThrow();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("throws on timeout", async () => {
|
|
23
|
+
expect(
|
|
24
|
+
runCommand({ command: "sleep", args: ["10"], timeoutMs: 100 }),
|
|
25
|
+
).rejects.toThrow();
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe("runText", () => {
|
|
30
|
+
test("returns stdout as string", async () => {
|
|
31
|
+
const text = await runText({ command: "echo", args: ["-n", "hello world"], timeoutMs: 5000 });
|
|
32
|
+
expect(text).toBe("hello world");
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe("runJson", () => {
|
|
37
|
+
test("parses JSON output", async () => {
|
|
38
|
+
const result = await runJson<{ name: string }>({ command: "echo", args: ["-n", '{"name":"test"}'], timeoutMs: 5000 });
|
|
39
|
+
expect(result.name).toBe("test");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("throws on invalid JSON", async () => {
|
|
43
|
+
expect(
|
|
44
|
+
runJson({ command: "echo", args: ["-n", "not-json"], timeoutMs: 5000 }),
|
|
45
|
+
).rejects.toThrow();
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe("startPersistentProcess", () => {
|
|
50
|
+
test("starts a process and returns object with pid", () => {
|
|
51
|
+
const child = startPersistentProcess({ command: "echo", args: ["hello"], stdio: ["ignore", "pipe", "pipe"] });
|
|
52
|
+
expect(child).toBeDefined();
|
|
53
|
+
expect(typeof child.pid).toBe("number");
|
|
54
|
+
child.kill();
|
|
55
|
+
});
|
|
56
|
+
});
|