Skip to content

Commit 3dc434b

Browse files
committed
ui: add /ui/events explorer + WS entity field
Closes bd-2qm.2 and bd-2qm.4. Verification: bun run typecheck; bun test packages/hub; bun test.
1 parent 8b30d6f commit 3dc434b

6 files changed

Lines changed: 887 additions & 2 deletions

File tree

.beads/issues.jsonl

Lines changed: 2 additions & 2 deletions
Large diffs are not rendered by default.

docs/protocol.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -589,6 +589,10 @@ All events sent to clients use this envelope:
589589
"topic_id": null,
590590
"topic_id2": null
591591
},
592+
"entity": {
593+
"type": "channel",
594+
"id": "ch_..."
595+
},
592596
"data": {
593597
"channel": {
594598
"id": "ch_...",
@@ -600,6 +604,13 @@ All events sent to clients use this envelope:
600604
}
601605
```
602606

607+
**Additive Fields (Gate D)**:
608+
- `entity` (optional): Entity reference for the event
609+
- `type`: Entity type (e.g., `"channel"`, `"topic"`, `"message"`, `"attachment"`)
610+
- `id`: Entity ID
611+
612+
This field is additive (added in Gate D); clients should tolerate its presence or absence.
613+
603614
### Subscription Filtering
604615

605616
Events are filtered based on `scope` fields:

packages/client/src/types.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,10 @@ export interface EventEnvelope {
3434
topic_id?: string | null;
3535
topic_id2?: string | null;
3636
};
37+
entity?: {
38+
type: string;
39+
id: string;
40+
};
3741
data: Record<string, unknown>;
3842
}
3943

packages/hub/src/ui.test.ts

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -311,4 +311,119 @@ describe("UI endpoints", () => {
311311
expect(html).toContain("BlinkMacSystemFont");
312312
expect(html).toContain("Segoe UI");
313313
});
314+
315+
test("GET /ui/events returns HTML events timeline page", async () => {
316+
const res = await fetch(`${baseUrl}/ui/events`);
317+
318+
expect(res.status).toBe(200);
319+
expect(res.headers.get("Content-Type")).toContain("text/html");
320+
321+
const html = await res.text();
322+
323+
// Verify it's HTML
324+
expect(html).toContain("<!DOCTYPE html>");
325+
expect(html).toContain("<html");
326+
expect(html).toContain("</html>");
327+
expect(html).toContain("Event Timeline");
328+
329+
// Verify init function
330+
expect(html).toContain("loadEvents");
331+
332+
// Verify WS connection
333+
expect(html).toContain("connectWebSocket");
334+
expect(html).toContain("new WebSocket");
335+
336+
// Verify auth token is embedded
337+
expect(html).toContain(authToken);
338+
});
339+
340+
test("Events page includes filtering controls", async () => {
341+
const res = await fetch(`${baseUrl}/ui/events`);
342+
const html = await res.text();
343+
344+
// Verify filter inputs
345+
expect(html).toContain('id="filter-name"');
346+
expect(html).toContain('id="filter-channel"');
347+
expect(html).toContain('id="filter-topic"');
348+
349+
// Verify pause button
350+
expect(html).toContain('id="pause-btn"');
351+
});
352+
353+
test("Events page uses XSS-safe rendering", async () => {
354+
const res = await fetch(`${baseUrl}/ui/events`);
355+
const html = await res.text();
356+
357+
// Verify textContent usage (not innerHTML for user data)
358+
expect(html).toContain("textContent");
359+
360+
// Verify JSON rendering is safe
361+
expect(html).toContain("JSON.stringify");
362+
363+
// No eval
364+
expect(html).not.toContain("eval(");
365+
});
366+
367+
test("Events page includes reconnection logic", async () => {
368+
const res = await fetch(`${baseUrl}/ui/events`);
369+
const html = await res.text();
370+
371+
// Verify reconnection handling
372+
expect(html).toContain("attemptReconnect");
373+
expect(html).toContain("reconnectAttempts");
374+
expect(html).toContain("retry-btn");
375+
});
376+
377+
test("Events page includes pause/resume with buffering", async () => {
378+
const res = await fetch(`${baseUrl}/ui/events`);
379+
const html = await res.text();
380+
381+
// Verify pause/resume logic
382+
expect(html).toContain("togglePause");
383+
expect(html).toContain("pausedBuffer");
384+
expect(html).toContain("flushPausedBuffer");
385+
386+
// Verify buffer limits
387+
expect(html).toContain("MAX_BUFFER");
388+
});
389+
390+
test("Events page includes navigation link in other pages", async () => {
391+
// Check channels page
392+
const channelsRes = await fetch(`${baseUrl}/ui`);
393+
const channelsHtml = await channelsRes.text();
394+
expect(channelsHtml).toContain('/ui/events');
395+
expect(channelsHtml).toContain('Events');
396+
397+
// Check topics page
398+
const topicsRes = await fetch(`${baseUrl}/ui/channels/${channelId}`);
399+
const topicsHtml = await topicsRes.text();
400+
expect(topicsHtml).toContain('/ui/events');
401+
402+
// Check messages page
403+
const messagesRes = await fetch(`${baseUrl}/ui/topics/${topicId}`);
404+
const messagesHtml = await messagesRes.text();
405+
expect(messagesHtml).toContain('/ui/events');
406+
});
407+
408+
test("Events page validates IDs before creating links", async () => {
409+
const res = await fetch(`${baseUrl}/ui/events`);
410+
const html = await res.text();
411+
412+
// Verify ID validation
413+
expect(html).toContain("isValidId");
414+
expect(html).toContain("ID_REGEX");
415+
});
416+
417+
test("Events page includes entity linking logic", async () => {
418+
const res = await fetch(`${baseUrl}/ui/events`);
419+
const html = await res.text();
420+
421+
// Verify entity rendering with links
422+
expect(html).toContain("renderEntity");
423+
expect(html).toContain("entity-link");
424+
425+
// Verify topic and message link generation
426+
expect(html).toContain("entity.type === 'topic'");
427+
expect(html).toContain("entity.type === 'message'");
428+
});
314429
});

0 commit comments

Comments
 (0)