@runwisp/ui 0.10.0 → 0.12.0

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.
@@ -22,7 +22,10 @@ const UNIT_NAMES: Record<string, [string, string]> = {
22
22
  // humanizeEvery turns robfig's `@every 1h30m` (Go duration syntax) into
23
23
  // "Every 1 hour 30 minutes". Returns null when the duration doesn't parse.
24
24
  function humanizeEvery(duration: string): string | null {
25
- const parts = [...duration.matchAll(/(\d+(?:\.\d+)?)(h|m|s|ms|us|µs|ns)/g)];
25
+ // The number is matched atomically (lookahead-capture + backreference) so a
26
+ // long digit run with no trailing unit fails fast instead of backtracking
27
+ // through every length (super-linear). `\1` is the number; group 2 the unit.
28
+ const parts = [...duration.matchAll(/(?=(\d+(?:\.\d+)?))\1(h|m|s|ms|us|µs|ns)/g)];
26
29
  if (parts.length === 0 || parts.map((p) => p[0]).join("") !== duration) {
27
30
  return null;
28
31
  }
@@ -0,0 +1,299 @@
1
+ // SPDX-FileCopyrightText: PoppyCake, s.r.o.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ import { describe, expect, it } from "vitest";
5
+ import { FAILURE_END_REASONS, END_REASONS } from "@runwisp/common";
6
+ import {
7
+ emptyRunFilters,
8
+ NEEDS_ATTENTION_STATUSES,
9
+ isNeedsAttention,
10
+ dimensionActive,
11
+ activeDimensions,
12
+ activeFilterCount,
13
+ clearDimension,
14
+ clearPopoverFilters,
15
+ humanizeStatus,
16
+ triggerDescription,
17
+ FILTERABLE_TRIGGERS,
18
+ statusChipLabel,
19
+ STATUS_BUCKETS,
20
+ bucketState,
21
+ toggleBucket,
22
+ dayStartIso,
23
+ dayEndIso,
24
+ isoToDayInput,
25
+ isWholeDay,
26
+ parseExitCodeRange,
27
+ exitCodeRange,
28
+ exitCodeRangeActive,
29
+ isExitCodeExprValid,
30
+ exitCodeChipLabel,
31
+ type StatusBucket,
32
+ type RunsListFilters,
33
+ } from "./run-filters.js";
34
+
35
+ function bucketByKey(key: string): StatusBucket {
36
+ const bucket = STATUS_BUCKETS.find((b) => b.key === key);
37
+ if (!bucket) throw new Error(`no bucket ${key}`);
38
+ return bucket;
39
+ }
40
+
41
+ const base = (overrides: Partial<RunsListFilters> = {}): RunsListFilters => ({
42
+ ...emptyRunFilters(),
43
+ ...overrides,
44
+ });
45
+
46
+ describe("emptyRunFilters", () => {
47
+ it("is a fully-open filter — no dimension active, newest-first", () => {
48
+ const f = emptyRunFilters();
49
+ expect(f.statuses).toEqual([]);
50
+ expect(f.sort_direction).toBe("desc");
51
+ expect(activeFilterCount(f)).toBe(0);
52
+ });
53
+ });
54
+
55
+ describe("isNeedsAttention", () => {
56
+ it("is true for exactly the attention set, order-insensitive", () => {
57
+ expect(isNeedsAttention([...NEEDS_ATTENTION_STATUSES])).toBe(true);
58
+ expect(isNeedsAttention([...NEEDS_ATTENTION_STATUSES].reverse())).toBe(true);
59
+ });
60
+
61
+ it("includes every failure reason plus missed", () => {
62
+ for (const reason of FAILURE_END_REASONS) {
63
+ expect(NEEDS_ATTENTION_STATUSES).toContain(reason);
64
+ }
65
+ expect(NEEDS_ATTENTION_STATUSES).toContain("missed");
66
+ });
67
+
68
+ it("is false for a strict subset or a different set", () => {
69
+ expect(isNeedsAttention(["failed"])).toBe(false);
70
+ expect(isNeedsAttention(["success"])).toBe(false);
71
+ expect(isNeedsAttention([])).toBe(false);
72
+ });
73
+ });
74
+
75
+ describe("dimensionActive / activeDimensions", () => {
76
+ it("detects each dimension independently", () => {
77
+ expect(dimensionActive(base({ statuses: ["failed"] }), "status")).toBe(true);
78
+ expect(dimensionActive(base({ created_after: "2026-01-01T00:00:00Z" }), "time")).toBe(true);
79
+ expect(dimensionActive(base({ created_before: "2026-01-01T00:00:00Z" }), "time")).toBe(
80
+ true,
81
+ );
82
+ expect(dimensionActive(base({ task_name: "backup" }), "task")).toBe(true);
83
+ expect(dimensionActive(base({ triggered_by: "cron" }), "triggered_by")).toBe(true);
84
+ expect(dimensionActive(base({ exit_code: "137" }), "exit_code")).toBe(true);
85
+ expect(dimensionActive(base({ exit_code: ">100" }), "exit_code")).toBe(true);
86
+ expect(dimensionActive(base({ retries_only: true }), "retries")).toBe(true);
87
+ });
88
+
89
+ it("treats an exact exit code of 0 as active (distinct from absent)", () => {
90
+ expect(dimensionActive(base({ exit_code: "0" }), "exit_code")).toBe(true);
91
+ });
92
+
93
+ it("treats an empty or unparseable exit-code expression as inactive", () => {
94
+ expect(dimensionActive(base({ exit_code: "" }), "exit_code")).toBe(false);
95
+ expect(dimensionActive(base({ exit_code: "nonsense" }), "exit_code")).toBe(false);
96
+ });
97
+
98
+ it("lists active dimensions in display (most→least useful) order", () => {
99
+ const f = base({
100
+ retries_only: true,
101
+ statuses: ["failed"],
102
+ triggered_by: "cron",
103
+ created_after: "2026-01-01T00:00:00Z",
104
+ });
105
+ expect(activeDimensions(f)).toEqual(["status", "time", "triggered_by", "retries"]);
106
+ });
107
+ });
108
+
109
+ describe("clearDimension", () => {
110
+ it("resets one dimension and leaves the rest untouched", () => {
111
+ const f = base({ statuses: ["failed"], triggered_by: "cron", retries_only: true });
112
+ const cleared = clearDimension(f, "triggered_by");
113
+ expect(cleared.triggered_by).toBeUndefined();
114
+ expect(cleared.statuses).toEqual(["failed"]);
115
+ expect(cleared.retries_only).toBe(true);
116
+ });
117
+
118
+ it("clears the exit-code expression", () => {
119
+ const f = base({ exit_code: ">100 <150" });
120
+ expect(clearDimension(f, "exit_code").exit_code).toBeUndefined();
121
+ });
122
+
123
+ it("clears both time bounds together", () => {
124
+ const f = base({ created_after: "2026-01-01T00:00:00Z", created_before: "2026-02-01" });
125
+ const cleared = clearDimension(f, "time");
126
+ expect(cleared.created_after).toBeUndefined();
127
+ expect(cleared.created_before).toBeUndefined();
128
+ });
129
+ });
130
+
131
+ describe("clearPopoverFilters", () => {
132
+ it("clears every popover dimension but keeps search and sort", () => {
133
+ const f = base({
134
+ search: "nightly",
135
+ sort_direction: "asc",
136
+ statuses: ["failed"],
137
+ task_name: "backup",
138
+ triggered_by: "cron",
139
+ exit_code: "1",
140
+ retries_only: true,
141
+ created_after: "2026-01-01T00:00:00Z",
142
+ });
143
+ const cleared = clearPopoverFilters(f);
144
+ expect(activeFilterCount(cleared)).toBe(0);
145
+ expect(cleared.search).toBe("nightly");
146
+ expect(cleared.sort_direction).toBe("asc");
147
+ });
148
+ });
149
+
150
+ describe("humanizeStatus", () => {
151
+ it("title-cases and de-snakes a status token", () => {
152
+ expect(humanizeStatus("log_overflow")).toBe("Log overflow");
153
+ expect(humanizeStatus("failed")).toBe("Failed");
154
+ expect(humanizeStatus("")).toBe("");
155
+ });
156
+ });
157
+
158
+ describe("statusChipLabel", () => {
159
+ it("names a bucket when the selection matches one exactly", () => {
160
+ // The Failed bucket is the needs-attention set.
161
+ expect(statusChipLabel([...NEEDS_ATTENTION_STATUSES])).toBe("Failed");
162
+ expect(statusChipLabel(["pending", "running"])).toBe("Running");
163
+ expect(statusChipLabel(["success"])).toBe("Succeeded");
164
+ });
165
+
166
+ it("humanizes a single status that is not a whole bucket", () => {
167
+ expect(statusChipLabel(["log_overflow"])).toBe("Log overflow");
168
+ });
169
+
170
+ it("counts a multi-status set that matches no bucket", () => {
171
+ expect(statusChipLabel(["failed", "crashed"])).toBe("2 statuses");
172
+ });
173
+ });
174
+
175
+ describe("STATUS_BUCKETS", () => {
176
+ it("covers every filterable status exactly once", () => {
177
+ const all = STATUS_BUCKETS.flatMap((b) => [...b.statuses]);
178
+ expect(new Set(all).size).toBe(all.length); // no status in two buckets
179
+ expect(new Set(all)).toEqual(new Set([...END_REASONS, "pending", "running"]));
180
+ });
181
+
182
+ it("Failed bucket is the needs-attention set", () => {
183
+ expect(new Set(bucketByKey("failed").statuses)).toEqual(new Set(NEEDS_ATTENTION_STATUSES));
184
+ });
185
+ });
186
+
187
+ describe("bucketState / toggleBucket", () => {
188
+ const failed = bucketByKey("failed");
189
+
190
+ it("reads off / partial / on", () => {
191
+ expect(bucketState([], failed)).toBe("off");
192
+ expect(bucketState(["failed"], failed)).toBe("partial");
193
+ expect(bucketState([...failed.statuses], failed)).toBe("on");
194
+ });
195
+
196
+ it("selects all of an off or partial bucket", () => {
197
+ const fromOff = toggleBucket(base(), failed);
198
+ expect(new Set(fromOff.statuses)).toEqual(new Set(failed.statuses));
199
+
200
+ const fromPartial = toggleBucket(base({ statuses: ["failed"] }), failed);
201
+ expect(new Set(fromPartial.statuses)).toEqual(new Set(failed.statuses));
202
+ });
203
+
204
+ it("clears a fully-selected bucket without touching other statuses", () => {
205
+ const f = base({ statuses: [...failed.statuses, "success"] });
206
+ const cleared = toggleBucket(f, failed);
207
+ expect(cleared.statuses).toEqual(["success"]);
208
+ });
209
+ });
210
+
211
+ describe("date helpers", () => {
212
+ it("round-trips a day through its start/end bounds in local time", () => {
213
+ const start = dayStartIso("2016-07-10");
214
+ const end = dayEndIso("2016-07-10");
215
+ expect(isoToDayInput(start ?? "")).toBe("2016-07-10");
216
+ expect(isoToDayInput(end ?? "")).toBe("2016-07-10");
217
+ expect(new Date(start ?? "").getTime()).toBeLessThan(new Date(end ?? "").getTime());
218
+ });
219
+
220
+ it("ignores a malformed date", () => {
221
+ expect(dayStartIso("not-a-date")).toBeUndefined();
222
+ expect(dayEndIso("2016-13")).toBeUndefined();
223
+ });
224
+
225
+ it("isWholeDay is true when From and To bound the same day", () => {
226
+ // Picking the same date for both ends (start-of-day .. end-of-day) is a
227
+ // whole-day range, so the chip can read "On <date>".
228
+ expect(isWholeDay(dayStartIso("2016-07-10"), dayEndIso("2016-07-10"))).toBe(true);
229
+ });
230
+
231
+ it("isWholeDay is false for partial or multi-day ranges", () => {
232
+ expect(isWholeDay(undefined, undefined)).toBe(false);
233
+ expect(isWholeDay(dayStartIso("2016-07-10"), undefined)).toBe(false);
234
+ expect(isWholeDay(dayStartIso("2016-07-10"), dayEndIso("2016-07-12"))).toBe(false);
235
+ });
236
+ });
237
+
238
+ describe("triggerDescription", () => {
239
+ it("gives a fuller label than the row badge", () => {
240
+ expect(triggerDescription("cron")).toBe("Scheduled (cron)");
241
+ expect(triggerDescription("api")).toBe("Manual (UI or API)");
242
+ expect(triggerDescription("service")).toBe("Service auto-start");
243
+ expect(triggerDescription("startup")).toBe("On daemon start");
244
+ });
245
+
246
+ it("falls back to a humanized token for anything unknown", () => {
247
+ expect(triggerDescription("whatever_else")).toBe("Whatever else");
248
+ });
249
+ });
250
+
251
+ describe("FILTERABLE_TRIGGERS", () => {
252
+ it("offers every trigger except cloud", () => {
253
+ expect(FILTERABLE_TRIGGERS).toEqual(["cron", "api", "service", "startup"]);
254
+ expect(FILTERABLE_TRIGGERS).not.toContain("cloud");
255
+ });
256
+ });
257
+
258
+ describe("exit-code expression", () => {
259
+ it("parses an exact code as an inclusive [n, n] range", () => {
260
+ expect(parseExitCodeRange("137")).toEqual({ range: { min: 137, max: 137 }, valid: true });
261
+ expect(parseExitCodeRange("-2")).toEqual({ range: { min: -2, max: -2 }, valid: true });
262
+ });
263
+
264
+ it("normalizes strict comparisons to inclusive integer bounds", () => {
265
+ expect(exitCodeRange(">100")).toEqual({ min: 101 });
266
+ expect(exitCodeRange(">=100")).toEqual({ min: 100 });
267
+ expect(exitCodeRange("<150")).toEqual({ max: 149 });
268
+ expect(exitCodeRange("<=150")).toEqual({ max: 150 });
269
+ });
270
+
271
+ it("combines space-separated tokens into a window", () => {
272
+ expect(exitCodeRange(">100 <150")).toEqual({ min: 101, max: 149 });
273
+ expect(exitCodeRange(">=1 <=255")).toEqual({ min: 1, max: 255 });
274
+ });
275
+
276
+ it("treats an empty expression as valid with no bounds", () => {
277
+ expect(parseExitCodeRange("")).toEqual({ range: {}, valid: true });
278
+ expect(parseExitCodeRange(" ")).toEqual({ range: {}, valid: true });
279
+ expect(exitCodeRangeActive("")).toBe(false);
280
+ });
281
+
282
+ it("rejects an unparseable token, applying none of it", () => {
283
+ expect(isExitCodeExprValid("12a")).toBe(false);
284
+ expect(isExitCodeExprValid(">>5")).toBe(false);
285
+ expect(exitCodeRange("12a")).toEqual({});
286
+ expect(exitCodeRangeActive("12a")).toBe(false);
287
+ });
288
+
289
+ it("reports activity only when a bound resolves", () => {
290
+ expect(exitCodeRangeActive("137")).toBe(true);
291
+ expect(exitCodeRangeActive(">0")).toBe(true);
292
+ expect(exitCodeRangeActive(undefined)).toBe(false);
293
+ });
294
+
295
+ it("labels the chip with the raw expression", () => {
296
+ expect(exitCodeChipLabel(">100 <150")).toBe("Exit >100 <150");
297
+ expect(exitCodeChipLabel(" 137 ")).toBe("Exit 137");
298
+ });
299
+ });