@runwisp/ui 1.7.0 → 1.7.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@runwisp/ui",
3
3
  "description": "Svelte 5 UI component library for RunWisp dashboards and admin interfaces.",
4
- "version": "1.7.0",
4
+ "version": "1.7.1",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
7
7
  "type": "git",
@@ -49,8 +49,8 @@
49
49
  "devDependencies": {
50
50
  "@chromatic-com/storybook": "^5.2.1",
51
51
  "@eslint/js": "^10.0.1",
52
- "@runwisp/eslint-config": "1.7.0",
53
- "@runwisp/typescript-config": "1.7.0",
52
+ "@runwisp/eslint-config": "1.7.1",
53
+ "@runwisp/typescript-config": "1.7.1",
54
54
  "@storybook/addon-a11y": "^10.5.5",
55
55
  "@storybook/addon-docs": "^10.5.5",
56
56
  "@storybook/addon-svelte-csf": "^5.1.2",
@@ -92,7 +92,7 @@
92
92
  "@fontsource-variable/geist-mono": "^5.3.0",
93
93
  "@fontsource/tasa-orbiter": "^5.3.0",
94
94
  "@lucide/svelte": "^1.52.0",
95
- "@runwisp/common": "1.7.0",
95
+ "@runwisp/common": "1.7.1",
96
96
  "@tanstack/svelte-virtual": "^3.13.35",
97
97
  "ansi-to-html": "^0.7.2",
98
98
  "cron-parser": "^5.7.0",
@@ -0,0 +1,135 @@
1
+ // SPDX-FileCopyrightText: PoppyCake, s.r.o.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ import { describe, expect, it } from "vitest";
5
+ import { LogCache } from "./LogCache.svelte";
6
+
7
+ describe("LogCache", () => {
8
+ it("merges a slice, skipping bad keys and reporting what changed", () => {
9
+ const cache = new LogCache();
10
+ expect(cache.mergeSlice({ 2: "two", 5: "five", [-1]: "neg" })).toEqual({
11
+ touched: true,
12
+ min: 2,
13
+ max: 5,
14
+ });
15
+ expect(cache.size).toBe(2);
16
+ expect(cache.mergeSlice({ 2: "two" }).touched).toBe(false);
17
+ });
18
+
19
+ it("keeps the widest line width even after that line is gone", () => {
20
+ const cache = new LogCache();
21
+ cache.mergeSlice({ 0: "x".repeat(40) });
22
+ cache.mergeSlice({ 0: "short" });
23
+ expect(cache.maxLineColumns).toBe(40);
24
+ });
25
+
26
+ it("only grows totals and the first available line from events", () => {
27
+ const cache = new LogCache();
28
+ cache.applyEvent({
29
+ lines: {},
30
+ sizeLines: 10,
31
+ sizeBytes: 300,
32
+ finished: false,
33
+ firstAvailableLine: 4,
34
+ });
35
+ cache.applyEvent({
36
+ lines: {},
37
+ sizeLines: 8,
38
+ sizeBytes: 100,
39
+ finished: false,
40
+ firstAvailableLine: 2,
41
+ });
42
+ expect([cache.totalLines, cache.totalBytes, cache.firstAvailableLine]).toEqual([
43
+ 10, 300, 4,
44
+ ]);
45
+ });
46
+
47
+ it("records frame history only for lines that have some", () => {
48
+ const cache = new LogCache();
49
+ cache.applyEvent({
50
+ lines: { 3: "100%" },
51
+ sizeLines: 4,
52
+ finished: false,
53
+ frameCounts: { 3: 12, 1: 0 },
54
+ });
55
+ expect([...cache.frameCounts]).toEqual([[3, 12]]);
56
+ });
57
+
58
+ it("orders the live overlay stdout, stderr, then other streams by name", () => {
59
+ const cache = new LogCache();
60
+ cache.applyRegion({ stream: "zeta", epoch: 1, rows: ["z"] });
61
+ cache.applyRegion({ stream: "stderr", epoch: 1, rows: ["e"] });
62
+ cache.applyRegion({ stream: "alpha", epoch: 1, rows: ["a"] });
63
+ cache.applyRegion({ stream: "stdout", epoch: 1, rows: ["o1", "o2"] });
64
+ expect(cache.overlayRows).toEqual(["o1", "o2", "e", "a", "z"]);
65
+
66
+ cache.applyRegion({ stream: "zeta", epoch: 2, rows: [] });
67
+ expect(cache.overlayRows).toEqual(["o1", "o2", "e", "a"]);
68
+ });
69
+
70
+ it("keeps region frames out of the committed lines and drops them once finished", () => {
71
+ const cache = new LogCache();
72
+ const res = cache.applyEvent({
73
+ lines: {},
74
+ sizeLines: 0,
75
+ finished: false,
76
+ region: { stream: "stdout", epoch: 1, rows: ["[##..] 50%"] },
77
+ });
78
+ expect(res.touched).toBe(false);
79
+ expect(cache.size).toBe(0);
80
+ expect(cache.overlayRows).toEqual(["[##..] 50%"]);
81
+
82
+ cache.applyEvent({ lines: { 0: "done" }, sizeLines: 1, finished: true });
83
+ expect(cache.overlayRows).toEqual([]);
84
+ expect(cache.finished).toBe(true);
85
+ });
86
+
87
+ it("prunes lines far from the viewport once the cache is full", () => {
88
+ const cache = new LogCache();
89
+ const slice: Record<number, string> = {};
90
+ for (let n = 0; n < cache.MAX_CACHE_SIZE; n++) slice[n] = "l";
91
+ cache.mergeSlice(slice);
92
+
93
+ cache.prune(40_000, 40_100);
94
+ expect(cache.lines.has(40_050)).toBe(true);
95
+ expect(cache.lines.has(0)).toBe(false);
96
+ expect(cache.size).toBeLessThan(cache.MAX_CACHE_SIZE);
97
+ });
98
+
99
+ it("leaves a cache under the limit alone", () => {
100
+ const cache = new LogCache();
101
+ cache.mergeSlice({ 0: "a", 99_999: "b" });
102
+ cache.prune(50_000, 50_010);
103
+ expect(cache.size).toBe(2);
104
+ });
105
+
106
+ it("judges a range complete within what the server still holds", () => {
107
+ const cache = new LogCache();
108
+ cache.applyEvent({
109
+ lines: { 5: "a", 6: "b" },
110
+ sizeLines: 7,
111
+ finished: false,
112
+ firstAvailableLine: 5,
113
+ });
114
+ expect(cache.isRangeComplete(0, 100)).toBe(true);
115
+ cache.applyEvent({ lines: {}, sizeLines: 8, finished: false });
116
+ expect(cache.isRangeComplete(0, 100)).toBe(false);
117
+ });
118
+
119
+ it("resets to empty", () => {
120
+ const cache = new LogCache();
121
+ cache.applyEvent({
122
+ lines: { 0: "a" },
123
+ sizeLines: 1,
124
+ finished: true,
125
+ firstAvailableLine: 1,
126
+ });
127
+ cache.reset();
128
+ expect([cache.size, cache.totalLines, cache.finished, cache.firstAvailableLine]).toEqual([
129
+ 0,
130
+ 0,
131
+ false,
132
+ 0,
133
+ ]);
134
+ });
135
+ });
@@ -0,0 +1,159 @@
1
+ // SPDX-FileCopyrightText: PoppyCake, s.r.o.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
+ import { LogCache } from "./LogCache.svelte";
6
+ import { LogFetcher } from "./LogFetcher.svelte";
7
+ import type { LogEvent, LogSlice } from "./types";
8
+
9
+ const CHUNK = 4096;
10
+
11
+ /** A cache that knows the log is `total` lines long but holds none of them. */
12
+ function emptyCache(total: number): LogCache {
13
+ const cache = new LogCache();
14
+ cache.applyEvent({ lines: {}, sizeLines: total, finished: false });
15
+ return cache;
16
+ }
17
+
18
+ /** A fetch whose calls stay pending until the test settles them. */
19
+ function deferredFetch() {
20
+ const pending: { from: number; to: number; settle: (v: LogSlice | LogEvent) => void }[] = [];
21
+ const fn = vi.fn(
22
+ (from: number, to: number) =>
23
+ new Promise<LogSlice | LogEvent>((resolve) => {
24
+ pending.push({ from, to, settle: resolve });
25
+ }),
26
+ );
27
+ return { fn, pending };
28
+ }
29
+
30
+ const live: LogFetcher[] = [];
31
+
32
+ function start(...args: ConstructorParameters<typeof LogFetcher>): LogFetcher {
33
+ const fetcher = new LogFetcher(...args);
34
+ live.push(fetcher);
35
+ return fetcher;
36
+ }
37
+
38
+ beforeEach(() => {
39
+ vi.useFakeTimers();
40
+ });
41
+
42
+ afterEach(() => {
43
+ for (const fetcher of live.splice(0)) fetcher.destroy();
44
+ vi.useRealTimers();
45
+ });
46
+
47
+ describe("LogFetcher", () => {
48
+ it("fetches a missing chunk once, merges it, and reports the loaded range", async () => {
49
+ const cache = emptyCache(10);
50
+ const onLoaded = vi.fn();
51
+ const fetchLogs = vi.fn(() => ({ 0: "a", 9: "j" }));
52
+ const fetcher = start(cache, fetchLogs, onLoaded);
53
+
54
+ fetcher.enqueue(0, 9);
55
+ fetcher.enqueue(2, 5); // same chunk, already queued
56
+ await vi.runAllTimersAsync();
57
+
58
+ expect(fetchLogs).toHaveBeenCalledExactlyOnceWith(0, 9);
59
+ expect(cache.lines.get(9)).toBe("j");
60
+ expect(onLoaded).toHaveBeenCalledWith(0, 9);
61
+ expect(fetcher.isFetching).toBe(false);
62
+ });
63
+
64
+ it("applies an event response, not just a slice", async () => {
65
+ const cache = emptyCache(1);
66
+ const fetcher = start(cache, () => ({ lines: { 0: "x" }, sizeLines: 3, finished: true }));
67
+ fetcher.enqueue(0, 0);
68
+ await vi.runAllTimersAsync();
69
+ expect([cache.totalLines, cache.finished]).toEqual([3, true]);
70
+ });
71
+
72
+ it("skips a range the cache already holds", async () => {
73
+ const cache = emptyCache(2);
74
+ cache.mergeSlice({ 0: "a", 1: "b" });
75
+ const fetchLogs = vi.fn(() => ({}));
76
+ const fetcher = start(cache, fetchLogs);
77
+ fetcher.enqueue(0, 1);
78
+ await vi.runAllTimersAsync();
79
+ expect(fetchLogs).not.toHaveBeenCalled();
80
+ });
81
+
82
+ it("never asks for lines rotated away", async () => {
83
+ const cache = emptyCache(CHUNK * 2);
84
+ cache.applyEvent({
85
+ lines: {},
86
+ sizeLines: CHUNK * 2,
87
+ finished: false,
88
+ firstAvailableLine: CHUNK + 5,
89
+ });
90
+ const fetchLogs = vi.fn(() => ({}));
91
+ const fetcher = start(cache, fetchLogs);
92
+ fetcher.enqueue(0, CHUNK * 2 - 1);
93
+ await vi.runAllTimersAsync();
94
+ expect(fetchLogs.mock.calls).toEqual([[CHUNK, CHUNK * 2 - 1]]);
95
+ });
96
+
97
+ it("keeps at most two chunks in flight", async () => {
98
+ const { fn, pending } = deferredFetch();
99
+ const fetcher = start(emptyCache(CHUNK * 3), fn);
100
+
101
+ fetcher.enqueue(0, 0);
102
+ await vi.advanceTimersByTimeAsync(fetcher.MIN_REQUEST_INTERVAL_MS);
103
+ fetcher.enqueue(CHUNK, CHUNK * 3 - 1);
104
+ await vi.advanceTimersByTimeAsync(fetcher.MIN_REQUEST_INTERVAL_MS * 5);
105
+ expect(fn).toHaveBeenCalledTimes(2);
106
+ expect(fetcher.isFetching).toBe(true);
107
+
108
+ pending[0]?.settle({});
109
+ await vi.advanceTimersByTimeAsync(fetcher.MIN_REQUEST_INTERVAL_MS * 5);
110
+ expect(fn).toHaveBeenCalledTimes(3);
111
+ });
112
+
113
+ it("drops queued chunks the viewport has left behind", async () => {
114
+ const fetchLogs = vi.fn((_from: number, _to: number) => ({}));
115
+ const fetcher = start(emptyCache(CHUNK * 10), fetchLogs);
116
+ fetcher.enqueue(0, CHUNK * 10 - 1);
117
+ fetcher.pruneQueue(CHUNK * 9, CHUNK * 10 - 1);
118
+ await vi.runAllTimersAsync();
119
+ expect(fetchLogs.mock.calls.map(([from]) => from / CHUNK)).toEqual([7, 8, 9]);
120
+ });
121
+
122
+ it("recovers from a failed fetch so the chunk can be asked for again", async () => {
123
+ const fetchLogs = vi
124
+ .fn()
125
+ .mockRejectedValueOnce(new Error("503"))
126
+ .mockResolvedValue({ 0: "a" });
127
+ const cache = emptyCache(1);
128
+ const fetcher = start(cache, fetchLogs);
129
+ fetcher.enqueue(0, 0);
130
+ await vi.runAllTimersAsync();
131
+ expect(fetcher.isFetching).toBe(false);
132
+ fetcher.enqueue(0, 0);
133
+ await vi.runAllTimersAsync();
134
+ expect(cache.lines.get(0)).toBe("a");
135
+ });
136
+
137
+ it("requests only the incomplete chunks in view, up to the cap", async () => {
138
+ const cache = emptyCache(CHUNK * 4);
139
+ const filled: Record<number, string> = {};
140
+ for (let n = CHUNK; n < CHUNK * 2; n++) filled[n] = "x";
141
+ cache.mergeSlice(filled);
142
+ const fetchLogs = vi.fn((_from: number, _to: number) => ({}));
143
+ const fetcher = start(cache, fetchLogs);
144
+
145
+ fetcher.maybeRequestMissing(0, CHUNK * 4 - 1, 3);
146
+ await vi.runAllTimersAsync();
147
+ expect(fetchLogs.mock.calls.map(([from]) => from / CHUNK)).toEqual([0, 2]);
148
+ });
149
+
150
+ it("does nothing without a fetch function or after destroy", async () => {
151
+ const fetchLogs = vi.fn(() => ({}));
152
+ new LogFetcher(emptyCache(5), undefined).enqueue(0, 4);
153
+ const fetcher = start(emptyCache(5), fetchLogs);
154
+ fetcher.enqueue(0, 4);
155
+ fetcher.destroy();
156
+ await vi.runAllTimersAsync();
157
+ expect(fetchLogs).not.toHaveBeenCalled();
158
+ });
159
+ });
@@ -0,0 +1,46 @@
1
+ // SPDX-FileCopyrightText: PoppyCake, s.r.o.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
+ import { toast } from "./toast.svelte";
6
+
7
+ beforeEach(() => {
8
+ vi.useFakeTimers();
9
+ });
10
+
11
+ afterEach(() => {
12
+ for (const t of toast.items) toast.remove(t.id);
13
+ vi.useRealTimers();
14
+ });
15
+
16
+ describe("toast", () => {
17
+ it("expires after its duration", () => {
18
+ toast.success("Saved", { duration: 1000 });
19
+ expect(toast.items.map((t) => [t.type, t.message])).toEqual([["success", "Saved"]]);
20
+ vi.advanceTimersByTime(999);
21
+ expect(toast.items).toHaveLength(1);
22
+ vi.advanceTimersByTime(1);
23
+ expect(toast.items).toHaveLength(0);
24
+ });
25
+
26
+ it("folds a repeat into the shown toast and restarts its clock", () => {
27
+ const first = toast.error("Run failed", { duration: 1000 });
28
+ vi.advanceTimersByTime(800);
29
+ expect(toast.error("Run failed", { duration: 1000 })).toBe(first);
30
+ vi.advanceTimersByTime(800);
31
+ expect(toast.items).toHaveLength(1);
32
+ vi.advanceTimersByTime(200);
33
+ expect(toast.items).toHaveLength(0);
34
+ });
35
+
36
+ it("keeps a zero-duration toast until it is removed", () => {
37
+ const onClick = vi.fn();
38
+ const id = toast.info("Deleted run", { duration: 0, action: { label: "Undo", onClick } });
39
+ toast.warning("Disk almost full");
40
+ vi.advanceTimersByTime(60_000);
41
+ expect(toast.items.map((t) => t.id)).toEqual([id]);
42
+ expect(toast.items[0]?.action?.label).toBe("Undo");
43
+ toast.remove(id);
44
+ expect(toast.items).toHaveLength(0);
45
+ });
46
+ });