mattrguerra/meridian-ops-boardmainPrivate on GitHub, published here in full

smoke.ts

Language TypeScript6.0 KB148 lines

scripts/smoke.ts

  1. #!/usr/bin/env tsx
  2. /**
  3. * Ninety smoke test. Read-only.
  4. *
  5. * The unit tests all run offline against fixtures. This puts the real client
  6. * against the real API, end to end: auth, rate
  7. * limiting, pagination, roster hydration, recurrence detection.
  8. *
  9. * pnpm smoke # every to-do the token can see
  10. * pnpm smoke --team "All Hands" # one team
  11. *
  12. * The numbers it prints are the ones the dashboard would show. Check them
  13. * against what you see in the Ninety UI before trusting anything downstream:
  14. * that comparison is the real check, not a green test suite.
  15. *
  16. * Reads NINETY_TOKEN from the environment. Never prints it.
  17. */
  18. import { MERIDIAN_TZ, clientFromEnv, collapseSeries, isOverdue, localStamp, type Todo } from '@meridian/ninety-client'
  19. import { loadRoster } from './lib.js'
  20. import { errorMessage } from '@meridian/shared'
  21. const args = process.argv.slice(2)
  22. const teamArg = args.includes('--team') ? args[args.indexOf('--team') + 1] : undefined
  23. const NOW = new Date()
  24. function line(char = '-'): void {
  25. console.log(char.repeat(72))
  26. }
  27. async function main(): Promise<void> {
  28. const seed = await loadRoster()
  29. const client = clientFromEnv(process.env, seed)
  30. console.log(`Ninety smoke test, ${localStamp(NOW)} CT`)
  31. console.log(seed?.length ? `Seeded roster: ${seed.length} pinned` : 'No roster.json, harvesting only')
  32. line('=')
  33. // --- teams --------------------------------------------------------------
  34. const teams = await client.teams.list()
  35. console.log(`\nTEAMS (${teams.length})`)
  36. for (const t of teams) console.log(` ${t.name.padEnd(24)} ${t.id}`)
  37. let teamId: string | undefined
  38. if (teamArg) {
  39. const match = teams.find((t) => t.name.toLowerCase().includes(teamArg.toLowerCase()))
  40. if (!match) {
  41. console.error(`\nNo team matching "${teamArg}". Options: ${teams.map((t) => t.name).join(', ')}`)
  42. process.exit(1)
  43. }
  44. teamId = match.id
  45. console.log(`\nFiltering to: ${match.name}`)
  46. }
  47. // --- the sweep ----------------------------------------------------------
  48. const started = Date.now()
  49. const snap = await client.snapshot(teamId ? { teamId } : {})
  50. const elapsed = ((Date.now() - started) / 1000).toFixed(1)
  51. line('=')
  52. console.log(`\nOPEN TO-DOS: ${snap.todos.length} (fetched in ${elapsed}s)`)
  53. // --- recurrence ---------------------------------------------------------
  54. const { total, collapsed, duplicates } = snap.recurrence
  55. console.log(`\nRECURRENCE`)
  56. console.log(` raw rows ${total}`)
  57. console.log(` distinct work items ${collapsed}`)
  58. console.log(` duplicate rows ${duplicates}`)
  59. if (duplicates > 0) {
  60. const pct = Math.round((duplicates / total) * 100)
  61. console.log(
  62. `\n ${pct}% of rows are repeat occurrences of a recurring to-do.\n` +
  63. ` A dashboard counting raw rows would overstate open work by ${duplicates}.\n` +
  64. ` Decide before V1 ships whether the headline number is ${total} or ${collapsed}.`,
  65. )
  66. }
  67. // --- roster -------------------------------------------------------------
  68. const r = snap.roster
  69. console.log(`\nROSTER`)
  70. console.log(` pinned from roster.json ${r.seed}`)
  71. console.log(` free from createdBy ${r.noted}`)
  72. console.log(` fetched via /users/{id} ${r.fetch}`)
  73. console.log(` unresolved ${r.unresolved}`)
  74. if (snap.unresolvedUsers.length) {
  75. console.log(`\n Unresolved ids (these render as "unknown user"):`)
  76. for (const id of snap.unresolvedUsers) console.log(` ${id}`)
  77. console.log(` Pin them in roster.json with a real name.`)
  78. }
  79. // --- what the dashboard would render ------------------------------------
  80. const open = collapseSeries(snap.todos).filter((t) => !t.completed)
  81. const overdue = open.filter((t) => isOverdue(t.dueDate, NOW))
  82. console.log(`\nOVERDUE: ${overdue.length} of ${open.length} open items`)
  83. const byOwner = new Map<string, Todo[]>()
  84. for (const t of open) {
  85. const key = t.owner.name
  86. byOwner.set(key, [...(byOwner.get(key) ?? []), t])
  87. }
  88. console.log(`\nBY OWNER`)
  89. const rows = [...byOwner.entries()].sort((a, b) => b[1].length - a[1].length)
  90. for (const [owner, items] of rows) {
  91. const late = items.filter((t) => isOverdue(t.dueDate, NOW)).length
  92. console.log(` ${owner.padEnd(24)} ${String(items.length).padStart(3)} open${late ? `, ${late} overdue` : ''}`)
  93. }
  94. // Every row, so the list can be diffed line by line against the Ninety UI.
  95. console.log(`\nALL OPEN ITEMS (dates in ${MERIDIAN_TZ}, sorted by due date)`)
  96. const sorted = [...open].sort(
  97. (a, b) => (a.dueDate ? Date.parse(a.dueDate) : Infinity) - (b.dueDate ? Date.parse(b.dueDate) : Infinity),
  98. )
  99. for (const t of sorted) {
  100. console.log(` ${t.dueLabel.padEnd(26)} ${t.owner.name.padEnd(18)} ${t.title.slice(0, 46)}`)
  101. }
  102. // --- sanity checks ------------------------------------------------------
  103. line('=')
  104. const problems: string[] = []
  105. const objectId = /^[0-9a-f]{24}$/
  106. if (open.some((t) => objectId.test(t.owner.name))) problems.push('An ObjectId leaked into an owner name.')
  107. if (r.unresolved > 0) problems.push(`${r.unresolved} user id(s) render as "unknown user".`)
  108. if (snap.todos.some((t) => t.deleted)) problems.push('A soft-deleted row survived filtering.')
  109. if (snap.todos.length === 100) {
  110. problems.push('Exactly 100 rows, suspiciously equal to one page. Check that pagination advanced.')
  111. }
  112. if (problems.length) {
  113. console.log('\nCHECK THESE:')
  114. for (const p of problems) console.log(` - ${p}`)
  115. } else {
  116. console.log('\nNo problems detected.')
  117. }
  118. console.log(`\nNext: compare the ALL OPEN ITEMS list above against the Ninety UI, date by date.`)
  119. console.log('Ninety serializes due dates as end-of-day LOCAL time in UTC, so a naive render')
  120. console.log('shows every date one day late. That bug shipped once already; these dates are')
  121. console.log(`formatted in ${MERIDIAN_TZ} and should match the UI exactly.`)
  122. console.log(`\nSynced at ${snap.syncedAt}`)
  123. }
  124. main().catch((err) => {
  125. console.error('\nSmoke test failed.\n')
  126. console.error(errorMessage(err))
  127. process.exit(1)
  128. })