@telorun/debug-ui 0.1.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.
package/package.json ADDED
@@ -0,0 +1,68 @@
1
+ {
2
+ "name": "@telorun/debug-ui",
3
+ "version": "0.1.0",
4
+ "description": "Browser-safe debug event UI for Telo: wire types, filtering, SSE client, and React components for watching a running app's event stream.",
5
+ "keywords": [
6
+ "telo",
7
+ "debug",
8
+ "events",
9
+ "sse"
10
+ ],
11
+ "author": "Bartosz Pasiński <bartosz.pasinski@codenet.pl>",
12
+ "license": "SEE LICENSE IN LICENSE",
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git+https://github.com/telorun/telo.git",
16
+ "directory": "packages/debug-ui"
17
+ },
18
+ "type": "module",
19
+ "main": "./dist/index.js",
20
+ "exports": {
21
+ ".": {
22
+ "source": "./src/index.ts",
23
+ "types": "./src/index.ts",
24
+ "bun": "./src/index.ts",
25
+ "import": "./dist/index.js",
26
+ "default": "./dist/index.js"
27
+ },
28
+ "./components": {
29
+ "source": "./src/components/index.ts",
30
+ "types": "./src/components/index.ts",
31
+ "bun": "./src/components/index.ts",
32
+ "import": "./dist/components/index.js",
33
+ "default": "./dist/components/index.js"
34
+ },
35
+ "./app-dist/*": "./app-dist/*",
36
+ "./app-single/*": "./app-single/*",
37
+ "./package.json": "./package.json"
38
+ },
39
+ "files": [
40
+ "dist/**",
41
+ "app-dist/**",
42
+ "app-single/**",
43
+ "src/**"
44
+ ],
45
+ "peerDependencies": {
46
+ "react": ">=18",
47
+ "react-dom": ">=18"
48
+ },
49
+ "devDependencies": {
50
+ "@types/node": "^20.0.0",
51
+ "@types/react": "^19",
52
+ "@types/react-dom": "^19",
53
+ "@vitejs/plugin-react": "^4",
54
+ "react": "19.2.3",
55
+ "react-dom": "19.2.3",
56
+ "typescript": "^5.0.0",
57
+ "vite": "^6",
58
+ "vite-plugin-singlefile": "^2.3.3",
59
+ "vitest": "^2.1.8"
60
+ },
61
+ "scripts": {
62
+ "build": "vite build && vite build --config vite.single.config.ts",
63
+ "build:lib": "tsc -p tsconfig.lib.json",
64
+ "typecheck": "tsc -p tsconfig.json --noEmit",
65
+ "test": "vitest run",
66
+ "test:watch": "vitest"
67
+ }
68
+ }
package/src/App.tsx ADDED
@@ -0,0 +1,10 @@
1
+ import { DebugWatcher } from "./components/DebugWatcher.js";
2
+
3
+ /**
4
+ * Standalone debug-watcher page. Served by a Telo runtime's debug server at `/`,
5
+ * so the SSE endpoint is `/events` on the same origin — no configuration needed.
6
+ */
7
+ export function App() {
8
+ const url = new URL("events", window.location.href).toString();
9
+ return <DebugWatcher url={url} />;
10
+ }
@@ -0,0 +1,86 @@
1
+ import { useEffect, useMemo, useReducer, useRef, useState } from "react";
2
+ import { distinctSuffixes, type EventFilter, matchesFilter } from "../filter.js";
3
+ import { connectDebugStream } from "../sse-client.js";
4
+ import type { DebugEvent } from "../wire.js";
5
+ import { EventTable } from "./EventTable.js";
6
+ import { FilterBar } from "./FilterBar.js";
7
+ import "./styles.css";
8
+
9
+ export interface DebugWatcherProps {
10
+ /** The producer's SSE endpoint, e.g. `http://localhost:9230/events`. */
11
+ url: string;
12
+ /** Ring-buffer cap; oldest events drop past this. Default 5000. */
13
+ maxEvents?: number;
14
+ }
15
+
16
+ /**
17
+ * The full debug-watcher view: connects to `url`, retains a bounded ring buffer,
18
+ * and renders a filterable, pausable, newest-first event stream. Pausing freezes
19
+ * the *view* while still collecting into the buffer, so nothing is lost on resume.
20
+ *
21
+ * Self-contained and prop-driven — the standalone app and the editor panel both
22
+ * mount this; only the `url` differs.
23
+ */
24
+ export function DebugWatcher({ url, maxEvents = 5000 }: DebugWatcherProps) {
25
+ const bufferRef = useRef<DebugEvent[]>([]);
26
+ const pausedRef = useRef(false);
27
+ const [, bump] = useReducer((n: number) => n + 1, 0);
28
+ const [filter, setFilter] = useState<EventFilter>({});
29
+ const [status, setStatus] = useState<"connecting" | "open" | "closed">("connecting");
30
+ const [paused, setPaused] = useState(false);
31
+
32
+ useEffect(() => {
33
+ bufferRef.current = [];
34
+ return connectDebugStream(url, {
35
+ onStatus: setStatus,
36
+ onEvent: (e) => {
37
+ const buf = bufferRef.current;
38
+ buf.push(e);
39
+ if (buf.length > maxEvents) buf.splice(0, buf.length - maxEvents);
40
+ if (!pausedRef.current) bump();
41
+ },
42
+ });
43
+ }, [url, maxEvents]);
44
+
45
+ function togglePause() {
46
+ const next = !pausedRef.current;
47
+ pausedRef.current = next;
48
+ setPaused(next);
49
+ if (!next) bump();
50
+ }
51
+
52
+ function clear() {
53
+ bufferRef.current = [];
54
+ bump();
55
+ }
56
+
57
+ // Blob pointers are relative to the producer origin; resolve them against the
58
+ // SSE URL so the standalone page and the editor (cross-origin) both work.
59
+ const resolveUrl = (rel: string) => new URL(rel, url).toString();
60
+
61
+ const all = bufferRef.current;
62
+ // `all.length` + `paused` are the render signal (the ref array is mutated in
63
+ // place); recompute the derived views against the current buffer snapshot.
64
+ const suffixes = useMemo(() => distinctSuffixes(all), [all.length, paused]);
65
+ const visible = useMemo(
66
+ () => all.filter((e) => matchesFilter(e, filter)),
67
+ [all.length, filter, paused],
68
+ );
69
+
70
+ return (
71
+ <div className="tdbg-root">
72
+ <FilterBar
73
+ filter={filter}
74
+ onChange={setFilter}
75
+ suffixes={suffixes}
76
+ status={status}
77
+ paused={paused}
78
+ onTogglePause={togglePause}
79
+ onClear={clear}
80
+ total={all.length}
81
+ shown={visible.length}
82
+ />
83
+ <EventTable events={visible} resolveUrl={resolveUrl} />
84
+ </div>
85
+ );
86
+ }
@@ -0,0 +1,65 @@
1
+ import { useState } from "react";
2
+ import { type DebugEvent, eventSuffix } from "../wire.js";
3
+ import { PayloadInspector } from "./PayloadInspector.js";
4
+
5
+ export interface EventTableProps {
6
+ events: readonly DebugEvent[];
7
+ resolveUrl: (rel: string) => string;
8
+ }
9
+
10
+ /** Newest-first list of events; each row expands to its payload. Expansion is
11
+ * keyed by event identity so it survives re-renders as new events stream in. */
12
+ export function EventTable({ events, resolveUrl }: EventTableProps) {
13
+ const [open, setOpen] = useState<Set<DebugEvent>>(new Set());
14
+
15
+ function toggle(e: DebugEvent) {
16
+ setOpen((prev) => {
17
+ const next = new Set(prev);
18
+ if (next.has(e)) next.delete(e);
19
+ else next.add(e);
20
+ return next;
21
+ });
22
+ }
23
+
24
+ if (events.length === 0) {
25
+ return <div className="tdbg-empty">No events match — waiting for the stream…</div>;
26
+ }
27
+
28
+ return (
29
+ <div className="tdbg-table" role="log" aria-live="polite">
30
+ {events
31
+ .map((e, i) => ({ e, i }))
32
+ .reverse()
33
+ .map(({ e, i }) => {
34
+ const suffix = eventSuffix(e.event);
35
+ return (
36
+ <div className="tdbg-row" key={i}>
37
+ <button
38
+ className="tdbg-rowhead"
39
+ onClick={() => toggle(e)}
40
+ aria-expanded={open.has(e)}
41
+ >
42
+ <span className="tdbg-caret">{open.has(e) ? "▾" : "▸"}</span>
43
+ <span className="tdbg-time">{e.timestamp.slice(11, 23)}</span>
44
+ <span className={`tdbg-badge tdbg-suffix-${suffix.toLowerCase()}`}>{suffix}</span>
45
+ <span className="tdbg-name">{e.event}</span>
46
+ <span className="tdbg-preview">{preview(e.payload)}</span>
47
+ </button>
48
+ {open.has(e) && <PayloadInspector value={e.payload} resolveUrl={resolveUrl} />}
49
+ </div>
50
+ );
51
+ })}
52
+ </div>
53
+ );
54
+ }
55
+
56
+ function preview(payload: unknown): string {
57
+ if (payload == null) return "";
58
+ let text: string;
59
+ try {
60
+ text = typeof payload === "string" ? payload : JSON.stringify(payload);
61
+ } catch {
62
+ return "";
63
+ }
64
+ return text.length > 120 ? `${text.slice(0, 120)}…` : text;
65
+ }
@@ -0,0 +1,77 @@
1
+ import type { EventFilter } from "../filter.js";
2
+
3
+ export interface FilterBarProps {
4
+ filter: EventFilter;
5
+ onChange: (filter: EventFilter) => void;
6
+ suffixes: string[];
7
+ status: "connecting" | "open" | "closed";
8
+ paused: boolean;
9
+ onTogglePause: () => void;
10
+ onClear: () => void;
11
+ total: number;
12
+ shown: number;
13
+ }
14
+
15
+ export function FilterBar({
16
+ filter,
17
+ onChange,
18
+ suffixes,
19
+ status,
20
+ paused,
21
+ onTogglePause,
22
+ onClear,
23
+ total,
24
+ shown,
25
+ }: FilterBarProps) {
26
+ const active = new Set(filter.suffixes ?? []);
27
+
28
+ function toggleSuffix(suffix: string) {
29
+ const next = new Set(active);
30
+ if (next.has(suffix)) next.delete(suffix);
31
+ else next.add(suffix);
32
+ onChange({ ...filter, suffixes: next.size ? [...next] : undefined });
33
+ }
34
+
35
+ return (
36
+ <div className="tdbg-bar">
37
+ <div className="tdbg-bar-row">
38
+ <span className={`tdbg-status tdbg-status-${status}`} title={`stream ${status}`} />
39
+ <input
40
+ className="tdbg-search"
41
+ placeholder="Search event name or payload…"
42
+ value={filter.text ?? ""}
43
+ onChange={(e) => onChange({ ...filter, text: e.target.value || undefined })}
44
+ />
45
+ <input
46
+ className="tdbg-kind"
47
+ placeholder="kind / resource…"
48
+ value={filter.kind ?? ""}
49
+ onChange={(e) => onChange({ ...filter, kind: e.target.value || undefined })}
50
+ />
51
+ <button className="tdbg-btn" onClick={onTogglePause}>
52
+ {paused ? "Resume" : "Pause"}
53
+ </button>
54
+ <button className="tdbg-btn" onClick={onClear}>
55
+ Clear
56
+ </button>
57
+ <span className="tdbg-counts">
58
+ {shown === total ? `${total}` : `${shown} / ${total}`} events
59
+ {paused ? " · paused" : ""}
60
+ </span>
61
+ </div>
62
+ {suffixes.length > 0 && (
63
+ <div className="tdbg-facets">
64
+ {suffixes.map((s) => (
65
+ <button
66
+ key={s}
67
+ className={`tdbg-facet${active.has(s) ? " tdbg-facet-on" : ""}`}
68
+ onClick={() => toggleSuffix(s)}
69
+ >
70
+ {s}
71
+ </button>
72
+ ))}
73
+ </div>
74
+ )}
75
+ </div>
76
+ );
77
+ }
@@ -0,0 +1,71 @@
1
+ import { blobDimensions, collectBlobs, formatBytes, type FoundBlob } from "../media.js";
2
+ import { isWireRef } from "../wire.js";
3
+
4
+ export interface PayloadInspectorProps {
5
+ value: unknown;
6
+ /** Turns a relative blob path (`blobs/<id>`) into a fetchable URL against the
7
+ * producer origin. */
8
+ resolveUrl: (rel: string) => string;
9
+ }
10
+
11
+ /** Pretty-prints an event payload. Binary lives in the blob store, not the
12
+ * payload — any `{ $blob }` pointers are rendered as media (images inline, other
13
+ * files as download links) above the JSON, which itself only carries the small
14
+ * pointer descriptors. */
15
+ export function PayloadInspector({ value, resolveUrl }: PayloadInspectorProps) {
16
+ if (value === undefined) {
17
+ return <div className="tdbg-payload tdbg-muted">(no payload)</div>;
18
+ }
19
+ if (isWireRef(value)) {
20
+ return (
21
+ <div className="tdbg-payload">
22
+ <span className="tdbg-ref">
23
+ {value.kind} <span className="tdbg-muted">·</span> {value.name}
24
+ </span>
25
+ </div>
26
+ );
27
+ }
28
+
29
+ const blobs = collectBlobs(value);
30
+ let text: string;
31
+ try {
32
+ text = JSON.stringify(value, null, 2);
33
+ } catch {
34
+ text = String(value);
35
+ }
36
+
37
+ return (
38
+ <div className="tdbg-payload-wrap">
39
+ {blobs.length > 0 && (
40
+ <div className="tdbg-media">
41
+ {blobs.map((b, i) => (
42
+ <BlobView key={i} found={b} resolveUrl={resolveUrl} />
43
+ ))}
44
+ </div>
45
+ )}
46
+ <pre className="tdbg-payload">{text}</pre>
47
+ </div>
48
+ );
49
+ }
50
+
51
+ function BlobView({ found, resolveUrl }: { found: FoundBlob; resolveUrl: (rel: string) => string }) {
52
+ const { path, blob, parent } = found;
53
+ const src = resolveUrl(blob.$blob);
54
+ const dims = blobDimensions(parent);
55
+ const caption = [path, blob.mediaType, formatBytes(blob.byteLength), dims].filter(Boolean).join(" · ");
56
+
57
+ return (
58
+ <figure className="tdbg-blob">
59
+ {blob.mediaType.startsWith("image/") ? (
60
+ <a href={src} target="_blank" rel="noreferrer">
61
+ <img className="tdbg-blob-img" src={src} alt={path} loading="lazy" />
62
+ </a>
63
+ ) : (
64
+ <a className="tdbg-blob-file" href={src} target="_blank" rel="noreferrer" download>
65
+ ⬇ {blob.mediaType}
66
+ </a>
67
+ )}
68
+ <figcaption className="tdbg-blob-cap">{caption}</figcaption>
69
+ </figure>
70
+ );
71
+ }
@@ -0,0 +1,5 @@
1
+ // React component surface, consumed by the editor panel and the standalone app.
2
+ export { DebugWatcher, type DebugWatcherProps } from "./DebugWatcher.js";
3
+ export { EventTable } from "./EventTable.js";
4
+ export { FilterBar, type FilterBarProps } from "./FilterBar.js";
5
+ export { PayloadInspector } from "./PayloadInspector.js";
@@ -0,0 +1,219 @@
1
+ .tdbg-root {
2
+ --tdbg-bg: #0f1115;
3
+ --tdbg-panel: #161a21;
4
+ --tdbg-line: #232a34;
5
+ --tdbg-text: #d7dce3;
6
+ --tdbg-muted: #7a8493;
7
+ --tdbg-accent: #4f9cf0;
8
+ display: flex;
9
+ flex-direction: column;
10
+ height: 100%;
11
+ min-height: 0;
12
+ font: 13px/1.5 ui-monospace, "SF Mono", Menlo, Consolas, monospace;
13
+ color: var(--tdbg-text);
14
+ background: var(--tdbg-bg);
15
+ }
16
+
17
+ .tdbg-bar {
18
+ position: sticky;
19
+ top: 0;
20
+ z-index: 1;
21
+ padding: 8px 10px;
22
+ background: var(--tdbg-panel);
23
+ border-bottom: 1px solid var(--tdbg-line);
24
+ }
25
+ .tdbg-bar-row {
26
+ display: flex;
27
+ align-items: center;
28
+ gap: 8px;
29
+ }
30
+ .tdbg-search {
31
+ flex: 1;
32
+ }
33
+ .tdbg-search,
34
+ .tdbg-kind {
35
+ padding: 5px 8px;
36
+ color: var(--tdbg-text);
37
+ background: var(--tdbg-bg);
38
+ border: 1px solid var(--tdbg-line);
39
+ border-radius: 5px;
40
+ font: inherit;
41
+ }
42
+ .tdbg-kind {
43
+ width: 140px;
44
+ }
45
+ .tdbg-btn {
46
+ padding: 5px 10px;
47
+ color: var(--tdbg-text);
48
+ background: var(--tdbg-bg);
49
+ border: 1px solid var(--tdbg-line);
50
+ border-radius: 5px;
51
+ cursor: pointer;
52
+ font: inherit;
53
+ }
54
+ .tdbg-btn:hover {
55
+ border-color: var(--tdbg-accent);
56
+ }
57
+ .tdbg-counts {
58
+ color: var(--tdbg-muted);
59
+ white-space: nowrap;
60
+ }
61
+ .tdbg-status {
62
+ width: 9px;
63
+ height: 9px;
64
+ border-radius: 50%;
65
+ background: var(--tdbg-muted);
66
+ flex: 0 0 auto;
67
+ }
68
+ .tdbg-status-open {
69
+ background: #3fb950;
70
+ }
71
+ .tdbg-status-connecting {
72
+ background: #d29922;
73
+ }
74
+ .tdbg-status-closed {
75
+ background: #f85149;
76
+ }
77
+
78
+ .tdbg-facets {
79
+ display: flex;
80
+ flex-wrap: wrap;
81
+ gap: 6px;
82
+ margin-top: 8px;
83
+ }
84
+ .tdbg-facet {
85
+ padding: 2px 8px;
86
+ color: var(--tdbg-muted);
87
+ background: var(--tdbg-bg);
88
+ border: 1px solid var(--tdbg-line);
89
+ border-radius: 999px;
90
+ cursor: pointer;
91
+ font: inherit;
92
+ }
93
+ .tdbg-facet-on {
94
+ color: #fff;
95
+ border-color: var(--tdbg-accent);
96
+ background: rgba(79, 156, 240, 0.18);
97
+ }
98
+
99
+ .tdbg-table {
100
+ flex: 1;
101
+ min-height: 0;
102
+ overflow: auto;
103
+ }
104
+ .tdbg-empty {
105
+ padding: 24px;
106
+ color: var(--tdbg-muted);
107
+ text-align: center;
108
+ }
109
+ .tdbg-row {
110
+ border-bottom: 1px solid var(--tdbg-line);
111
+ }
112
+ .tdbg-rowhead {
113
+ display: flex;
114
+ align-items: baseline;
115
+ gap: 8px;
116
+ width: 100%;
117
+ padding: 4px 10px;
118
+ background: none;
119
+ border: none;
120
+ color: inherit;
121
+ cursor: pointer;
122
+ text-align: left;
123
+ font: inherit;
124
+ }
125
+ .tdbg-rowhead:hover {
126
+ background: var(--tdbg-panel);
127
+ }
128
+ .tdbg-caret {
129
+ width: 12px;
130
+ color: var(--tdbg-muted);
131
+ }
132
+ .tdbg-time {
133
+ color: var(--tdbg-muted);
134
+ white-space: nowrap;
135
+ }
136
+ .tdbg-badge {
137
+ padding: 1px 6px;
138
+ border-radius: 4px;
139
+ background: var(--tdbg-line);
140
+ color: var(--tdbg-text);
141
+ white-space: nowrap;
142
+ }
143
+ .tdbg-suffix-failed,
144
+ .tdbg-suffix-rejected,
145
+ .tdbg-suffix-cancelled {
146
+ background: rgba(248, 81, 73, 0.2);
147
+ color: #ff8079;
148
+ }
149
+ .tdbg-suffix-invoked,
150
+ .tdbg-suffix-listening,
151
+ .tdbg-suffix-started,
152
+ .tdbg-suffix-initialized {
153
+ background: rgba(63, 185, 80, 0.18);
154
+ color: #5fd673;
155
+ }
156
+ .tdbg-name {
157
+ white-space: nowrap;
158
+ }
159
+ .tdbg-preview {
160
+ color: var(--tdbg-muted);
161
+ overflow: hidden;
162
+ text-overflow: ellipsis;
163
+ white-space: nowrap;
164
+ }
165
+ .tdbg-payload-wrap {
166
+ background: var(--tdbg-panel);
167
+ }
168
+ .tdbg-payload {
169
+ margin: 0;
170
+ padding: 8px 10px 10px 30px;
171
+ background: var(--tdbg-panel);
172
+ overflow: auto;
173
+ white-space: pre-wrap;
174
+ word-break: break-word;
175
+ }
176
+ .tdbg-media {
177
+ display: flex;
178
+ flex-wrap: wrap;
179
+ gap: 12px;
180
+ padding: 10px 10px 4px 30px;
181
+ }
182
+ .tdbg-blob {
183
+ margin: 0;
184
+ display: flex;
185
+ flex-direction: column;
186
+ gap: 4px;
187
+ max-width: 320px;
188
+ }
189
+ .tdbg-blob-img {
190
+ max-width: 320px;
191
+ max-height: 240px;
192
+ border: 1px solid var(--tdbg-line);
193
+ border-radius: 6px;
194
+ background:
195
+ repeating-conic-gradient(#1b202840 0% 25%, transparent 0% 50%) 50% / 16px 16px;
196
+ object-fit: contain;
197
+ }
198
+ .tdbg-blob-file {
199
+ display: inline-block;
200
+ padding: 8px 12px;
201
+ color: var(--tdbg-accent);
202
+ border: 1px solid var(--tdbg-line);
203
+ border-radius: 6px;
204
+ text-decoration: none;
205
+ }
206
+ .tdbg-blob-file:hover {
207
+ border-color: var(--tdbg-accent);
208
+ }
209
+ .tdbg-blob-cap {
210
+ color: var(--tdbg-muted);
211
+ font-size: 11px;
212
+ word-break: break-all;
213
+ }
214
+ .tdbg-muted {
215
+ color: var(--tdbg-muted);
216
+ }
217
+ .tdbg-ref {
218
+ color: var(--tdbg-accent);
219
+ }
package/src/css.d.ts ADDED
@@ -0,0 +1,3 @@
1
+ // Allow `import "./styles.css"` from component modules under tsc (Vite handles
2
+ // the actual asset at build time).
3
+ declare module "*.css";
@@ -0,0 +1,53 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { distinctSuffixes, matchesFilter } from "./filter.js";
3
+ import type { DebugEvent } from "./wire.js";
4
+
5
+ const ev = (event: string, payload?: unknown): DebugEvent => ({
6
+ timestamp: "2026-01-01T00:00:00.000Z",
7
+ event,
8
+ payload,
9
+ });
10
+
11
+ describe("matchesFilter", () => {
12
+ it("empty filter matches everything", () => {
13
+ expect(matchesFilter(ev("A.B.Invoked"), {})).toBe(true);
14
+ });
15
+
16
+ it("filters by suffix", () => {
17
+ const f = { suffixes: ["Failed"] };
18
+ expect(matchesFilter(ev("A.B.Failed"), f)).toBe(true);
19
+ expect(matchesFilter(ev("A.B.Invoked"), f)).toBe(false);
20
+ });
21
+
22
+ it("filters by kind substring (case-insensitive)", () => {
23
+ expect(matchesFilter(ev("Http.Server.Listening"), { kind: "server" })).toBe(true);
24
+ expect(matchesFilter(ev("Sql.Query.Invoked"), { kind: "server" })).toBe(false);
25
+ });
26
+
27
+ it("text searches name and payload", () => {
28
+ expect(matchesFilter(ev("A.Invoked", { port: 5599 }), { text: "5599" })).toBe(true);
29
+ expect(matchesFilter(ev("A.Invoked", { port: 1 }), { text: "listening" })).toBe(false);
30
+ expect(matchesFilter(ev("Server.Listening"), { text: "listening" })).toBe(true);
31
+ });
32
+
33
+ it("AND-combines fields", () => {
34
+ const f = { suffixes: ["Invoked"], text: "5599" };
35
+ expect(matchesFilter(ev("A.Invoked", { port: 5599 }), f)).toBe(true);
36
+ expect(matchesFilter(ev("A.Failed", { port: 5599 }), f)).toBe(false);
37
+ });
38
+
39
+ it("tolerates a non-serializable payload in text search", () => {
40
+ const cyclic: Record<string, unknown> = {};
41
+ cyclic.self = cyclic;
42
+ expect(matchesFilter(ev("A.Invoked", cyclic), { text: "A" })).toBe(true);
43
+ });
44
+ });
45
+
46
+ describe("distinctSuffixes", () => {
47
+ it("returns sorted distinct suffixes", () => {
48
+ expect(distinctSuffixes([ev("A.Invoked"), ev("B.Failed"), ev("C.Invoked")])).toEqual([
49
+ "Failed",
50
+ "Invoked",
51
+ ]);
52
+ });
53
+ });