hub-index-status-ui-contract.test.mjs
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