hub-index-status-ui-contract.test.mjs
141 lines 5.8 KB
Raw
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor ⚠ breaking 1 day ago
1 /**
2 * Contract tests for the Hub UI's auto-routing handling in `web/hub/hub.js`
3 * + `web/hub/index.html` + `web/hub/hub.css`.
4 *
5 * These tests lock in the static wiring that makes the Re-index button work
6 * with the bridge's three response shapes (200 sync, 202 background, 409
7 * already-running) and that renders the passive "Last indexed: N minutes ago"
8 * line next to the button.
9 *
10 * If any of these regress, the user-visible failure mode is one of:
11 * - 202 from the bridge silently looks like an error in the UI;
12 * - duplicate Re-index clicks while a background job runs do nothing useful;
13 * - "Last indexed" line never appears, defeating the auto-routing feedback loop.
14 */
15
16 import { readFileSync } from 'node:fs';
17 import { fileURLToPath } from 'node:url';
18 import { dirname, join } from 'node:path';
19 import test from 'node:test';
20 import assert from 'node:assert/strict';
21
22 const root = join(dirname(fileURLToPath(import.meta.url)), '..');
23 const hubJs = readFileSync(join(root, 'web/hub/hub.js'), 'utf8');
24 const hubHtml = readFileSync(join(root, 'web/hub/index.html'), 'utf8');
25 const hubCss = readFileSync(join(root, 'web/hub/hub.css'), 'utf8');
26
27 test('hub.js handles status:"background" response from POST /api/v1/index', () => {
28 // The 202 + status:'background' shape is what the bridge returns when the
29 // preflight estimator routes a re-index to the background function. Without
30 // this branch the UI would treat the response as a normal sync result and
31 // the toast would say "Indexed 0 notes, 0 chunks".
32 assert.match(
33 hubJs,
34 /out\s*&&\s*out\.status\s*===\s*['"]background['"]/,
35 'Re-index click handler must branch on status:"background"',
36 );
37 });
38
39 test('hub.js handles status:"already_running" response from POST /api/v1/index', () => {
40 // 409 + already_running is what the bridge returns when a second click arrives
41 // while a background job is in flight. Without this branch the api() helper
42 // would throw on the 409 and the user would see a generic error toast.
43 assert.match(
44 hubJs,
45 /out\s*&&\s*out\.status\s*===\s*['"]already_running['"]/,
46 'Re-index click handler must branch on status:"already_running"',
47 );
48 });
49
50 test('hub.js calls hubLoadIndexStatus({ pollWhileRunning: true }) on background route', () => {
51 // Polling while the background job runs is what flips the "Re-indexing in
52 // background…" line back to "Last indexed: just now" without the user
53 // reloading. Dropping this means the line stays stale until the next manual page reload.
54 assert.match(
55 hubJs,
56 /hubLoadIndexStatus\(\{\s*pollWhileRunning:\s*true\s*\}\)/,
57 'background-mode response must trigger polling so the status line auto-refreshes',
58 );
59 });
60
61 test('hub.js polls GET /api/v1/index/status', () => {
62 // The status endpoint is the authoritative source of "Last indexed" — it reads
63 // the sidecar maintained by both sync and background paths.
64 assert.match(
65 hubJs,
66 /api\(\s*['"]\/api\/v1\/index\/status['"]/,
67 'must call GET /api/v1/index/status to populate the "Last indexed" line',
68 );
69 });
70
71 test('hub.js relative time formatter handles common buckets', () => {
72 // Source-string assertion that the formatter exists; the actual logic gets a
73 // separate behavioral test below by re-evaluating the function in isolation.
74 assert.match(
75 hubJs,
76 /function\s+hubFormatRelativeTime\s*\(\s*epochMs\s*\)/,
77 'must expose hubFormatRelativeTime(epochMs) for the status line',
78 );
79 });
80
81 test('hub.js stops polling when no in-flight job', () => {
82 // The setInterval handle MUST be cleared once `inProgress: false` comes back,
83 // otherwise we keep polling forever and burn user CPU + bridge function calls.
84 assert.match(
85 hubJs,
86 /clearInterval\(\s*_hubIndexStatusPollTimer\s*\)/,
87 'must clear the poll timer when the background job finishes',
88 );
89 });
90
91 test('index.html has the hub-index-status placeholder near the Re-index button', () => {
92 assert.match(
93 hubHtml,
94 /<span\s+id="hub-index-status"/,
95 'must include <span id="hub-index-status"> placeholder for the "Last indexed" line',
96 );
97 // aria-live so screen readers announce when the status flips.
98 assert.match(
99 hubHtml,
100 /id="hub-index-status"[^>]*aria-live="polite"/,
101 'status placeholder must be aria-live="polite" so screen readers announce updates',
102 );
103 });
104
105 test('hub.css styles the .hub-index-status class', () => {
106 assert.match(
107 hubCss,
108 /\.hub-index-status\b/,
109 'must define a .hub-index-status rule',
110 );
111 assert.match(
112 hubCss,
113 /\.hub-index-status-running\b/,
114 'must define a .hub-index-status-running rule for the "background job in flight" state',
115 );
116 });
117
118 test('relative time formatter behavior (re-evaluate inline)', () => {
119 // Pull the function source out of hub.js, evaluate it in isolation, and assert
120 // bucket boundaries. This is the one place we can run real JS logic against
121 // the UI module without bringing up jsdom.
122 const m = hubJs.match(
123 /function\s+hubFormatRelativeTime\s*\(\s*epochMs\s*\)\s*\{[\s\S]*?\n\s{2}\}/,
124 );
125 assert.ok(m, 'must extract hubFormatRelativeTime source');
126 // Wrap it in a closure so we can call it with controlled `Date.now`.
127 const factory = new Function(
128 'mockNow',
129 `${m[0]}; return hubFormatRelativeTime;`,
130 );
131 // We can't easily inject Date.now, so we test against actual real-time math
132 // with a generous margin (+- 1 sec) to avoid flake.
133 const fn = factory();
134 const now = Date.now();
135 assert.strictEqual(fn(now), 'just now', 'now → "just now"');
136 assert.strictEqual(fn(now - 30 * 1000), 'just now', '30 s ago → "just now"');
137 assert.match(fn(now - 5 * 60 * 1000), /^5 minutes ago$/, '5 min ago');
138 assert.match(fn(now - 1 * 60 * 1000), /^1 minute ago$/, 'singular minute');
139 assert.match(fn(now - 90 * 60 * 1000), /^2 hours ago$/, '90 min ago → 2 hours');
140 assert.match(fn(now - 3 * 24 * 60 * 60 * 1000), /^3 days ago$/, '3 days ago');
141 });
File History 2 commits
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor 1 day ago
sha256:9103f98c89257ed2b01c237cea895dabb3e85ea337dccb1161c175e4422355b6 docs: accept Calendar Events v0 spec with Phase 0 security … Human 2 days ago