morphit/apps/web/scripts/chat-realtime-cadence-smoke.ts

105 lines
3.9 KiB
TypeScript

#!/usr/bin/env tsx
/**
* Smoke: chat surfaces (messages, inbox, notifications) stay inside the ≤6s
* "fastchat" window. Anchor 2026-07-08.
*
* THE BUGS THIS GUARDS AGAINST:
* - The chat message fallback poll was 60s, so when the SSE was flaky
* messages crawled in a full minute late ("chat is WAY too slow").
* - The inbox loaded once on mount with no refresh, so new messages /
* requests never appeared and a just-read conversation kept a stale dot +
* "N min ago" until a manual reload.
* - The global chat-unread poll (avatar/tab badge) was 60s.
*
* Locks all three cadences ≤6s and the inbox real-time refresh in place.
*/
import { readFileSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const WEB = join(__dirname, '..');
let pass = 0;
let fail = 0;
function check(name: string, ok: boolean): void {
if (ok) {
pass++;
console.log(` \u2713 ${name}`);
} else {
fail++;
console.error(` \u2717 ${name}`);
}
}
function num(src: string, name: string): number | null {
const m = new RegExp(`const ${name}\\s*=\\s*([0-9_]+)`).exec(src);
return m ? Number(m[1].replace(/_/g, '')) : null;
}
const svc = readFileSync(join(WEB, 'src', 'lib', 'chat', 'chatService.ts'), 'utf8');
const base = num(svc, 'FALLBACK_POLL_INTERVAL_MS');
const jitter = num(svc, 'POLL_JITTER_MS');
check('chatService FALLBACK_POLL_INTERVAL_MS is defined', base !== null);
check('chatService POLL_JITTER_MS is defined', jitter !== null);
check(
`chat message fallback worst-case (base+jitter) \u2264 6s (was 60s) — got ${base}+${jitter}`,
base !== null && jitter !== null && base + jitter <= 6_000
);
const cu = readFileSync(join(WEB, 'src', 'lib', 'notifications', 'chatUnread.ts'), 'utf8');
const cuPoll = num(cu, 'POLL_MS');
check(
`chat-unread (badge/tab) poll \u2264 6s — got ${cuPoll}`,
cuPoll !== null && cuPoll <= 6_000
);
const inbox = readFileSync(join(WEB, 'src', 'routes', '[lang]', 'chat', '+page.svelte'), 'utf8');
check('inbox has a refresh() that re-fetches conversations', /async function refresh\(/.test(inbox));
check(
'inbox polls on an interval (real-time) + a visibility listener',
/setInterval\(/.test(inbox) && /visibilitychange/.test(inbox)
);
const inboxPoll = num(inbox, 'POLL_MS');
check(`inbox poll \u2264 6s — got ${inboxPoll}`, inboxPoll !== null && inboxPoll <= 6_000);
check(
'inbox cleans up its poll + listener onDestroy (no leak)',
/onDestroy\(/.test(inbox) && /clearInterval\(/.test(inbox)
);
check(
'inbox background-poll failures keep the last list (fallback only on initial load)',
/if \(initial\) await fallbackToRecentPeers\(\)/.test(inbox)
);
// ── Global chat-activity SSE (sub-second, privacy-preserving) ───────────────
const gcas = readFileSync(join(WEB, 'src', 'lib', 'chat', 'globalChatActivityStream.ts'), 'utf8');
check(
'global chat-activity SSE connects same-origin to /v1/chat-activity/:me/stream',
/new EventSource\(`\/v1\/chat-activity\/\$\{encodeURIComponent\(me\)\}\/stream`\)/.test(gcas)
);
check(
'global SSE exports start + subscribe',
/export function startGlobalChatActivity/.test(gcas) &&
/export function subscribeChatActivity/.test(gcas)
);
check(
'global SSE debounces bursts (no refresh storm from a spammer)',
/FIRE_DEBOUNCE_MS/.test(gcas)
);
check(
'the ambient chat-unread channel starts the global SSE + re-polls on its ping',
/startGlobalChatActivity\(\)/.test(cu) && /subscribeChatActivity\(/.test(cu)
);
check(
'the inbox refreshes on the global SSE ping (sub-second) reusing the ambient stream',
/subscribeChatActivity\(/.test(inbox)
);
console.log('');
if (fail === 0) {
console.log(`\u2713 all ${pass} chat-realtime-cadence scenarios passed`);
} else {
console.error(`\u2717 ${fail} of ${pass + fail} chat-realtime-cadence checks FAILED`);
process.exit(1);
}