smoke.ts
Language TypeScript6.0 KB148 lines
#!/usr/bin/env tsx/*** Ninety smoke test. Read-only.** The unit tests all run offline against fixtures. This puts the real client* against the real API, end to end: auth, rate* limiting, pagination, roster hydration, recurrence detection.** pnpm smoke # every to-do the token can see* pnpm smoke --team "All Hands" # one team** The numbers it prints are the ones the dashboard would show. Check them* against what you see in the Ninety UI before trusting anything downstream:* that comparison is the real check, not a green test suite.** Reads NINETY_TOKEN from the environment. Never prints it.*/import { MERIDIAN_TZ, clientFromEnv, collapseSeries, isOverdue, localStamp, type Todo } from '@meridian/ninety-client'import { loadRoster } from './lib.js'import { errorMessage } from '@meridian/shared'const args = process.argv.slice(2)const teamArg = args.includes('--team') ? args[args.indexOf('--team') + 1] : undefinedconst NOW = new Date()function line(char = '-'): void {console.log(char.repeat(72))}async function main(): Promise<void> {const seed = await loadRoster()const client = clientFromEnv(process.env, seed)console.log(`Ninety smoke test, ${localStamp(NOW)} CT`)console.log(seed?.length ? `Seeded roster: ${seed.length} pinned` : 'No roster.json, harvesting only')line('=')// --- teams --------------------------------------------------------------const teams = await client.teams.list()console.log(`\nTEAMS (${teams.length})`)for (const t of teams) console.log(` ${t.name.padEnd(24)} ${t.id}`)let teamId: string | undefinedif (teamArg) {const match = teams.find((t) => t.name.toLowerCase().includes(teamArg.toLowerCase()))if (!match) {console.error(`\nNo team matching "${teamArg}". Options: ${teams.map((t) => t.name).join(', ')}`)process.exit(1)}teamId = match.idconsole.log(`\nFiltering to: ${match.name}`)}// --- the sweep ----------------------------------------------------------const started = Date.now()const snap = await client.snapshot(teamId ? { teamId } : {})const elapsed = ((Date.now() - started) / 1000).toFixed(1)line('=')console.log(`\nOPEN TO-DOS: ${snap.todos.length} (fetched in ${elapsed}s)`)// --- recurrence ---------------------------------------------------------const { total, collapsed, duplicates } = snap.recurrenceconsole.log(`\nRECURRENCE`)console.log(` raw rows ${total}`)console.log(` distinct work items ${collapsed}`)console.log(` duplicate rows ${duplicates}`)if (duplicates > 0) {const pct = Math.round((duplicates / total) * 100)console.log(`\n ${pct}% of rows are repeat occurrences of a recurring to-do.\n` +` A dashboard counting raw rows would overstate open work by ${duplicates}.\n` +` Decide before V1 ships whether the headline number is ${total} or ${collapsed}.`,)}// --- roster -------------------------------------------------------------const r = snap.rosterconsole.log(`\nROSTER`)console.log(` pinned from roster.json ${r.seed}`)console.log(` free from createdBy ${r.noted}`)console.log(` fetched via /users/{id} ${r.fetch}`)console.log(` unresolved ${r.unresolved}`)if (snap.unresolvedUsers.length) {console.log(`\n Unresolved ids (these render as "unknown user"):`)for (const id of snap.unresolvedUsers) console.log(` ${id}`)console.log(` Pin them in roster.json with a real name.`)}// --- what the dashboard would render ------------------------------------const open = collapseSeries(snap.todos).filter((t) => !t.completed)const overdue = open.filter((t) => isOverdue(t.dueDate, NOW))console.log(`\nOVERDUE: ${overdue.length} of ${open.length} open items`)const byOwner = new Map<string, Todo[]>()for (const t of open) {const key = t.owner.namebyOwner.set(key, [...(byOwner.get(key) ?? []), t])}console.log(`\nBY OWNER`)const rows = [...byOwner.entries()].sort((a, b) => b[1].length - a[1].length)for (const [owner, items] of rows) {const late = items.filter((t) => isOverdue(t.dueDate, NOW)).lengthconsole.log(` ${owner.padEnd(24)} ${String(items.length).padStart(3)} open${late ? `, ${late} overdue` : ''}`)}// Every row, so the list can be diffed line by line against the Ninety UI.console.log(`\nALL OPEN ITEMS (dates in ${MERIDIAN_TZ}, sorted by due date)`)const sorted = [...open].sort((a, b) => (a.dueDate ? Date.parse(a.dueDate) : Infinity) - (b.dueDate ? Date.parse(b.dueDate) : Infinity),)for (const t of sorted) {console.log(` ${t.dueLabel.padEnd(26)} ${t.owner.name.padEnd(18)} ${t.title.slice(0, 46)}`)}// --- sanity checks ------------------------------------------------------line('=')const problems: string[] = []const objectId = /^[0-9a-f]{24}$/if (open.some((t) => objectId.test(t.owner.name))) problems.push('An ObjectId leaked into an owner name.')if (r.unresolved > 0) problems.push(`${r.unresolved} user id(s) render as "unknown user".`)if (snap.todos.some((t) => t.deleted)) problems.push('A soft-deleted row survived filtering.')if (snap.todos.length === 100) {problems.push('Exactly 100 rows, suspiciously equal to one page. Check that pagination advanced.')}if (problems.length) {console.log('\nCHECK THESE:')for (const p of problems) console.log(` - ${p}`)} else {console.log('\nNo problems detected.')}console.log(`\nNext: compare the ALL OPEN ITEMS list above against the Ninety UI, date by date.`)console.log('Ninety serializes due dates as end-of-day LOCAL time in UTC, so a naive render')console.log('shows every date one day late. That bug shipped once already; these dates are')console.log(`formatted in ${MERIDIAN_TZ} and should match the UI exactly.`)console.log(`\nSynced at ${snap.syncedAt}`)}main().catch((err) => {console.error('\nSmoke test failed.\n')console.error(errorMessage(err))process.exit(1)})