@app-vitals/agent-admin 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (325) hide show
  1. package/Dockerfile +43 -0
  2. package/README.md +72 -2
  3. package/e2e/agents-page.e2e.ts +338 -0
  4. package/e2e/chat-degraded.e2e.ts +153 -0
  5. package/e2e/chat-page.e2e.ts +169 -0
  6. package/e2e/chat-progress.e2e.ts +206 -0
  7. package/e2e/chat-thread-page.e2e.ts +444 -0
  8. package/e2e/login-page.e2e.ts +148 -0
  9. package/e2e/test-server.ts +427 -0
  10. package/openapi.json +6098 -0
  11. package/openapi.platform.json +5553 -0
  12. package/package.json +64 -4
  13. package/playwright.config.ts +25 -0
  14. package/prisma/client/browser.ts +134 -0
  15. package/prisma/client/client.ts +158 -0
  16. package/prisma/client/commonInputTypes.ts +578 -0
  17. package/prisma/client/enums.ts +15 -0
  18. package/prisma/client/internal/class.ts +424 -0
  19. package/prisma/client/internal/prismaNamespace.ts +2898 -0
  20. package/prisma/client/internal/prismaNamespaceBrowser.ts +476 -0
  21. package/prisma/client/models/Account.ts +1774 -0
  22. package/prisma/client/models/AccountInvite.ts +1386 -0
  23. package/prisma/client/models/AccountMember.ts +1351 -0
  24. package/prisma/client/models/Agent.ts +4554 -0
  25. package/prisma/client/models/AgentAsk.ts +1373 -0
  26. package/prisma/client/models/AgentChatTokenUsageDailyByModel.ts +1638 -0
  27. package/prisma/client/models/AgentCronJob.ts +2416 -0
  28. package/prisma/client/models/AgentCronRun.ts +2858 -0
  29. package/prisma/client/models/AgentCronRunModelBreakdown.ts +1532 -0
  30. package/prisma/client/models/AgentCronRunSkillUsage.ts +1642 -0
  31. package/prisma/client/models/AgentEnv.ts +1421 -0
  32. package/prisma/client/models/AgentGitHubInstallationsSnapshot.ts +1289 -0
  33. package/prisma/client/models/AgentMember.ts +1316 -0
  34. package/prisma/client/models/AgentPhaseMethodology.ts +1355 -0
  35. package/prisma/client/models/AgentPlugin.ts +1421 -0
  36. package/prisma/client/models/AgentToken.ts +1380 -0
  37. package/prisma/client/models/AgentTool.ts +1351 -0
  38. package/prisma/client/models/AgentWorkQueueSnapshot.ts +1297 -0
  39. package/prisma/client/models/ChatThreadWatch.ts +1183 -0
  40. package/prisma/client/models/PushSubscription.ts +1233 -0
  41. package/prisma/client/models/SessionAlertState.ts +1187 -0
  42. package/prisma/client/models/SessionFollow.ts +1187 -0
  43. package/prisma/client/models/UserNotificationPrefs.ts +1221 -0
  44. package/prisma/client/models.ts +34 -0
  45. package/prisma/migrations/20260608000000_init/migration.sql +112 -0
  46. package/prisma/migrations/20260616000000_agent_members/migration.sql +18 -0
  47. package/prisma/migrations/20260624000000_add_agent_self_hosted/migration.sql +2 -0
  48. package/prisma/migrations/20260624120000_add_agent_repos/migration.sql +3 -0
  49. package/prisma/migrations/20260625000000_add_agent_cron_run/migration.sql +26 -0
  50. package/prisma/migrations/20260626000000_add_cron_run_token_fields/migration.sql +10 -0
  51. package/prisma/migrations/20260627000000_add_agent_chat_token_usage_daily/migration.sql +18 -0
  52. package/prisma/migrations/20260627010000_add_agent_chat_token_usage_fk/migration.sql +2 -0
  53. package/prisma/migrations/20260701000000_add_chat_threads_messages/migration.sql +37 -0
  54. package/prisma/migrations/20260701010000_drop_chat_threads_messages/migration.sql +17 -0
  55. package/prisma/migrations/20260701020000_add_cron_run_model_breakdown/migration.sql +23 -0
  56. package/prisma/migrations/20260701020000_chat_token_by_model/migration.sql +25 -0
  57. package/prisma/migrations/20260701030000_add_secret_flag_to_agent_env/migration.sql +2 -0
  58. package/prisma/migrations/20260701030000_drop_cron_run_legacy_columns/migration.sql +5 -0
  59. package/prisma/migrations/20260706000000_consolidate_cron_run_token_accounting/migration.sql +54 -0
  60. package/prisma/migrations/20260709000000_add_phase_to_agent_cron_run/migration.sql +8 -0
  61. package/prisma/migrations/20260717000000_add_item_tracking_to_agent_cron_run/migration.sql +10 -0
  62. package/prisma/migrations/20260717010000_add_agent_work_queue_snapshot/migration.sql +20 -0
  63. package/prisma/migrations/20260718000000_add_cron_phase_columns/migration.sql +25 -0
  64. package/prisma/migrations/20260718100000_drop_agent_cron_run_phase_column/migration.sql +8 -0
  65. package/prisma/migrations/20260724000000_add_agent_type_name/migration.sql +3 -0
  66. package/prisma/migrations/20260725232433_add_agent_author_allowlist/migration.sql +2 -0
  67. package/prisma/migrations/20260727120000_add_not_null_to_agent_author_allowlist/migration.sql +3 -0
  68. package/prisma/migrations/20260731000000_add_session_id_to_agent_cron_run/migration.sql +2 -0
  69. package/prisma/migrations/20260821000000_add_agent_restrict_slack_to_members/migration.sql +3 -0
  70. package/prisma/migrations/20260901000000_add_push_subscription_and_chat_thread_watch/migration.sql +40 -0
  71. package/prisma/migrations/20260903000000_add_agent_patch_author_allowlist/migration.sql +2 -0
  72. package/prisma/migrations/20260903120000_add_agent_review_author_allowlist/migration.sql +18 -0
  73. package/prisma/migrations/20260903150000_drop_agent_author_allowlist/migration.sql +14 -0
  74. package/prisma/migrations/20260905000000_add_started_at_index_to_agent_cron_run/migration.sql +11 -0
  75. package/prisma/migrations/20260906000000_add_session_follow_notification_prefs_alert_state/migration.sql +47 -0
  76. package/prisma/migrations/20260911000000_backfill_auto_follow_since/migration.sql +16 -0
  77. package/prisma/migrations/20260923115814_add_last_heartbeat_at_to_agent_cron_run/migration.sql +2 -0
  78. package/prisma/migrations/20260924120000_add_trial_expiry_fields_to_agent/migration.sql +6 -0
  79. package/prisma/migrations/20260929000000_add_agent_phase_methodology/migration.sql +21 -0
  80. package/prisma/migrations/20260929120000_add_agent_policy_fields/migration.sql +15 -0
  81. package/prisma/migrations/20261005000000_add_agent_github_installations_snapshot/migration.sql +21 -0
  82. package/prisma/migrations/20261008000000_add_cron_run_telemetry/migration.sql +50 -0
  83. package/prisma/migrations/20261008010000_add_account_models/migration.sql +48 -0
  84. package/prisma/migrations/20261008020000_add_cron_lockdown_disabled_at/migration.sql +3 -0
  85. package/prisma/migrations/20261009000000_chat_token_usage_bigint/migration.sql +7 -0
  86. package/prisma/migrations/20261010000000_add_cron_run_command_variant/migration.sql +2 -0
  87. package/prisma/migrations/20261019000000_add_agent_anthropic_identity/migration.sql +9 -0
  88. package/prisma/migrations/20261025000000_add_agent_ask/migration.sql +23 -0
  89. package/prisma/migrations/migration_lock.toml +3 -0
  90. package/prisma/schema/coding.prisma +85 -0
  91. package/prisma/schema/schema.prisma +506 -0
  92. package/prisma.config.ts +34 -0
  93. package/pwa-assets/icons/apple-touch-icon.png +0 -0
  94. package/pwa-assets/icons/favicon-32.png +0 -0
  95. package/pwa-assets/icons/icon-192.png +0 -0
  96. package/pwa-assets/icons/icon-512.png +0 -0
  97. package/pwa-assets/icons/icon-maskable-192.png +0 -0
  98. package/pwa-assets/icons/icon-maskable-512.png +0 -0
  99. package/src/account-agent-create.ts +42 -0
  100. package/src/account-agent-create.unit.test.ts +28 -0
  101. package/src/account-created-notifier.ts +71 -0
  102. package/src/account-created-notifier.unit.test.ts +70 -0
  103. package/src/account-email.ts +9 -0
  104. package/src/account-email.unit.test.ts +21 -0
  105. package/src/account-invites.ts +70 -0
  106. package/src/account-lifecycle.integration.test.ts +329 -0
  107. package/src/account-lifecycle.ts +206 -0
  108. package/src/account-lifecycle.unit.test.ts +108 -0
  109. package/src/account-members.ts +128 -0
  110. package/src/account-onboarding.integration.test.ts +101 -0
  111. package/src/account-onboarding.ts +95 -0
  112. package/src/account-trial-expiry-sweeper.ts +85 -0
  113. package/src/account-trial-warning-sweeper.integration.test.ts +228 -0
  114. package/src/account-trial-warning-sweeper.ts +216 -0
  115. package/src/accounts-api.integration.test.ts +196 -0
  116. package/src/accounts-api.smoke.test.ts +292 -0
  117. package/src/accounts-api.ts +289 -0
  118. package/src/accounts-api.unit.test.ts +93 -0
  119. package/src/accounts.integration.test.ts +178 -0
  120. package/src/accounts.ts +208 -0
  121. package/src/admin-boot-config.ts +574 -0
  122. package/src/admin-boot-config.unit.test.ts +28 -0
  123. package/src/admin-boot.boot-order.unit.test.ts +91 -0
  124. package/src/admin-boot.ts +690 -0
  125. package/src/admin-dev-login.smoke.test.ts +317 -0
  126. package/src/admin-extension.ts +432 -0
  127. package/src/admin-extension.unit.test.ts +532 -0
  128. package/src/admin-local-agent.smoke.test.ts +1643 -0
  129. package/src/admin-spec.smoke.test.ts +96 -0
  130. package/src/admin-spec.ts +186 -0
  131. package/src/admin-ui-account-nav.ts +20 -0
  132. package/src/admin-ui-account-pages.ts +131 -0
  133. package/src/admin-ui-account-pages.unit.test.ts +112 -0
  134. package/src/admin-ui-account.smoke.test.ts +286 -0
  135. package/src/admin-ui-account.ts +185 -0
  136. package/src/admin-ui-accounts-pages.ts +202 -0
  137. package/src/admin-ui-accounts-pages.unit.test.ts +138 -0
  138. package/src/admin-ui-accounts.smoke.test.ts +434 -0
  139. package/src/admin-ui-accounts.ts +266 -0
  140. package/src/admin-ui-layout.ts +60 -0
  141. package/src/admin-ui-layout.unit.test.ts +140 -0
  142. package/src/admin-ui-pages.ts +3489 -0
  143. package/src/admin-ui-pages.unit.test.ts +4355 -0
  144. package/src/admin-ui-push-test.smoke.test.ts +125 -0
  145. package/src/admin-ui-push-test.ts +102 -0
  146. package/src/admin-ui-styles.ts +656 -0
  147. package/src/admin-ui-styles.unit.test.ts +103 -0
  148. package/src/admin-ui.fixtures.ts +457 -0
  149. package/src/admin-ui.smoke.test.ts +7227 -0
  150. package/src/admin-ui.ts +3824 -0
  151. package/src/agent-account-assignment.ts +49 -0
  152. package/src/agent-account-assignment.unit.test.ts +99 -0
  153. package/src/agent-anthropic-identity.integration.test.ts +99 -0
  154. package/src/agent-anthropic-identity.ts +78 -0
  155. package/src/agent-anthropic-identity.unit.test.ts +77 -0
  156. package/src/agent-ask-config.ts +59 -0
  157. package/src/agent-ask-config.unit.test.ts +70 -0
  158. package/src/agent-ask.integration.test.ts +175 -0
  159. package/src/agent-ask.smoke.test.ts +457 -0
  160. package/src/agent-ask.ts +378 -0
  161. package/src/agent-chat-tokens.integration.test.ts +420 -0
  162. package/src/agent-chat-tokens.smoke.test.ts +551 -0
  163. package/src/agent-chat-tokens.ts +259 -0
  164. package/src/agent-cron-jobs.integration.test.ts +582 -0
  165. package/src/agent-cron-jobs.ts +615 -0
  166. package/src/agent-cron-jobs.unit.test.ts +420 -0
  167. package/src/agent-cron-run-stats.integration.test.ts +1551 -0
  168. package/src/agent-cron-run-stats.smoke.test.ts +953 -0
  169. package/src/agent-cron-run-stats.ts +752 -0
  170. package/src/agent-cron-runs.integration.test.ts +1491 -0
  171. package/src/agent-cron-runs.ts +489 -0
  172. package/src/agent-deletion-checklist.ts +128 -0
  173. package/src/agent-deletion-checklist.unit.test.ts +201 -0
  174. package/src/agent-deletion-per-agent.integration.test.ts +323 -0
  175. package/src/agent-deletion.integration.test.ts +323 -0
  176. package/src/agent-deletion.ts +329 -0
  177. package/src/agent-deletion.unit.test.ts +412 -0
  178. package/src/agent-envs.integration.test.ts +323 -0
  179. package/src/agent-envs.ts +244 -0
  180. package/src/agent-github-installations.integration.test.ts +115 -0
  181. package/src/agent-github-installations.ts +52 -0
  182. package/src/agent-manifest.ts +685 -0
  183. package/src/agent-manifest.unit.test.ts +870 -0
  184. package/src/agent-members.ts +69 -0
  185. package/src/agent-members.unit.test.ts +250 -0
  186. package/src/agent-patch-author-allowlist.integration.test.ts +218 -0
  187. package/src/agent-plugins.integration.test.ts +157 -0
  188. package/src/agent-plugins.ts +95 -0
  189. package/src/agent-plugins.unit.test.ts +43 -0
  190. package/src/agent-provisioner-per-agent.integration.test.ts +949 -0
  191. package/src/agent-provisioner.integration.test.ts +1245 -0
  192. package/src/agent-provisioner.ts +1650 -0
  193. package/src/agent-provisioner.unit.test.ts +1852 -0
  194. package/src/agent-tokens.integration.test.ts +150 -0
  195. package/src/agent-tokens.ts +112 -0
  196. package/src/agent-tools.integration.test.ts +129 -0
  197. package/src/agent-tools.ts +111 -0
  198. package/src/agents-api.integration.test.ts +705 -0
  199. package/src/agents-api.sentry.smoke.test.ts +478 -0
  200. package/src/agents-api.smoke.test.ts +5986 -0
  201. package/src/agents-api.ts +2826 -0
  202. package/src/agents.integration.test.ts +512 -0
  203. package/src/agents.ts +1097 -0
  204. package/src/agents.unit.test.ts +1736 -0
  205. package/src/anthropic-credential-guard.ts +44 -0
  206. package/src/anthropic-credential-guard.unit.test.ts +88 -0
  207. package/src/anthropic-federated-token-provider.integration.test.ts +248 -0
  208. package/src/anthropic-federated-token-provider.ts +249 -0
  209. package/src/anthropic-identity-client.fixtures.ts +237 -0
  210. package/src/anthropic-identity-client.integration.test.ts +342 -0
  211. package/src/anthropic-identity-client.ts +473 -0
  212. package/src/anthropic-identity-fixture-spec.integration.test.ts +169 -0
  213. package/src/anthropic-identity-name.integration.test.ts +83 -0
  214. package/src/anthropic-identity-name.ts +163 -0
  215. package/src/anthropic-identity-name.unit.test.ts +345 -0
  216. package/src/anthropic-openapi-contract.fixtures.ts +192 -0
  217. package/src/api-auth.ts +383 -0
  218. package/src/api-auth.unit.test.ts +837 -0
  219. package/src/api.smoke.test.ts +1225 -0
  220. package/src/api.ts +376 -0
  221. package/src/attachment-validation.ts +124 -0
  222. package/src/attachment-validation.unit.test.ts +218 -0
  223. package/src/caller-scope.ts +131 -0
  224. package/src/caller-scope.unit.test.ts +116 -0
  225. package/src/chat-credential-config.ts +22 -0
  226. package/src/chat-credentials-logging.unit.test.ts +92 -0
  227. package/src/chat-markers.ts +114 -0
  228. package/src/chat-markers.unit.test.ts +116 -0
  229. package/src/chat-service-provisioning-client.integration.test.ts +412 -0
  230. package/src/chat-service-provisioning-client.ts +221 -0
  231. package/src/chat.smoke.test.ts +1875 -0
  232. package/src/clock.ts +47 -0
  233. package/src/coding-task-store-provisioning-client.integration.test.ts +372 -0
  234. package/src/coding-task-store-provisioning-client.ts +156 -0
  235. package/src/crypto.ts +83 -0
  236. package/src/dev-auth-guard.ts +29 -0
  237. package/src/dev-auth-guard.unit.test.ts +51 -0
  238. package/src/errors.ts +77 -0
  239. package/src/errors.unit.test.ts +52 -0
  240. package/src/fixtures/anthropic-identity-recorded-fixtures.json +850 -0
  241. package/src/fixtures/anthropic-openapi/SDK_VERSION +1 -0
  242. package/src/fixtures/anthropic-openapi/mock-spec.json.gz +0 -0
  243. package/src/fixtures/chat-service-provisioning-cassette.json +156 -0
  244. package/src/fixtures/github-app-manifest-cassette.json +44 -0
  245. package/src/fixtures/google-auth-cassette.json +36 -0
  246. package/src/fixtures/http-chat-client-cassette.json +226 -0
  247. package/src/fixtures/k8s-cassette.json +470 -0
  248. package/src/fixtures/okta-auth-cassette.json +36 -0
  249. package/src/fixtures/slack-provision-cassette.json +12 -0
  250. package/src/fixtures/slack-provisioning-http-cassette.json +98 -0
  251. package/src/fixtures/task-store-provisioning-cassette.json +85 -0
  252. package/src/github-app-provisioning-client.integration.test.ts +149 -0
  253. package/src/github-app-provisioning-client.ts +161 -0
  254. package/src/github-app-provisioning-client.unit.test.ts +114 -0
  255. package/src/github-provisioning-service.ts +569 -0
  256. package/src/github-provisioning-service.unit.test.ts +123 -0
  257. package/src/google-auth-client.integration.test.ts +195 -0
  258. package/src/google-auth-client.ts +121 -0
  259. package/src/http-chat-client.integration.test.ts +554 -0
  260. package/src/http-chat-client.ts +533 -0
  261. package/src/http-chat-client.unit.test.ts +119 -0
  262. package/src/import-boundary.unit.test.ts +240 -0
  263. package/src/kubernetes-client.integration.test.ts +697 -0
  264. package/src/kubernetes-client.ts +1027 -0
  265. package/src/kubernetes-client.unit.test.ts +726 -0
  266. package/src/main.smoke.test.ts +91 -0
  267. package/src/main.ts +21 -0
  268. package/src/main.unit.test.ts +801 -0
  269. package/src/okta-auth-client.integration.test.ts +263 -0
  270. package/src/okta-auth-client.ts +152 -0
  271. package/src/openapi-schemas.ts +1310 -0
  272. package/src/openapi-schemas.unit.test.ts +348 -0
  273. package/src/per-agent-identity.fixtures.ts +305 -0
  274. package/src/platform-doc-route.ts +40 -0
  275. package/src/prisma-client.integration.test.ts +57 -0
  276. package/src/prisma-client.ts +187 -0
  277. package/src/prisma-client.unit.test.ts +178 -0
  278. package/src/prisma-tx.ts +23 -0
  279. package/src/push-content.ts +177 -0
  280. package/src/push-content.unit.test.ts +213 -0
  281. package/src/push-crypto.ts +135 -0
  282. package/src/push-crypto.unit.test.ts +66 -0
  283. package/src/push-sender.ts +169 -0
  284. package/src/push-sender.unit.test.ts +107 -0
  285. package/src/push-service.ts +332 -0
  286. package/src/push-service.unit.test.ts +679 -0
  287. package/src/push-toggle.ts +135 -0
  288. package/src/push-toggle.unit.test.ts +78 -0
  289. package/src/pwa-https-gating.smoke.test.ts +229 -0
  290. package/src/pwa-icons.unit.test.ts +41 -0
  291. package/src/pwa-routes.smoke.test.ts +276 -0
  292. package/src/pwa.ts +380 -0
  293. package/src/pwa.unit.test.ts +321 -0
  294. package/src/schema.unit.test.ts +122 -0
  295. package/src/self-serve-config.ts +52 -0
  296. package/src/self-serve-config.unit.test.ts +79 -0
  297. package/src/server.smoke.test.ts +273 -0
  298. package/src/server.ts +50 -0
  299. package/src/service-integration.ts +67 -0
  300. package/src/service-integration.unit.test.ts +129 -0
  301. package/src/slack-provisioning-client.integration.test.ts +308 -0
  302. package/src/slack-provisioning-client.ts +411 -0
  303. package/src/slack-provisioning-client.unit.test.ts +80 -0
  304. package/src/slack-provisioning-service.integration.test.ts +637 -0
  305. package/src/slack-provisioning-service.ts +451 -0
  306. package/src/slack-provisioning.integration.test.ts +395 -0
  307. package/src/specialty-manifest-loader.fixture.integration.test.ts +63 -0
  308. package/src/specialty-manifest-loader.ts +255 -0
  309. package/src/specialty-manifest-loader.unit.test.ts +301 -0
  310. package/src/specialty-registry.schema.unit.test.ts +35 -0
  311. package/src/specialty-registry.ts +236 -0
  312. package/src/specialty-registry.unit.test.ts +272 -0
  313. package/src/startup-policy-reconcile.ts +77 -0
  314. package/src/startup-policy-reconcile.unit.test.ts +251 -0
  315. package/src/theme-tokens.json +12 -0
  316. package/src/token-crypto.ts +60 -0
  317. package/src/token-crypto.unit.test.ts +182 -0
  318. package/src/trial-expiry-sweeper.integration.test.ts +318 -0
  319. package/src/trial-expiry-sweeper.ts +132 -0
  320. package/src/trial-expiry-sweeper.unit.test.ts +391 -0
  321. package/src/trial-expiry-warning-sweeper.integration.test.ts +468 -0
  322. package/src/trial-expiry-warning-sweeper.ts +447 -0
  323. package/src/trial-expiry-warning-sweeper.unit.test.ts +148 -0
  324. package/src/voice-contract.unit.test.ts +102 -0
  325. package/tsconfig.json +9 -0
@@ -0,0 +1,4355 @@
1
+ /**
2
+ * agent/src/admin-ui-pages.unit.test.ts
3
+ * Pure unit tests for all render functions in admin-ui-pages.ts.
4
+ *
5
+ * Strategy: call render functions directly, assert on returned HTML strings.
6
+ * No I/O, no Hono, no HTTP — pure string → string.
7
+ */
8
+
9
+ import { describe, expect, test } from "bun:test";
10
+ import { renderZeroQuotaNotice } from "./admin-ui-account-pages.ts";
11
+ import {
12
+ type AgentDetail,
13
+ type AgentListItem,
14
+ type CronJobItem,
15
+ ERROR_KIND_LABELS,
16
+ type MemberItem,
17
+ type PluginItem,
18
+ renderAddAnotherOrgAction,
19
+ renderAgentDetailPage,
20
+ renderAgentsPage,
21
+ renderChatMessageBubble,
22
+ renderChatPage,
23
+ renderChatThreadPage,
24
+ renderGithubAppInstalledPage,
25
+ renderGithubAppInstallPage,
26
+ renderGithubAppManifestRedirectPage,
27
+ renderLoginPage,
28
+ renderNewLocalAgentPage,
29
+ renderProvisionCompletePage,
30
+ renderProvisionXappTokenPage,
31
+ type TokenItem,
32
+ type ToolItem,
33
+ } from "./admin-ui-pages.ts";
34
+ import { baseStyles, renderAdminToolbar } from "./admin-ui-styles.ts";
35
+ import type { ChatMessage, ChatThread } from "./http-chat-client.ts";
36
+
37
+ // ─── Shared fixtures ──────────────────────────────────────────────────────────
38
+
39
+ const AGENT: AgentDetail = {
40
+ id: "agent-123",
41
+ name: "Test Agent",
42
+ slackId: "U12345",
43
+ selfHosted: false,
44
+ createdAt: new Date("2024-01-01T00:00:00Z"),
45
+ updatedAt: new Date("2024-01-02T00:00:00Z"),
46
+ repos: [],
47
+ authorAllowlist: [],
48
+ patchAuthorAllowlist: [],
49
+ restrictSlackToMembers: false,
50
+ typeName: "coding",
51
+ missingRequiredEnv: [],
52
+ autoPostReviews: true,
53
+ allowSelfReview: false,
54
+ minConfidence: 75,
55
+ maxFindings: 5,
56
+ cleanupMergedWorktrees: true,
57
+ cleanupAfterDays: 14,
58
+ };
59
+
60
+ const AGENT_LIST_ITEM: AgentListItem = {
61
+ id: "agent-123",
62
+ name: "Test Agent",
63
+ slackId: "U12345",
64
+ createdAt: new Date("2024-01-01T00:00:00Z"),
65
+ };
66
+
67
+ const ENV_VARS: Record<string, string> = {
68
+ API_KEY: "secret-value",
69
+ DB_HOST: "localhost",
70
+ };
71
+
72
+ const SYSTEM_CRON: CronJobItem = {
73
+ id: "cron-sys-1",
74
+ schedule: "0 * * * *",
75
+ prompt: "System health check",
76
+ channel: "C123",
77
+ user: null,
78
+ enabled: true,
79
+ name: "health-check",
80
+ system: true,
81
+ parentCronId: null,
82
+ preCheck: "shipwright:check-dev-task.ts",
83
+ createdAt: new Date("2024-02-01T00:00:00Z"),
84
+ };
85
+
86
+ const CUSTOM_CRON: CronJobItem = {
87
+ id: "cron-custom-1",
88
+ schedule: "30 8 * * *",
89
+ prompt: "Daily standup prompt",
90
+ channel: null,
91
+ user: "U99",
92
+ enabled: false,
93
+ name: null,
94
+ system: false,
95
+ parentCronId: null,
96
+ createdAt: new Date("2024-02-15T00:00:00Z"),
97
+ };
98
+
99
+ const TOOL_ENABLED: ToolItem = {
100
+ id: "tool-1",
101
+ pattern: "Bash(git:*)",
102
+ enabled: true,
103
+ createdAt: new Date("2024-03-05T00:00:00Z"),
104
+ };
105
+
106
+ const TOOL_DISABLED: ToolItem = {
107
+ id: "tool-2",
108
+ pattern: "Read(**)",
109
+ enabled: false,
110
+ createdAt: new Date("2024-03-06T00:00:00Z"),
111
+ };
112
+
113
+ const TOKEN_ACTIVE: TokenItem = {
114
+ id: "tok-1",
115
+ label: "CI token",
116
+ createdAt: new Date("2024-03-01T00:00:00Z"),
117
+ revokedAt: null,
118
+ };
119
+
120
+ const TOKEN_REVOKED: TokenItem = {
121
+ id: "tok-2",
122
+ label: "old token",
123
+ createdAt: new Date("2024-01-15T00:00:00Z"),
124
+ revokedAt: new Date("2024-02-01T00:00:00Z"),
125
+ };
126
+
127
+ const PLUGIN_ENABLED: PluginItem = {
128
+ id: "plug-1",
129
+ name: "shipwright",
130
+ version: "1.2.3",
131
+ enabled: true,
132
+ createdAt: new Date("2024-04-01T00:00:00Z"),
133
+ };
134
+
135
+ const PLUGIN_DISABLED: PluginItem = {
136
+ id: "plug-2",
137
+ name: "entropy-patrol",
138
+ version: null,
139
+ enabled: false,
140
+ createdAt: new Date("2024-04-02T00:00:00Z"),
141
+ };
142
+
143
+ const USER_NAME = "alice";
144
+
145
+ // ─── renderLoginPage ──────────────────────────────────────────────────────────
146
+
147
+ describe("renderLoginPage", () => {
148
+ test("returns a valid HTML document", () => {
149
+ const html = renderLoginPage();
150
+ expect(html).toContain("<!DOCTYPE html>");
151
+ expect(html).toContain("<html");
152
+ expect(html).toContain("</html>");
153
+ });
154
+
155
+ test("includes the page title", () => {
156
+ const html = renderLoginPage();
157
+ expect(html).toContain("Admin Login");
158
+ });
159
+
160
+ test("includes Sign in with Google link pointing at /admin/auth/google", () => {
161
+ const html = renderLoginPage();
162
+ expect(html).toContain("Sign in with Google");
163
+ expect(html).toContain('href="/admin/auth/google"');
164
+ });
165
+
166
+ test("no error div when no error provided", () => {
167
+ const html = renderLoginPage();
168
+ expect(html).not.toContain('class="alert alert-error"');
169
+ });
170
+
171
+ test("no error div when called with empty opts", () => {
172
+ const html = renderLoginPage({});
173
+ expect(html).not.toContain('class="alert alert-error"');
174
+ });
175
+
176
+ test("renders error div when error string provided", () => {
177
+ const html = renderLoginPage({ error: "Invalid password" });
178
+ expect(html).toContain('class="alert alert-error"');
179
+ expect(html).toContain("Invalid password");
180
+ });
181
+
182
+ test("XSS: error string is escaped", () => {
183
+ const html = renderLoginPage({ error: '<script>alert("xss")</script>' });
184
+ expect(html).not.toContain("<script>");
185
+ expect(html).toContain("&lt;script&gt;");
186
+ });
187
+
188
+ test("does not show Sign in with Okta when oktaEnabled is not provided", () => {
189
+ const html = renderLoginPage();
190
+ expect(html).not.toContain("Sign in with Okta");
191
+ expect(html).not.toContain('href="/admin/auth/okta"');
192
+ });
193
+
194
+ test("does not show Sign in with Okta when oktaEnabled is false", () => {
195
+ const html = renderLoginPage({ oktaEnabled: false });
196
+ expect(html).not.toContain("Sign in with Okta");
197
+ expect(html).not.toContain('href="/admin/auth/okta"');
198
+ });
199
+
200
+ test("shows Sign in with Okta when oktaEnabled is true", () => {
201
+ const html = renderLoginPage({ oktaEnabled: true });
202
+ expect(html).toContain("Sign in with Okta");
203
+ expect(html).toContain('href="/admin/auth/okta"');
204
+ });
205
+
206
+ test("includes returnTo query param in Okta href when provided", () => {
207
+ const html = renderLoginPage({
208
+ oktaEnabled: true,
209
+ returnTo: "/admin/agents",
210
+ });
211
+ expect(html).toContain(
212
+ 'href="/admin/auth/okta?returnTo=%2Fadmin%2Fagents"',
213
+ );
214
+ });
215
+
216
+ test("google off + okta on shows only the Okta button", () => {
217
+ const html = renderLoginPage({ googleEnabled: false, oktaEnabled: true });
218
+ expect(html).toContain("Sign in with Okta");
219
+ expect(html).not.toContain("Sign in with Google");
220
+ expect(html).not.toContain("No sign-in provider is configured");
221
+ });
222
+
223
+ test("both providers on shows both buttons", () => {
224
+ const html = renderLoginPage({ googleEnabled: true, oktaEnabled: true });
225
+ expect(html).toContain("Sign in with Google");
226
+ expect(html).toContain("Sign in with Okta");
227
+ expect(html).not.toContain("No sign-in provider is configured");
228
+ });
229
+
230
+ test("neither provider on shows the misconfiguration notice and no buttons", () => {
231
+ const html = renderLoginPage({ googleEnabled: false, oktaEnabled: false });
232
+ expect(html).toContain("No sign-in provider is configured");
233
+ expect(html).toContain("alert-error");
234
+ expect(html).not.toContain("Sign in with Google");
235
+ expect(html).not.toContain("Sign in with Okta");
236
+ });
237
+
238
+ test("returnTo is preserved on each rendered button", () => {
239
+ const returnTo = "/admin/agents";
240
+ const both = renderLoginPage({
241
+ googleEnabled: true,
242
+ oktaEnabled: true,
243
+ returnTo,
244
+ });
245
+ expect(both).toContain(
246
+ 'href="/admin/auth/google?returnTo=%2Fadmin%2Fagents"',
247
+ );
248
+ expect(both).toContain(
249
+ 'href="/admin/auth/okta?returnTo=%2Fadmin%2Fagents"',
250
+ );
251
+ const googleOnly = renderLoginPage({ googleEnabled: true, returnTo });
252
+ expect(googleOnly).toContain(
253
+ 'href="/admin/auth/google?returnTo=%2Fadmin%2Fagents"',
254
+ );
255
+ const oktaOnly = renderLoginPage({
256
+ googleEnabled: false,
257
+ oktaEnabled: true,
258
+ returnTo,
259
+ });
260
+ expect(oktaOnly).toContain(
261
+ 'href="/admin/auth/okta?returnTo=%2Fadmin%2Fagents"',
262
+ );
263
+ });
264
+ });
265
+
266
+ // ─── renderAgentsPage ─────────────────────────────────────────────────────────
267
+
268
+ describe("renderAgentsPage", () => {
269
+ test("returns a valid HTML document", () => {
270
+ const html = renderAgentsPage([], USER_NAME, true, "UTC");
271
+ expect(html).toContain("<!DOCTYPE html>");
272
+ expect(html).toContain("<html");
273
+ expect(html).toContain("</html>");
274
+ });
275
+
276
+ test("empty agents array shows 'No agents yet' empty state", () => {
277
+ const html = renderAgentsPage([], USER_NAME, true, "UTC");
278
+ expect(html).toContain("No agents yet");
279
+ });
280
+
281
+ test("empty state links to /admin/agents/new (not the Slack wizard)", () => {
282
+ const html = renderAgentsPage([], USER_NAME, true, "UTC");
283
+ expect(html).toContain('href="/admin/agents/new"');
284
+ expect(html).toContain("Create one");
285
+ });
286
+
287
+ test("admin: primary CTA is '+ New agent' → /admin/agents/new", () => {
288
+ const html = renderAgentsPage([AGENT_LIST_ITEM], USER_NAME, true, "UTC");
289
+ expect(html).toContain("+ New agent");
290
+ expect(html).toContain('href="/admin/agents/new"');
291
+ });
292
+
293
+ test("admin: only one CTA (no secondary Slack button)", () => {
294
+ const html = renderAgentsPage([AGENT_LIST_ITEM], USER_NAME, true, "UTC");
295
+ // Should NOT have the secondary Slack app button in the page header
296
+ expect(html).not.toContain("Connect Slack app");
297
+ // Should have exactly one btn-primary (the + New agent button)
298
+ const btnMatches = html.match(/class="btn btn-primary"/g);
299
+ expect(btnMatches).toHaveLength(1);
300
+ });
301
+
302
+ test("agent name appears as a link", () => {
303
+ const html = renderAgentsPage([AGENT_LIST_ITEM], USER_NAME, true, "UTC");
304
+ expect(html).toContain("Test Agent");
305
+ expect(html).toContain('href="/admin/agents/agent-123"');
306
+ });
307
+
308
+ test("Manage button links to agent detail page", () => {
309
+ const html = renderAgentsPage([AGENT_LIST_ITEM], USER_NAME, true, "UTC");
310
+ expect(html).toContain("Manage");
311
+ expect(html).toContain(`/admin/agents/${AGENT_LIST_ITEM.id}`);
312
+ });
313
+
314
+ test("XSS: agent name is escaped", () => {
315
+ const xssAgent: AgentListItem = {
316
+ ...AGENT_LIST_ITEM,
317
+ name: '<script>alert("xss")</script>',
318
+ };
319
+ const html = renderAgentsPage([xssAgent], USER_NAME, true, "UTC");
320
+ expect(html).not.toContain('<script>alert("xss")</script>');
321
+ expect(html).toContain("&lt;script&gt;");
322
+ });
323
+
324
+ test("XSS: agent id used in href is escaped", () => {
325
+ const xssAgent: AgentListItem = {
326
+ ...AGENT_LIST_ITEM,
327
+ id: 'agent-"><script>',
328
+ };
329
+ const html = renderAgentsPage([xssAgent], USER_NAME, true, "UTC");
330
+ expect(html).not.toContain('"><script>');
331
+ expect(html).toContain("&quot;&gt;&lt;script&gt;");
332
+ });
333
+
334
+ test("multiple agents are all rendered", () => {
335
+ const second: AgentListItem = {
336
+ ...AGENT_LIST_ITEM,
337
+ id: "agent-456",
338
+ name: "Second Agent",
339
+ };
340
+ const html = renderAgentsPage(
341
+ [AGENT_LIST_ITEM, second],
342
+ USER_NAME,
343
+ true,
344
+ "UTC",
345
+ );
346
+ expect(html).toContain("Test Agent");
347
+ expect(html).toContain("Second Agent");
348
+ });
349
+
350
+ test("no empty-state message when agents present", () => {
351
+ const html = renderAgentsPage([AGENT_LIST_ITEM], USER_NAME, true, "UTC");
352
+ expect(html).not.toContain("No agents yet");
353
+ });
354
+
355
+ test("non-admin: create buttons are hidden", () => {
356
+ const html = renderAgentsPage([AGENT_LIST_ITEM], USER_NAME, false, "UTC");
357
+ expect(html).not.toContain("+ New agent");
358
+ expect(html).not.toContain("Connect Slack app");
359
+ });
360
+
361
+ test("non-admin: empty state shows 'No agents.' without a create link", () => {
362
+ const html = renderAgentsPage([], USER_NAME, false, "UTC");
363
+ expect(html).toContain("No agents.");
364
+ expect(html).not.toContain("Create one");
365
+ });
366
+
367
+ test("createdAt date uses the provided timezone", () => {
368
+ // 2025-01-15T20:00:00Z = Jan 15 in UTC but also Jan 15 in America/Los_Angeles (UTC-8)
369
+ // However this specific UTC time is 12:00 PM Pacific — still Jan 15 in both.
370
+ // Use a time that's Jan 15 in UTC but Jan 14 in Pacific: e.g. 2025-01-15T05:00:00Z = Jan 14 in Pacific (UTC-8)
371
+ const agent: AgentListItem = {
372
+ ...AGENT_LIST_ITEM,
373
+ createdAt: new Date("2025-01-15T05:00:00Z"), // Jan 14 Pacific, Jan 15 UTC
374
+ };
375
+ const htmlUTC = renderAgentsPage([agent], USER_NAME, true, "UTC");
376
+ const htmlPacific = renderAgentsPage(
377
+ [agent],
378
+ USER_NAME,
379
+ true,
380
+ "America/Los_Angeles",
381
+ );
382
+ // In UTC: 1/15/2025; in Pacific (UTC-8): 1/14/2025
383
+ expect(htmlUTC).toContain("1/15/2025");
384
+ expect(htmlPacific).toContain("1/14/2025");
385
+ });
386
+ });
387
+
388
+ // ─── renderAgentDetailPage — members section ─────────────────────────────────
389
+
390
+ describe("renderAgentDetailPage — members section", () => {
391
+ const MEMBER: MemberItem = {
392
+ id: "m1",
393
+ email: "member@example.com",
394
+ createdAt: new Date("2025-01-15T00:00:00Z"),
395
+ };
396
+
397
+ test("admin sees the Members section", () => {
398
+ const html = renderAgentDetailPage(
399
+ AGENT,
400
+ {},
401
+ [],
402
+ [],
403
+ [],
404
+ [],
405
+ [],
406
+ USER_NAME,
407
+ true,
408
+ { timezone: "UTC" },
409
+ );
410
+ expect(html).toContain("Members");
411
+ });
412
+
413
+ test("non-admin does not see the Members section", () => {
414
+ const html = renderAgentDetailPage(
415
+ AGENT,
416
+ {},
417
+ [],
418
+ [],
419
+ [],
420
+ [],
421
+ [],
422
+ USER_NAME,
423
+ false,
424
+ { timezone: "UTC" },
425
+ );
426
+ expect(html).not.toContain("Members");
427
+ });
428
+
429
+ test("member email appears in the members table", () => {
430
+ const html = renderAgentDetailPage(
431
+ AGENT,
432
+ {},
433
+ [],
434
+ [],
435
+ [],
436
+ [],
437
+ [MEMBER],
438
+ USER_NAME,
439
+ true,
440
+ { timezone: "UTC" },
441
+ );
442
+ expect(html).toContain("member@example.com");
443
+ });
444
+
445
+ test("member added date appears in the members table", () => {
446
+ const html = renderAgentDetailPage(
447
+ AGENT,
448
+ {},
449
+ [],
450
+ [],
451
+ [],
452
+ [],
453
+ [MEMBER],
454
+ USER_NAME,
455
+ true,
456
+ { timezone: "UTC" },
457
+ );
458
+ // With timezone-aware formatting (UTC), 2025-01-15T00:00:00Z renders as 1/15/2025
459
+ expect(html).toContain("1/15/2025");
460
+ });
461
+
462
+ test("member remove button posts to the delete route with memberId", () => {
463
+ const html = renderAgentDetailPage(
464
+ AGENT,
465
+ {},
466
+ [],
467
+ [],
468
+ [],
469
+ [],
470
+ [MEMBER],
471
+ USER_NAME,
472
+ true,
473
+ { timezone: "UTC" },
474
+ );
475
+ expect(html).toContain(`/admin/agents/${AGENT.id}/members/delete`);
476
+ expect(html).toContain('name="memberId"');
477
+ expect(html).toContain(`value="${MEMBER.id}"`);
478
+ });
479
+
480
+ test("empty members list shows 'No members yet'", () => {
481
+ const html = renderAgentDetailPage(
482
+ AGENT,
483
+ {},
484
+ [],
485
+ [],
486
+ [],
487
+ [],
488
+ [],
489
+ USER_NAME,
490
+ true,
491
+ { timezone: "UTC" },
492
+ );
493
+ expect(html).toContain("No members yet");
494
+ });
495
+
496
+ test("Members table is wrapped in .data-table-wrapper", () => {
497
+ const html = renderAgentDetailPage(
498
+ AGENT,
499
+ {},
500
+ [],
501
+ [],
502
+ [],
503
+ [],
504
+ [MEMBER],
505
+ USER_NAME,
506
+ true,
507
+ { timezone: "UTC" },
508
+ );
509
+ expect(html).toMatch(
510
+ /<div class="data-table-wrapper">\s*<table class="data-table">\s*<thead>\s*<tr>\s*<th>Email<\/th>/,
511
+ );
512
+ });
513
+
514
+ test("XSS: member email is escaped", () => {
515
+ const xssMember: MemberItem = {
516
+ id: "m-xss",
517
+ email: '<script>alert("xss")</script>',
518
+ createdAt: new Date("2025-01-01"),
519
+ };
520
+ const html = renderAgentDetailPage(
521
+ AGENT,
522
+ {},
523
+ [],
524
+ [],
525
+ [],
526
+ [],
527
+ [xssMember],
528
+ USER_NAME,
529
+ true,
530
+ { timezone: "UTC" },
531
+ );
532
+ // The raw XSS payload must not appear unescaped in the output
533
+ expect(html).not.toContain('alert("xss")');
534
+ // The email content must be HTML-escaped
535
+ expect(html).toContain("&lt;script&gt;");
536
+ });
537
+ });
538
+
539
+ // ─── renderAgentDetailPage — overview ────────────────────────────────────────
540
+
541
+ describe("renderAgentDetailPage — overview", () => {
542
+ function render(opts?: Parameters<typeof renderAgentDetailPage>[9]): string {
543
+ return renderAgentDetailPage(
544
+ AGENT,
545
+ {},
546
+ [],
547
+ [],
548
+ [],
549
+ [],
550
+ [],
551
+ USER_NAME,
552
+ true,
553
+ { timezone: "UTC", ...opts },
554
+ );
555
+ }
556
+
557
+ test("returns a valid HTML document", () => {
558
+ const html = render();
559
+ expect(html).toContain("<!DOCTYPE html>");
560
+ expect(html).toContain("<html");
561
+ expect(html).toContain("</html>");
562
+ });
563
+
564
+ test("page title includes agent name", () => {
565
+ const html = render();
566
+ expect(html).toContain("Test Agent");
567
+ });
568
+
569
+ test("XSS: agent name in title is escaped", () => {
570
+ const xssAgent: AgentDetail = {
571
+ ...AGENT,
572
+ name: '<script>alert("xss")</script>',
573
+ };
574
+ const html = renderAgentDetailPage(
575
+ xssAgent,
576
+ {},
577
+ [],
578
+ [],
579
+ [],
580
+ [],
581
+ [],
582
+ USER_NAME,
583
+ true,
584
+ { timezone: "UTC" },
585
+ );
586
+ // The raw XSS payload must not appear unescaped in the output
587
+ expect(html).not.toContain('alert("xss")');
588
+ // The agent name must be HTML-escaped wherever it appears
589
+ expect(html).toContain("&lt;script&gt;");
590
+ });
591
+
592
+ test("no error alert when no error", () => {
593
+ const html = render();
594
+ expect(html).not.toContain('class="alert alert-error"');
595
+ });
596
+
597
+ test("error alert shown when opts.error set", () => {
598
+ const html = render({ error: "Something went wrong" });
599
+ expect(html).toContain('class="alert alert-error"');
600
+ expect(html).toContain("Something went wrong");
601
+ });
602
+
603
+ test("XSS: error message is escaped", () => {
604
+ const html = render({ error: "<script>bad()</script>" });
605
+ // The opening script tag must not appear unescaped (would allow execution)
606
+ expect(html).not.toContain("<script>bad()");
607
+ expect(html).toContain("&lt;script&gt;");
608
+ });
609
+
610
+ test("no new-token alert when newToken not set", () => {
611
+ const html = render();
612
+ expect(html).not.toContain("Token created.");
613
+ });
614
+
615
+ test("new token alert shown when opts.newToken set", () => {
616
+ const html = render({ newToken: "test-token-value" });
617
+ expect(html).toContain("Token created.");
618
+ expect(html).toContain("test-token-value");
619
+ });
620
+
621
+ test("XSS: new token value is escaped", () => {
622
+ const html = render({ newToken: '<script>alert("xss")</script>' });
623
+ // The raw XSS payload must not appear unescaped in the output
624
+ expect(html).not.toContain('alert("xss")');
625
+ expect(html).toContain("&lt;script&gt;");
626
+ });
627
+
628
+ test("back link to /admin/agents present", () => {
629
+ const html = render();
630
+ expect(html).toContain('href="/admin/agents"');
631
+ });
632
+
633
+ test("specialty name is displayed", () => {
634
+ const html = render();
635
+ expect(html).toContain("coding");
636
+ });
637
+
638
+ test("XSS: specialty name is escaped", () => {
639
+ const xssAgent: AgentDetail = {
640
+ ...AGENT,
641
+ typeName: '<script>alert("xss")</script>',
642
+ };
643
+ const html = renderAgentDetailPage(
644
+ xssAgent,
645
+ {},
646
+ [],
647
+ [],
648
+ [],
649
+ [],
650
+ [],
651
+ USER_NAME,
652
+ true,
653
+ { timezone: "UTC" },
654
+ );
655
+ expect(html).not.toContain('alert("xss")');
656
+ expect(html).toContain("&lt;script&gt;");
657
+ });
658
+
659
+ test("danger zone: delete form uses data-agent-name attribute (XSS-safe)", () => {
660
+ const xssAgent: AgentDetail = {
661
+ ...AGENT,
662
+ name: "O'Brien",
663
+ };
664
+ const html = renderAgentDetailPage(
665
+ xssAgent,
666
+ {},
667
+ [],
668
+ [],
669
+ [],
670
+ [],
671
+ [],
672
+ USER_NAME,
673
+ true,
674
+ { timezone: "UTC" },
675
+ );
676
+ // Agent name stored as a data attribute; single quotes are encoded as &#39; for defense-in-depth
677
+ expect(html).toContain('data-agent-name="O&#39;Brien"');
678
+ // No inline onsubmit with unescaped single quotes
679
+ expect(html).not.toContain("onsubmit");
680
+ });
681
+
682
+ test("danger zone: delete form absent for non-admins", () => {
683
+ const html = renderAgentDetailPage(
684
+ AGENT,
685
+ {},
686
+ [],
687
+ [],
688
+ [],
689
+ [],
690
+ [],
691
+ USER_NAME,
692
+ false,
693
+ { timezone: "UTC" },
694
+ );
695
+ expect(html).not.toContain("Danger Zone");
696
+ expect(html).not.toContain("delete-agent-form");
697
+ });
698
+
699
+ test("renders a restrictSlackToMembers checkbox, unchecked when false", () => {
700
+ const html = render();
701
+ expect(html).toMatch(
702
+ /<input[^>]*name="restrictSlackToMembers"[^>]*type="checkbox"[^>]*>/,
703
+ );
704
+ // AGENT fixture has restrictSlackToMembers: false — the input must not carry "checked"
705
+ const match = html.match(/<input[^>]*name="restrictSlackToMembers"[^>]*>/);
706
+ expect(match?.[0]).not.toContain("checked");
707
+ });
708
+
709
+ test("renders the restrictSlackToMembers checkbox checked when the agent has it enabled", () => {
710
+ const restrictedAgent: AgentDetail = {
711
+ ...AGENT,
712
+ restrictSlackToMembers: true,
713
+ };
714
+ const html = renderAgentDetailPage(
715
+ restrictedAgent,
716
+ {},
717
+ [],
718
+ [],
719
+ [],
720
+ [],
721
+ [],
722
+ USER_NAME,
723
+ true,
724
+ { timezone: "UTC" },
725
+ );
726
+ const match = html.match(/<input[^>]*name="restrictSlackToMembers"[^>]*>/);
727
+ expect(match?.[0]).toContain("checked");
728
+ });
729
+
730
+ test("renders a warning banner when opts.warning is set", () => {
731
+ const html = render({ warning: "this agent has no members" });
732
+ expect(html).toContain('class="alert alert-warning"');
733
+ expect(html).toContain("this agent has no members");
734
+ });
735
+
736
+ test("renders no warning banner when opts.warning is absent", () => {
737
+ const html = render();
738
+ expect(html).not.toContain("this agent has no members");
739
+ });
740
+ });
741
+
742
+ // ─── renderNewLocalAgentPage ──────────────────────────────────────────────────
743
+
744
+ describe("renderNewLocalAgentPage", () => {
745
+ const CODING_TYPE = { name: "coding", displayName: "Coding Agent" };
746
+
747
+ test("returns a valid HTML document", () => {
748
+ const html = renderNewLocalAgentPage(USER_NAME, [CODING_TYPE]);
749
+ expect(html).toContain("<!DOCTYPE html>");
750
+ expect(html).toContain("<html");
751
+ expect(html).toContain("</html>");
752
+ });
753
+
754
+ test("renders a required select[name=type]", () => {
755
+ const html = renderNewLocalAgentPage(USER_NAME, [CODING_TYPE]);
756
+ expect(html).toMatch(
757
+ /<select[^>]*name="type"[^>]*required[^>]*>|<select[^>]*required[^>]*name="type"[^>]*>/,
758
+ );
759
+ });
760
+
761
+ test("renders exactly one option per registry type, value=name label=displayName", () => {
762
+ const html = renderNewLocalAgentPage(USER_NAME, [CODING_TYPE]);
763
+ expect(html).toContain('<option value="coding">Coding Agent</option>');
764
+ const optionMatches = html.match(/<option /g) ?? [];
765
+ expect(optionMatches).toHaveLength(1);
766
+ });
767
+
768
+ test("renders multiple options when the registry has multiple types", () => {
769
+ const html = renderNewLocalAgentPage(USER_NAME, [
770
+ CODING_TYPE,
771
+ { name: "research", displayName: "Research Agent" },
772
+ ]);
773
+ expect(html).toContain('<option value="coding">Coding Agent</option>');
774
+ expect(html).toContain('<option value="research">Research Agent</option>');
775
+ });
776
+
777
+ test("XSS: type name/displayName are escaped", () => {
778
+ const html = renderNewLocalAgentPage(USER_NAME, [
779
+ { name: "coding", displayName: '<script>alert("xss")</script>' },
780
+ ]);
781
+ expect(html).not.toContain('alert("xss")');
782
+ expect(html).toContain("&lt;script&gt;");
783
+ });
784
+
785
+ test("no error alert when no error", () => {
786
+ const html = renderNewLocalAgentPage(USER_NAME, [CODING_TYPE]);
787
+ expect(html).not.toContain('class="alert alert-error"');
788
+ });
789
+
790
+ test("error alert shown when error is passed", () => {
791
+ const html = renderNewLocalAgentPage(USER_NAME, [CODING_TYPE], {
792
+ error: "Something went wrong",
793
+ });
794
+ expect(html).toContain('class="alert alert-error"');
795
+ expect(html).toContain("Something went wrong");
796
+ });
797
+
798
+ test("renders an authorAllowlist textarea", () => {
799
+ const html = renderNewLocalAgentPage(USER_NAME, [CODING_TYPE]);
800
+ expect(html).toMatch(/<textarea[^>]*name="authorAllowlist"[^>]*>/);
801
+ });
802
+
803
+ test("author allowlist label reads 'Author allowlist (review)'", () => {
804
+ const html = renderNewLocalAgentPage(USER_NAME, [CODING_TYPE]);
805
+ expect(html).toContain(
806
+ '<label class="form-label" for="authorAllowlist">Author allowlist (review) (optional, one GitHub login per line)</label>',
807
+ );
808
+ });
809
+
810
+ test("renders a patchAuthorAllowlist textarea", () => {
811
+ const html = renderNewLocalAgentPage(USER_NAME, [CODING_TYPE]);
812
+ expect(html).toMatch(/<textarea[^>]*name="patchAuthorAllowlist"[^>]*>/);
813
+ });
814
+
815
+ test("patch author allowlist label reads 'Patch author allowlist'", () => {
816
+ const html = renderNewLocalAgentPage(USER_NAME, [CODING_TYPE]);
817
+ expect(html).toContain(
818
+ '<label class="form-label" for="patchAuthorAllowlist">Patch author allowlist (optional, one GitHub login per line)</label>',
819
+ );
820
+ });
821
+
822
+ test("renders a restrictSlackToMembers checkbox", () => {
823
+ const html = renderNewLocalAgentPage(USER_NAME, [CODING_TYPE]);
824
+ expect(html).toMatch(
825
+ /<input[^>]*name="restrictSlackToMembers"[^>]*type="checkbox"[^>]*>/,
826
+ );
827
+ });
828
+
829
+ test("renders both runtime radios", () => {
830
+ const html = renderNewLocalAgentPage(USER_NAME, [CODING_TYPE], {
831
+ canProvision: true,
832
+ });
833
+ expect(html).toMatch(
834
+ /<input[^>]*name="runtime"[^>]*value="in-cluster"[^>]*>/,
835
+ );
836
+ expect(html).toMatch(
837
+ /<input[^>]*name="runtime"[^>]*value="self-hosted"[^>]*>/,
838
+ );
839
+ });
840
+
841
+ test("canProvision: true preselects in-cluster and leaves it enabled", () => {
842
+ const html = renderNewLocalAgentPage(USER_NAME, [CODING_TYPE], {
843
+ canProvision: true,
844
+ });
845
+ const inCluster = html.match(
846
+ /<input[^>]*value="in-cluster"[^>]*\/>/,
847
+ )?.[0] as string;
848
+ expect(inCluster).toContain("checked");
849
+ expect(inCluster).not.toContain("disabled");
850
+ const selfHosted = html.match(
851
+ /<input[^>]*value="self-hosted"[^>]*\/>/,
852
+ )?.[0] as string;
853
+ expect(selfHosted).not.toContain("checked");
854
+ });
855
+
856
+ test("canProvision: false disables in-cluster, preselects self-hosted, and explains why", () => {
857
+ const html = renderNewLocalAgentPage(USER_NAME, [CODING_TYPE], {
858
+ canProvision: false,
859
+ });
860
+ const inCluster = html.match(
861
+ /<input[^>]*value="in-cluster"[^>]*\/>/,
862
+ )?.[0] as string;
863
+ expect(inCluster).toContain("disabled");
864
+ expect(inCluster).not.toContain("checked");
865
+ const selfHosted = html.match(
866
+ /<input[^>]*value="self-hosted"[^>]*\/>/,
867
+ )?.[0] as string;
868
+ expect(selfHosted).toContain("checked");
869
+ expect(html).toContain("SHIPWRIGHT_K8S_PROVISIONING");
870
+ });
871
+
872
+ test("omitted canProvision defaults to unavailable", () => {
873
+ const html = renderNewLocalAgentPage(USER_NAME, [CODING_TYPE]);
874
+ const inCluster = html.match(
875
+ /<input[^>]*value="in-cluster"[^>]*\/>/,
876
+ )?.[0] as string;
877
+ expect(inCluster).toContain("disabled");
878
+ });
879
+
880
+ test("renders an optional CLAUDE_CODE_OAUTH_TOKEN input", () => {
881
+ const html = renderNewLocalAgentPage(USER_NAME, [CODING_TYPE]);
882
+ expect(html).toMatch(
883
+ /<input[^>]*name="claudeCodeOauthToken"[^>]*type="password"[^>]*>/,
884
+ );
885
+ expect(html).toContain("CLAUDE_CODE_OAUTH_TOKEN");
886
+ });
887
+
888
+ test("points at the chat UI for agent conversation", () => {
889
+ const html = renderNewLocalAgentPage(USER_NAME, [CODING_TYPE], {
890
+ canProvision: true,
891
+ });
892
+ expect(html).toContain("/admin/chat");
893
+ });
894
+
895
+ test("renders an optional ANTHROPIC_API_KEY input alongside the Claude token, matching the wizard's field", () => {
896
+ const html = renderNewLocalAgentPage(USER_NAME, [CODING_TYPE]);
897
+ expect(html).toMatch(
898
+ /<input[^>]*name="anthropicApiKey"[^>]*type="password"[^>]*>/,
899
+ );
900
+ expect(html).toContain('placeholder="sk-ant-..."');
901
+ });
902
+
903
+ test("renders a Connect Slack checkbox", () => {
904
+ const html = renderNewLocalAgentPage(USER_NAME, [CODING_TYPE]);
905
+ expect(html).toMatch(
906
+ /<input[^>]*name="connectSlack"[^>]*type="checkbox"[^>]*>/,
907
+ );
908
+ });
909
+
910
+ test("renders an xoxpToken field inside a slack-fields block hidden by default", () => {
911
+ const html = renderNewLocalAgentPage(USER_NAME, [CODING_TYPE]);
912
+ expect(html).toMatch(/<input[^>]*name="xoxpToken"[^>]*>/);
913
+ // The xoxpToken field lives inside a container hidden until the
914
+ // connectSlack checkbox is toggled on (progressive disclosure).
915
+ const slackBlock = html.match(
916
+ /<div id="slack-fields"[^>]*style="display:none[^>]*>[\s\S]*?xoxpToken/,
917
+ );
918
+ expect(slackBlock).not.toBeNull();
919
+ });
920
+
921
+ test("connectSlack checkbox onchange toggles the slack-fields block's display", () => {
922
+ const html = renderNewLocalAgentPage(USER_NAME, [CODING_TYPE]);
923
+ const checkbox = html.match(
924
+ /<input[^>]*name="connectSlack"[^>]*>/,
925
+ )?.[0] as string;
926
+ expect(checkbox).toContain("onchange");
927
+ expect(checkbox).toContain("slack-fields");
928
+ });
929
+
930
+ test("renders a GitHub auth radio group with skip/pat/app options", () => {
931
+ const html = renderNewLocalAgentPage(USER_NAME, [CODING_TYPE]);
932
+ expect(html).toMatch(/<input[^>]*name="ghAuthMode"[^>]*value="skip"[^>]*>/);
933
+ expect(html).toMatch(/<input[^>]*name="ghAuthMode"[^>]*value="pat"[^>]*>/);
934
+ expect(html).toMatch(/<input[^>]*name="ghAuthMode"[^>]*value="app"[^>]*>/);
935
+ // UAP-5.3: two ghAuthMode="app" radios now exist (Create GitHub App /
936
+ // Use existing GitHub App), disambiguated by a shared hidden ghAppMode
937
+ // field the radios' onchange handlers set to "auto"/"manual".
938
+ expect(html).toMatch(/id="ghAppMode"/);
939
+ const appRadios = html.match(
940
+ /<input[^>]*name="ghAuthMode"[^>]*value="app"[^>]*>/g,
941
+ );
942
+ expect(appRadios?.length).toBe(2);
943
+ });
944
+
945
+ // ── UAP-5.3: "Use existing GitHub App" as a 4th ghAuthMode option ─────────
946
+
947
+ test("renders a 4th 'Use existing GitHub App' radio revealing App ID, Installation ID, and a PEM file upload", () => {
948
+ const html = renderNewLocalAgentPage(USER_NAME, [CODING_TYPE]);
949
+ expect(html).toContain("Use existing GitHub App");
950
+ const manualBlock = html.match(
951
+ /<div id="gh-app-manual-fields"[^>]*style="display:none"[\s\S]*?<\/div>\s*<\/div>/,
952
+ )?.[0] as string;
953
+ expect(manualBlock).toBeDefined();
954
+ expect(manualBlock).toMatch(/<input[^>]*name="ghAppId"[^>]*>/);
955
+ expect(manualBlock).toMatch(/<input[^>]*name="ghAppInstallationId"[^>]*>/);
956
+ expect(manualBlock).toMatch(
957
+ /<input[^>]*name="ghAppPrivateKeyFile"[^>]*type="file"[^>]*accept="\.pem"[^>]*>/,
958
+ );
959
+ });
960
+
961
+ test("the 'Use existing GitHub App' radio's onchange sets the hidden ghAppMode input to 'manual' and shows the manual fields block; 'Create GitHub App' sets it to 'auto'", () => {
962
+ const html = renderNewLocalAgentPage(USER_NAME, [CODING_TYPE]);
963
+ const appRadios = html.match(
964
+ /<input[^>]*name="ghAuthMode"[^>]*value="app"[^>]*\/>/g,
965
+ ) as string[];
966
+ expect(appRadios).toHaveLength(2);
967
+ const [autoRadio, manualRadio] = appRadios;
968
+ expect(autoRadio).toContain("ghAppMode').value='auto'");
969
+ expect(manualRadio).toContain("ghAppMode').value='manual'");
970
+ expect(manualRadio).toContain("gh-app-manual-fields");
971
+ });
972
+
973
+ test("the hidden ghAppMode input defaults to 'auto'", () => {
974
+ const html = renderNewLocalAgentPage(USER_NAME, [CODING_TYPE]);
975
+ const hidden = html.match(
976
+ /<input[^>]*type="hidden"[^>]*id="ghAppMode"[^>]*>/,
977
+ )?.[0] as string;
978
+ expect(hidden).toBeDefined();
979
+ expect(hidden).toContain('name="ghAppMode"');
980
+ expect(hidden).toContain('value="auto"');
981
+ });
982
+
983
+ test("the New Agent form gains enctype=multipart/form-data now that it contains a file input", () => {
984
+ const html = renderNewLocalAgentPage(USER_NAME, [CODING_TYPE]);
985
+ const formTag = html.match(
986
+ /<form method="POST" action="\/admin\/agents"[^>]*>/,
987
+ )?.[0] as string;
988
+ expect(formTag).toBeDefined();
989
+ expect(formTag).toContain('enctype="multipart/form-data"');
990
+ });
991
+
992
+ test("skip is the default checked GitHub auth mode", () => {
993
+ const html = renderNewLocalAgentPage(USER_NAME, [CODING_TYPE]);
994
+ const skipRadio = html.match(
995
+ /<input[^>]*name="ghAuthMode"[^>]*value="skip"[^>]*\/>/,
996
+ )?.[0] as string;
997
+ expect(skipRadio).toContain("checked");
998
+ });
999
+
1000
+ test("renders a ghPat field inside a gh-pat-fields block hidden by default", () => {
1001
+ const html = renderNewLocalAgentPage(USER_NAME, [CODING_TYPE]);
1002
+ expect(html).toMatch(/<input[^>]*name="ghPat"[^>]*type="password"[^>]*>/);
1003
+ const patBlock = html.match(
1004
+ /<div id="gh-pat-fields"[^>]*style="display:none"[\s\S]*?ghPat/,
1005
+ );
1006
+ expect(patBlock).not.toBeNull();
1007
+ });
1008
+
1009
+ test("renders a githubOrg field inside a gh-app-fields block hidden by default", () => {
1010
+ const html = renderNewLocalAgentPage(USER_NAME, [CODING_TYPE]);
1011
+ expect(html).toMatch(/<input[^>]*name="githubOrg"[^>]*>/);
1012
+ const appBlock = html.match(
1013
+ /<div id="gh-app-fields"[^>]*style="display:none"[\s\S]*?githubOrg/,
1014
+ );
1015
+ expect(appBlock).not.toBeNull();
1016
+ });
1017
+
1018
+ test("ghAuthMode radios toggle the pat/app field blocks via onchange", () => {
1019
+ const html = renderNewLocalAgentPage(USER_NAME, [CODING_TYPE]);
1020
+ const patRadio = html.match(
1021
+ /<input[^>]*name="ghAuthMode"[^>]*value="pat"[^>]*>/,
1022
+ )?.[0] as string;
1023
+ expect(patRadio).toContain("onchange");
1024
+ expect(patRadio).toContain("gh-pat-fields");
1025
+ const appRadio = html.match(
1026
+ /<input[^>]*name="ghAuthMode"[^>]*value="app"[^>]*>/,
1027
+ )?.[0] as string;
1028
+ expect(appRadio).toContain("onchange");
1029
+ expect(appRadio).toContain("gh-app-fields");
1030
+ });
1031
+
1032
+ // ── UAP-5.1: gate Slack/GitHub sections behind runtime=in-cluster ─────────
1033
+
1034
+ test("canProvision: false (self-hosted preselected) hides the restrictSlackToMembers group, Slack fieldset, and GitHub Authentication fieldset", () => {
1035
+ const html = renderNewLocalAgentPage(USER_NAME, [CODING_TYPE], {
1036
+ canProvision: false,
1037
+ });
1038
+ const restrictGroup = html.match(
1039
+ /<div id="restrict-slack-group"[^>]*>/,
1040
+ )?.[0] as string;
1041
+ expect(restrictGroup).toContain("display:none");
1042
+ const slackSection = html.match(
1043
+ /<fieldset id="slack-section"[^>]*>/,
1044
+ )?.[0] as string;
1045
+ expect(slackSection).toContain("display:none");
1046
+ const githubSection = html.match(
1047
+ /<fieldset id="github-section"[^>]*>/,
1048
+ )?.[0] as string;
1049
+ expect(githubSection).toContain("display:none");
1050
+ });
1051
+
1052
+ test("canProvision: true (in-cluster preselected) shows the restrictSlackToMembers group, Slack fieldset, and GitHub Authentication fieldset", () => {
1053
+ const html = renderNewLocalAgentPage(USER_NAME, [CODING_TYPE], {
1054
+ canProvision: true,
1055
+ });
1056
+ const restrictGroup = html.match(
1057
+ /<div id="restrict-slack-group"[^>]*>/,
1058
+ )?.[0] as string;
1059
+ expect(restrictGroup).not.toContain("display:none");
1060
+ const slackSection = html.match(
1061
+ /<fieldset id="slack-section"[^>]*>/,
1062
+ )?.[0] as string;
1063
+ expect(slackSection).not.toContain("display:none");
1064
+ const githubSection = html.match(
1065
+ /<fieldset id="github-section"[^>]*>/,
1066
+ )?.[0] as string;
1067
+ expect(githubSection).not.toContain("display:none");
1068
+ });
1069
+
1070
+ test("omitted canProvision defaults to hidden Slack/GitHub sections (self-hosted preselected)", () => {
1071
+ const html = renderNewLocalAgentPage(USER_NAME, [CODING_TYPE]);
1072
+ const slackSection = html.match(
1073
+ /<fieldset id="slack-section"[^>]*>/,
1074
+ )?.[0] as string;
1075
+ expect(slackSection).toContain("display:none");
1076
+ const githubSection = html.match(
1077
+ /<fieldset id="github-section"[^>]*>/,
1078
+ )?.[0] as string;
1079
+ expect(githubSection).toContain("display:none");
1080
+ });
1081
+
1082
+ test("both runtime radios carry an onchange handler that toggles the restrict-slack-group, slack-section, and github-section ids", () => {
1083
+ const html = renderNewLocalAgentPage(USER_NAME, [CODING_TYPE], {
1084
+ canProvision: true,
1085
+ });
1086
+ const inClusterRadio = html.match(
1087
+ /<input[^>]*name="runtime"[^>]*value="in-cluster"[^>]*\/>/,
1088
+ )?.[0] as string;
1089
+ expect(inClusterRadio).toContain("onchange");
1090
+ expect(inClusterRadio).toContain("restrict-slack-group");
1091
+ expect(inClusterRadio).toContain("slack-section");
1092
+ expect(inClusterRadio).toContain("github-section");
1093
+
1094
+ const selfHostedRadio = html.match(
1095
+ /<input[^>]*name="runtime"[^>]*value="self-hosted"[^>]*\/>/,
1096
+ )?.[0] as string;
1097
+ expect(selfHostedRadio).toContain("onchange");
1098
+ expect(selfHostedRadio).toContain("restrict-slack-group");
1099
+ expect(selfHostedRadio).toContain("slack-section");
1100
+ expect(selfHostedRadio).toContain("github-section");
1101
+ });
1102
+
1103
+ // ── UAP-5.2: inline member-email textarea for restrictSlackToMembers ──────
1104
+
1105
+ test("renders a memberEmails textarea inside a member-emails-fields block hidden by default", () => {
1106
+ const html = renderNewLocalAgentPage(USER_NAME, [CODING_TYPE]);
1107
+ expect(html).toMatch(/<textarea[^>]*name="memberEmails"[^>]*>/);
1108
+ const memberEmailsBlock = html.match(
1109
+ /<div id="member-emails-fields"[^>]*style="display:none[^>]*>[\s\S]*?memberEmails/,
1110
+ );
1111
+ expect(memberEmailsBlock).not.toBeNull();
1112
+ });
1113
+
1114
+ test("restrictSlackToMembers checkbox onchange toggles the member-emails-fields block's display", () => {
1115
+ const html = renderNewLocalAgentPage(USER_NAME, [CODING_TYPE]);
1116
+ const checkbox = html.match(
1117
+ /<input[^>]*name="restrictSlackToMembers"[^>]*>/,
1118
+ )?.[0] as string;
1119
+ expect(checkbox).toContain("onchange");
1120
+ expect(checkbox).toContain("member-emails-fields");
1121
+ });
1122
+ });
1123
+
1124
+ // ─── renderAgentDetailPage — connect-later actions (UAP-2.3 / UAP-5.4) ───────
1125
+
1126
+ describe("renderAgentDetailPage — connect-later actions", () => {
1127
+ function render(envVars: Record<string, string>): string {
1128
+ return renderAgentDetailPage(
1129
+ AGENT,
1130
+ envVars,
1131
+ [],
1132
+ [],
1133
+ [],
1134
+ [],
1135
+ [],
1136
+ USER_NAME,
1137
+ true,
1138
+ { timezone: "UTC" },
1139
+ );
1140
+ }
1141
+
1142
+ test("Connect Slack action renders exactly one field (single-input backward compat)", () => {
1143
+ const html = render({});
1144
+ expect(html).toContain("Connect Slack");
1145
+ const slackForm = html.match(
1146
+ /action="\/admin\/agents\/agent-123\/connect-slack"[\s\S]*?<\/form>/,
1147
+ )?.[0] as string;
1148
+ expect(slackForm).toBeDefined();
1149
+ const inputCount = (slackForm.match(/<input\b/g) ?? []).length;
1150
+ expect(inputCount).toBe(1);
1151
+ expect(slackForm).toMatch(/name="xoxpToken"[^>]*type="password"/);
1152
+ });
1153
+
1154
+ test("Connect Slack action is hidden once SLACK_APP_TOKEN is set", () => {
1155
+ const html = render({ SLACK_APP_TOKEN: "xoxe.xoxp-abc" });
1156
+ expect(html).not.toContain("Connect Slack<");
1157
+ });
1158
+
1159
+ test("Set up GitHub App (auto) action renders githubOrg plus the public-App checkbox", () => {
1160
+ const html = render({});
1161
+ const forms = html.match(
1162
+ /action="\/admin\/agents\/agent-123\/connect-github"[\s\S]*?<\/form>/g,
1163
+ ) as string[];
1164
+ const autoForm = forms.find((f) => f.includes('value="auto"')) as string;
1165
+ expect(autoForm).toBeDefined();
1166
+ const inputCount = (autoForm.match(/<input\b/g) ?? []).length;
1167
+ // 2 hidden fields (ghAuthMode, ghAppMode) + githubOrg + ghAppPublic checkbox.
1168
+ expect(inputCount).toBe(4);
1169
+ expect(autoForm).toMatch(/name="githubOrg"[^>]*type="text"/);
1170
+ expect(autoForm).toMatch(/name="ghAppPublic" type="checkbox"/);
1171
+ });
1172
+
1173
+ test("Add GitHub PAT action renders exactly one field (single-input backward compat)", () => {
1174
+ const html = render({});
1175
+ const forms = html.match(
1176
+ /action="\/admin\/agents\/agent-123\/connect-github"[\s\S]*?<\/form>/g,
1177
+ ) as string[];
1178
+ const patForm = forms.find((f) => f.includes('name="ghPat"')) as string;
1179
+ expect(patForm).toBeDefined();
1180
+ // 1 hidden field (ghAuthMode=pat) + 1 visible ghPat field.
1181
+ const inputCount = (patForm.match(/<input\b/g) ?? []).length;
1182
+ expect(inputCount).toBe(2);
1183
+ expect(patForm).toMatch(/name="ghPat"[^>]*type="password"/);
1184
+ });
1185
+
1186
+ test("Set up GitHub App and Add GitHub PAT are both hidden once GH_APP_ID/GH_TOKEN are set", () => {
1187
+ const html = render({ GH_APP_ID: "12345", GH_TOKEN: "ghp_abc" });
1188
+ expect(html).not.toContain("Set up GitHub App<");
1189
+ expect(html).not.toContain("Add GitHub PAT<");
1190
+ });
1191
+
1192
+ test("'Use existing GitHub App' action appears when GH_APP_ID is not set", () => {
1193
+ const html = render({});
1194
+ expect(html).toContain("Use existing GitHub App");
1195
+ });
1196
+
1197
+ test("'Use existing GitHub App' action is hidden once GH_APP_ID is set (same gate as auto flow)", () => {
1198
+ const html = render({ GH_APP_ID: "12345" });
1199
+ expect(html).not.toContain("Use existing GitHub App");
1200
+ });
1201
+
1202
+ test("'Use existing GitHub App' action posts to connect-github with hidden ghAuthMode=app/ghAppMode=manual", () => {
1203
+ const html = render({});
1204
+ const forms = html.match(
1205
+ /action="\/admin\/agents\/agent-123\/connect-github"[\s\S]*?<\/form>/g,
1206
+ ) as string[];
1207
+ const manualForm = forms.find(
1208
+ (f) => f.includes('value="manual"') && f.includes("ghAppId"),
1209
+ ) as string;
1210
+ expect(manualForm).toBeDefined();
1211
+ expect(manualForm).toMatch(
1212
+ /<input[^>]*type="hidden"[^>]*name="ghAuthMode"[^>]*value="app"[^>]*>/,
1213
+ );
1214
+ expect(manualForm).toMatch(
1215
+ /<input[^>]*type="hidden"[^>]*name="ghAppMode"[^>]*value="manual"[^>]*>/,
1216
+ );
1217
+ });
1218
+
1219
+ test("'Use existing GitHub App' action renders three fields: App ID text, Installation ID text, Private Key file", () => {
1220
+ const html = render({});
1221
+ const forms = html.match(
1222
+ /action="\/admin\/agents\/agent-123\/connect-github"[\s\S]*?<\/form>/g,
1223
+ ) as string[];
1224
+ const manualForm = forms.find(
1225
+ (f) => f.includes('value="manual"') && f.includes("ghAppId"),
1226
+ ) as string;
1227
+ expect(manualForm).toMatch(/name="ghAppId"[^>]*type="text"/);
1228
+ expect(manualForm).toMatch(/name="ghAppInstallationId"[^>]*type="text"/);
1229
+ expect(manualForm).toMatch(/name="ghAppPrivateKeyFile"[^>]*type="file"/);
1230
+ // The <form> itself must allow file uploads.
1231
+ expect(manualForm).toContain('enctype="multipart/form-data"');
1232
+ });
1233
+ });
1234
+
1235
+ // ─── renderAgentDetailPage — env vars section ────────────────────────────────
1236
+
1237
+ describe("renderAgentDetailPage — env vars", () => {
1238
+ function render(envVars: Record<string, string>): string {
1239
+ return renderAgentDetailPage(
1240
+ AGENT,
1241
+ envVars,
1242
+ [],
1243
+ [],
1244
+ [],
1245
+ [],
1246
+ [],
1247
+ USER_NAME,
1248
+ true,
1249
+ { timezone: "UTC" },
1250
+ );
1251
+ }
1252
+
1253
+ test("empty envVars shows 'No env vars set.' empty state", () => {
1254
+ const html = render({});
1255
+ expect(html).toContain("No env vars set.");
1256
+ });
1257
+
1258
+ test("env key is rendered", () => {
1259
+ const html = render(ENV_VARS);
1260
+ expect(html).toContain("API_KEY");
1261
+ expect(html).toContain("DB_HOST");
1262
+ });
1263
+
1264
+ test("env value is masked — shows •••••••• not the raw value", () => {
1265
+ const html = render({ SHORT: "abc" });
1266
+ expect(html).toContain("••••••••");
1267
+ expect(html).not.toContain("abc");
1268
+ });
1269
+
1270
+ test("long env value is masked — not shown even when long", () => {
1271
+ const longVal = "x".repeat(45);
1272
+ const html = render({ LONG: longVal });
1273
+ expect(html).toContain("••••••••");
1274
+ expect(html).not.toContain(longVal.slice(0, 40));
1275
+ });
1276
+
1277
+ test("delete form action points to /admin/agents/{agentId}/envs/delete", () => {
1278
+ const html = render(ENV_VARS);
1279
+ expect(html).toContain(`action="/admin/agents/${AGENT.id}/envs/delete"`);
1280
+ });
1281
+
1282
+ test("delete form contains hidden key input", () => {
1283
+ const html = render({ MY_KEY: "myval" });
1284
+ expect(html).toContain('name="key"');
1285
+ expect(html).toContain('value="MY_KEY"');
1286
+ });
1287
+
1288
+ test("add env var form action points to /admin/agents/{agentId}/envs", () => {
1289
+ const html = render({});
1290
+ expect(html).toContain(`action="/admin/agents/${AGENT.id}/envs"`);
1291
+ });
1292
+
1293
+ test("XSS: env key is escaped", () => {
1294
+ const html = render({ "<script>": "val" });
1295
+ // The raw XSS payload must not appear as a live tag in the output
1296
+ expect(html).not.toContain("<script>val");
1297
+ expect(html).toContain("&lt;script&gt;");
1298
+ });
1299
+
1300
+ test("XSS: env value is masked — raw value never reaches HTML", () => {
1301
+ const html = render({ SAFE_KEY: "<img src=x onerror=alert(1)>" });
1302
+ expect(html).not.toContain("<img src=x");
1303
+ expect(html).not.toContain("onerror=alert");
1304
+ expect(html).toContain("••••••••");
1305
+ });
1306
+
1307
+ test("Env Vars table is wrapped in .data-table-wrapper", () => {
1308
+ const html = render(ENV_VARS);
1309
+ expect(html).toMatch(
1310
+ /<div class="data-table-wrapper">\s*<table class="data-table">\s*<thead>\s*<tr>\s*<th>Key<\/th>/,
1311
+ );
1312
+ });
1313
+ });
1314
+
1315
+ // ─── renderAgentDetailPage — missingRequiredEnv badge (ATS-4.2) ──────────────
1316
+
1317
+ describe("renderAgentDetailPage — missingRequiredEnv badge", () => {
1318
+ function render(missingRequiredEnv: string[]): string {
1319
+ const agent: AgentDetail = { ...AGENT, missingRequiredEnv };
1320
+ return renderAgentDetailPage(
1321
+ agent,
1322
+ {},
1323
+ [],
1324
+ [],
1325
+ [],
1326
+ [],
1327
+ [],
1328
+ USER_NAME,
1329
+ true,
1330
+ { timezone: "UTC" },
1331
+ );
1332
+ }
1333
+
1334
+ test("missing required env key renders a badge/callout referencing the key name", () => {
1335
+ const html = render(["CLAUDE_CODE_OAUTH_TOKEN"]);
1336
+ expect(html).toContain("CLAUDE_CODE_OAUTH_TOKEN");
1337
+ expect(html).toContain('class="badge badge-warning"');
1338
+ expect(html).toContain('class="alert alert-warning"');
1339
+ });
1340
+
1341
+ test("badge links to the env editor card", () => {
1342
+ const html = render(["CLAUDE_CODE_OAUTH_TOKEN"]);
1343
+ expect(html).toContain('id="env-vars"');
1344
+ expect(html).toContain('href="#env-vars"');
1345
+ });
1346
+
1347
+ test("multiple missing keys are all listed", () => {
1348
+ const html = render(["CLAUDE_CODE_OAUTH_TOKEN", "GH_TOKEN"]);
1349
+ expect(html).toContain("CLAUDE_CODE_OAUTH_TOKEN");
1350
+ expect(html).toContain("GH_TOKEN");
1351
+ });
1352
+
1353
+ test("empty missingRequiredEnv renders no badge/callout", () => {
1354
+ const html = render([]);
1355
+ expect(html).not.toContain('class="badge badge-warning"');
1356
+ expect(html).not.toContain("Missing required env var");
1357
+ });
1358
+
1359
+ test("no env values ever appear alongside the badge — key names only", () => {
1360
+ const html = render(["CLAUDE_CODE_OAUTH_TOKEN"]);
1361
+ // The rendered badge must carry only the key name, never a value —
1362
+ // there is no value to render here since AgentDetail.missingRequiredEnv
1363
+ // is string[] of key names (secrets_in_logs).
1364
+ expect(html).not.toContain("secret-value");
1365
+ });
1366
+
1367
+ test("XSS: missing env key names are escaped", () => {
1368
+ const html = render(['<script>alert("xss")</script>']);
1369
+ expect(html).not.toContain('alert("xss")</script>');
1370
+ expect(html).toContain("&lt;script&gt;");
1371
+ });
1372
+ });
1373
+
1374
+ // ─── renderAgentDetailPage — crons section ───────────────────────────────────
1375
+
1376
+ describe("renderAgentDetailPage — crons", () => {
1377
+ function render(crons: CronJobItem[]): string {
1378
+ return renderAgentDetailPage(
1379
+ AGENT,
1380
+ {},
1381
+ crons,
1382
+ [],
1383
+ [],
1384
+ [],
1385
+ [],
1386
+ USER_NAME,
1387
+ true,
1388
+ { timezone: "UTC" },
1389
+ );
1390
+ }
1391
+
1392
+ test("empty crons: 'No system crons configured.' shown", () => {
1393
+ const html = render([]);
1394
+ expect(html).toContain("No system crons configured.");
1395
+ });
1396
+
1397
+ test("empty crons: 'No custom crons yet.' shown", () => {
1398
+ const html = render([]);
1399
+ expect(html).toContain("No custom crons yet.");
1400
+ });
1401
+
1402
+ test("system cron: toggle form action present", () => {
1403
+ const html = render([SYSTEM_CRON]);
1404
+ expect(html).toContain(
1405
+ `action="/admin/agents/${AGENT.id}/crons/${SYSTEM_CRON.id}/toggle"`,
1406
+ );
1407
+ });
1408
+
1409
+ test("system cron: NO delete form (system crons cannot be deleted)", () => {
1410
+ const html = render([SYSTEM_CRON]);
1411
+ expect(html).not.toContain(
1412
+ `action="/admin/agents/${AGENT.id}/crons/${SYSTEM_CRON.id}/delete"`,
1413
+ );
1414
+ });
1415
+
1416
+ test("custom cron: toggle form action present", () => {
1417
+ const html = render([CUSTOM_CRON]);
1418
+ expect(html).toContain(
1419
+ `action="/admin/agents/${AGENT.id}/crons/${CUSTOM_CRON.id}/toggle"`,
1420
+ );
1421
+ });
1422
+
1423
+ test("custom cron: delete form action present", () => {
1424
+ const html = render([CUSTOM_CRON]);
1425
+ expect(html).toContain(
1426
+ `action="/admin/agents/${AGENT.id}/crons/${CUSTOM_CRON.id}/delete"`,
1427
+ );
1428
+ });
1429
+
1430
+ test("custom cron: full edit form posts to /update with prefilled fields", () => {
1431
+ const html = render([CUSTOM_CRON]);
1432
+ expect(html).toContain(
1433
+ `action="/admin/agents/${AGENT.id}/crons/${CUSTOM_CRON.id}/update"`,
1434
+ );
1435
+ // full edit — schedule, prompt, channel, preCheck all editable
1436
+ expect(html).toContain('name="schedule"');
1437
+ expect(html).toContain('name="prompt"');
1438
+ expect(html).toContain('name="channel"');
1439
+ expect(html).toContain('name="preCheck"');
1440
+ });
1441
+
1442
+ test("system cron: NO edit form (contents owned by reconcile)", () => {
1443
+ const html = render([SYSTEM_CRON]);
1444
+ expect(html).not.toContain(
1445
+ `action="/admin/agents/${AGENT.id}/crons/${SYSTEM_CRON.id}/update"`,
1446
+ );
1447
+ });
1448
+
1449
+ test("cron: preCheck column header + value rendered (system cron, read-only)", () => {
1450
+ const html = render([SYSTEM_CRON]);
1451
+ expect(html).toContain("Pre-check");
1452
+ expect(html).toContain("shipwright:check-dev-task.ts");
1453
+ });
1454
+
1455
+ test("enabled cron: badge-green and 'enabled' text shown", () => {
1456
+ const html = render([SYSTEM_CRON]); // SYSTEM_CRON is enabled
1457
+ expect(html).toContain("badge-green");
1458
+ expect(html).toContain(">enabled<");
1459
+ });
1460
+
1461
+ test("disabled cron: badge-gray and 'disabled' text shown", () => {
1462
+ const html = render([CUSTOM_CRON]); // CUSTOM_CRON is disabled
1463
+ expect(html).toContain("badge-gray");
1464
+ expect(html).toContain(">disabled<");
1465
+ });
1466
+
1467
+ test("cron create form action points to /admin/agents/{agentId}/crons", () => {
1468
+ const html = render([]);
1469
+ expect(html).toContain(`action="/admin/agents/${AGENT.id}/crons"`);
1470
+ });
1471
+
1472
+ test("cron schedule is rendered", () => {
1473
+ const html = render([CUSTOM_CRON]);
1474
+ expect(html).toContain("30 8 * * *");
1475
+ });
1476
+
1477
+ test("cron prompt is rendered", () => {
1478
+ const html = render([CUSTOM_CRON]);
1479
+ expect(html).toContain("Daily standup prompt");
1480
+ });
1481
+
1482
+ test("named system cron renders name prefix in prompt cell", () => {
1483
+ const html = render([SYSTEM_CRON]);
1484
+ expect(html).toContain("health-check:");
1485
+ expect(html).toContain("System health check");
1486
+ });
1487
+
1488
+ test("XSS: cron schedule is escaped", () => {
1489
+ const xssCron: CronJobItem = {
1490
+ ...CUSTOM_CRON,
1491
+ schedule: "<script>bad()</script>",
1492
+ };
1493
+ const html = render([xssCron]);
1494
+ // The raw XSS payload must not appear unescaped in the output
1495
+ expect(html).not.toContain(">bad()<");
1496
+ expect(html).toContain("&lt;script&gt;");
1497
+ });
1498
+
1499
+ test("XSS: cron prompt is escaped", () => {
1500
+ const xssCron: CronJobItem = {
1501
+ ...CUSTOM_CRON,
1502
+ prompt: "<img src=x onerror=bad()>",
1503
+ };
1504
+ const html = render([xssCron]);
1505
+ expect(html).not.toContain("<img src=x");
1506
+ expect(html).toContain("&lt;img");
1507
+ });
1508
+
1509
+ test("renderCronRow with lastRun: shows relative time and outcome badge", () => {
1510
+ // Use a fixed reference time so the test is deterministic at any wall-clock instant.
1511
+ const fixedNow = new Date("2024-06-01T12:00:00Z");
1512
+ const twoHoursAgo = new Date(fixedNow.getTime() - 2 * 3600 * 1000);
1513
+ const cronWithLastRun: CronJobItem = {
1514
+ ...CUSTOM_CRON,
1515
+ lastRun: {
1516
+ startedAt: twoHoursAgo,
1517
+ completedAt: new Date(twoHoursAgo.getTime() + 60000),
1518
+ skipped: false,
1519
+ outcome: "posted",
1520
+ },
1521
+ runCountToday: 3,
1522
+ };
1523
+ // Pass now via opts so relativeTime uses a fixed reference — deterministic at any wall-clock instant.
1524
+ const html = renderAgentDetailPage(
1525
+ AGENT,
1526
+ {},
1527
+ [cronWithLastRun],
1528
+ [],
1529
+ [],
1530
+ [],
1531
+ [],
1532
+ USER_NAME,
1533
+ true,
1534
+ { now: fixedNow, timezone: "UTC" },
1535
+ );
1536
+ expect(html).toContain("hours ago");
1537
+ expect(html).toContain("posted");
1538
+ expect(html).toContain("3 runs");
1539
+ });
1540
+
1541
+ test("renderCronRow without lastRun: shows 'never'", () => {
1542
+ const html = render([CUSTOM_CRON]);
1543
+ expect(html).toContain("never");
1544
+ });
1545
+
1546
+ test("renderCronRow today count: 1 run (singular)", () => {
1547
+ const fixedNow = new Date("2024-06-01T12:00:00Z");
1548
+ const twoHoursAgo = new Date(fixedNow.getTime() - 2 * 3600 * 1000);
1549
+ const cronWithOneRun: CronJobItem = {
1550
+ ...CUSTOM_CRON,
1551
+ lastRun: {
1552
+ startedAt: twoHoursAgo,
1553
+ completedAt: new Date(twoHoursAgo.getTime() + 60000),
1554
+ skipped: false,
1555
+ outcome: "posted",
1556
+ },
1557
+ runCountToday: 1,
1558
+ };
1559
+ // Pass now via opts so relativeTime uses a fixed reference — deterministic at any wall-clock instant.
1560
+ const html = renderAgentDetailPage(
1561
+ AGENT,
1562
+ {},
1563
+ [cronWithOneRun],
1564
+ [],
1565
+ [],
1566
+ [],
1567
+ [],
1568
+ USER_NAME,
1569
+ true,
1570
+ { now: fixedNow, timezone: "UTC" },
1571
+ );
1572
+ expect(html).toContain("1 run");
1573
+ });
1574
+
1575
+ test("renderCronRow with lastRun skipped: shows 'skipped' badge (not 'unknown')", () => {
1576
+ const fixedNow = new Date("2024-06-01T12:00:00Z");
1577
+ const twoHoursAgo = new Date(fixedNow.getTime() - 2 * 3600 * 1000);
1578
+ const cronWithSkippedRun: CronJobItem = {
1579
+ ...CUSTOM_CRON,
1580
+ lastRun: {
1581
+ startedAt: twoHoursAgo,
1582
+ completedAt: new Date(twoHoursAgo.getTime() + 1000),
1583
+ skipped: true,
1584
+ outcome: null,
1585
+ },
1586
+ runCountToday: 0,
1587
+ };
1588
+ const html = renderAgentDetailPage(
1589
+ AGENT,
1590
+ {},
1591
+ [cronWithSkippedRun],
1592
+ [],
1593
+ [],
1594
+ [],
1595
+ [],
1596
+ USER_NAME,
1597
+ true,
1598
+ { now: fixedNow, timezone: "UTC" },
1599
+ );
1600
+ expect(html).toContain(">skipped<");
1601
+ expect(html).not.toContain(">unknown<");
1602
+ });
1603
+
1604
+ test("System Crons table is wrapped in .data-table-wrapper", () => {
1605
+ const html = render([SYSTEM_CRON]);
1606
+ expect(html).toMatch(
1607
+ /<div class="data-table-wrapper">\s*<table class="data-table">\s*<thead>\s*<tr>\s*<th>Schedule<\/th>/,
1608
+ );
1609
+ });
1610
+
1611
+ test("Custom Crons table is wrapped in .data-table-wrapper", () => {
1612
+ const html = render([CUSTOM_CRON]);
1613
+ // Both System and Custom crons tables share the same header shape —
1614
+ // assert there are two independent wrapper+table+Schedule occurrences.
1615
+ const matches = html.match(
1616
+ /<div class="data-table-wrapper">\s*<table class="data-table">\s*<thead>\s*<tr>\s*<th>Schedule<\/th>/g,
1617
+ );
1618
+ expect(matches?.length).toBe(2);
1619
+ });
1620
+
1621
+ test("System/Custom Crons table has a 'Created' column header", () => {
1622
+ const html = render([SYSTEM_CRON]);
1623
+ const createdHeaderCount = (
1624
+ html.match(/<th class="col-created">Created<\/th>/g) ?? []
1625
+ ).length;
1626
+ expect(createdHeaderCount).toBeGreaterThanOrEqual(2);
1627
+ });
1628
+
1629
+ test("System/Custom Crons table still has 'Last run' column header alongside Created", () => {
1630
+ const html = render([SYSTEM_CRON]);
1631
+ expect(html).toContain("<th>Last run</th>");
1632
+ expect(html).toContain('<th class="col-created">Created</th>');
1633
+ });
1634
+
1635
+ test("cron createdAt renders in the Created column", () => {
1636
+ const html = render([SYSTEM_CRON]);
1637
+ // SYSTEM_CRON.createdAt = 2024-02-01T00:00:00Z formatted en-US/UTC
1638
+ expect(html).toContain(
1639
+ new Date(SYSTEM_CRON.createdAt).toLocaleDateString("en-US", {
1640
+ timeZone: "UTC",
1641
+ }),
1642
+ );
1643
+ });
1644
+ });
1645
+
1646
+ // ─── renderAgentDetailPage — tools section ───────────────────────────────────
1647
+
1648
+ describe("renderAgentDetailPage — tools", () => {
1649
+ function render(tools: ToolItem[]): string {
1650
+ return renderAgentDetailPage(
1651
+ AGENT,
1652
+ {},
1653
+ [],
1654
+ tools,
1655
+ [],
1656
+ [],
1657
+ [],
1658
+ USER_NAME,
1659
+ true,
1660
+ { timezone: "UTC" },
1661
+ );
1662
+ }
1663
+
1664
+ test("empty tools: 'No tools configured.' empty state", () => {
1665
+ const html = render([]);
1666
+ expect(html).toContain("No tools configured.");
1667
+ });
1668
+
1669
+ test("tool pattern is rendered", () => {
1670
+ const html = render([TOOL_ENABLED]);
1671
+ expect(html).toContain("Bash(git:*)");
1672
+ });
1673
+
1674
+ test("toggle form action: /admin/agents/{agentId}/tools/{toolId}/toggle", () => {
1675
+ const html = render([TOOL_ENABLED]);
1676
+ expect(html).toContain(
1677
+ `action="/admin/agents/${AGENT.id}/tools/${TOOL_ENABLED.id}/toggle"`,
1678
+ );
1679
+ });
1680
+
1681
+ test("delete form action: /admin/agents/{agentId}/tools/{toolId}/delete", () => {
1682
+ const html = render([TOOL_ENABLED]);
1683
+ expect(html).toContain(
1684
+ `action="/admin/agents/${AGENT.id}/tools/${TOOL_ENABLED.id}/delete"`,
1685
+ );
1686
+ });
1687
+
1688
+ test("enabled tool: badge-green and 'enabled' text", () => {
1689
+ const html = render([TOOL_ENABLED]);
1690
+ expect(html).toContain("badge-green");
1691
+ expect(html).toContain(">enabled<");
1692
+ });
1693
+
1694
+ test("disabled tool: badge-gray and 'disabled' text", () => {
1695
+ const html = render([TOOL_DISABLED]);
1696
+ expect(html).toContain("badge-gray");
1697
+ expect(html).toContain(">disabled<");
1698
+ });
1699
+
1700
+ test("add tool form action: /admin/agents/{agentId}/tools", () => {
1701
+ const html = render([]);
1702
+ expect(html).toContain(`action="/admin/agents/${AGENT.id}/tools"`);
1703
+ });
1704
+
1705
+ test("XSS: tool pattern is escaped", () => {
1706
+ const xssTool: ToolItem = {
1707
+ ...TOOL_ENABLED,
1708
+ pattern: "<script>alert(1)</script>",
1709
+ };
1710
+ const html = render([xssTool]);
1711
+ // The raw XSS payload must not appear unescaped in the output
1712
+ expect(html).not.toContain(">alert(1)<");
1713
+ expect(html).toContain("&lt;script&gt;");
1714
+ });
1715
+
1716
+ test("Tools table is wrapped in .data-table-wrapper", () => {
1717
+ const html = render([TOOL_ENABLED]);
1718
+ expect(html).toMatch(
1719
+ /<div class="data-table-wrapper">\s*<table class="data-table">\s*<thead>\s*<tr>\s*<th>Pattern<\/th>/,
1720
+ );
1721
+ });
1722
+
1723
+ test("Tools table has a 'Created' column header", () => {
1724
+ const html = render([TOOL_ENABLED]);
1725
+ expect(html).toContain("<th>Created</th>");
1726
+ });
1727
+
1728
+ test("tool createdAt renders in the Created column", () => {
1729
+ const html = render([TOOL_ENABLED]);
1730
+ expect(html).toContain(
1731
+ new Date(TOOL_ENABLED.createdAt).toLocaleDateString("en-US", {
1732
+ timeZone: "UTC",
1733
+ }),
1734
+ );
1735
+ });
1736
+ });
1737
+
1738
+ // ─── renderAgentDetailPage — tokens section ──────────────────────────────────
1739
+
1740
+ describe("renderAgentDetailPage — tokens", () => {
1741
+ function render(tokens: TokenItem[]): string {
1742
+ return renderAgentDetailPage(
1743
+ AGENT,
1744
+ {},
1745
+ [],
1746
+ [],
1747
+ tokens,
1748
+ [],
1749
+ [],
1750
+ USER_NAME,
1751
+ true,
1752
+ { timezone: "UTC" },
1753
+ );
1754
+ }
1755
+
1756
+ test("empty tokens: 'No tokens created.' empty state", () => {
1757
+ const html = render([]);
1758
+ expect(html).toContain("No tokens created.");
1759
+ });
1760
+
1761
+ test("active token: badge-green and 'Active' shown", () => {
1762
+ const html = render([TOKEN_ACTIVE]);
1763
+ expect(html).toContain("badge-green");
1764
+ expect(html).toContain(">Active<");
1765
+ });
1766
+
1767
+ test("active token: Revoke form present", () => {
1768
+ const html = render([TOKEN_ACTIVE]);
1769
+ expect(html).toContain(
1770
+ `action="/admin/agents/${AGENT.id}/tokens/${TOKEN_ACTIVE.id}/revoke"`,
1771
+ );
1772
+ });
1773
+
1774
+ test("revoked token: badge-gray and 'Revoked' shown", () => {
1775
+ const html = render([TOKEN_REVOKED]);
1776
+ expect(html).toContain("badge-gray");
1777
+ expect(html).toContain("Revoked");
1778
+ });
1779
+
1780
+ test("revoked token: NO revoke form", () => {
1781
+ const html = render([TOKEN_REVOKED]);
1782
+ expect(html).not.toContain(
1783
+ `action="/admin/agents/${AGENT.id}/tokens/${TOKEN_REVOKED.id}/revoke"`,
1784
+ );
1785
+ });
1786
+
1787
+ test("token create form action: /admin/agents/{agentId}/tokens", () => {
1788
+ const html = render([]);
1789
+ expect(html).toContain(`action="/admin/agents/${AGENT.id}/tokens"`);
1790
+ });
1791
+
1792
+ test("revoke form action: /admin/agents/{agentId}/tokens/{tokenId}/revoke", () => {
1793
+ const html = render([TOKEN_ACTIVE]);
1794
+ expect(html).toContain(
1795
+ `action="/admin/agents/${AGENT.id}/tokens/${TOKEN_ACTIVE.id}/revoke"`,
1796
+ );
1797
+ });
1798
+
1799
+ test("token label is rendered", () => {
1800
+ const html = render([TOKEN_ACTIVE]);
1801
+ expect(html).toContain("CI token");
1802
+ });
1803
+
1804
+ test("null label renders dash placeholder", () => {
1805
+ const noLabel: TokenItem = { ...TOKEN_ACTIVE, label: null };
1806
+ const html = render([noLabel]);
1807
+ // The template renders a grey dash span when label is null
1808
+ expect(html).toContain("color:#9ca3af");
1809
+ });
1810
+
1811
+ test("XSS: token label is escaped", () => {
1812
+ const xssToken: TokenItem = {
1813
+ ...TOKEN_ACTIVE,
1814
+ label: "<script>steal()</script>",
1815
+ };
1816
+ const html = render([xssToken]);
1817
+ // The raw XSS payload must not appear unescaped in the output
1818
+ expect(html).not.toContain(">steal()<");
1819
+ expect(html).toContain("&lt;script&gt;");
1820
+ });
1821
+
1822
+ test("Task Store Tokens table is wrapped in .data-table-wrapper", () => {
1823
+ const html = render([TOKEN_ACTIVE]);
1824
+ expect(html).toMatch(
1825
+ /<div class="data-table-wrapper">\s*<table class="data-table">\s*<thead>\s*<tr>\s*<th>Label<\/th>/,
1826
+ );
1827
+ });
1828
+ });
1829
+
1830
+ // ─── renderAgentDetailPage — plugins section ─────────────────────────────────
1831
+
1832
+ describe("renderAgentDetailPage — plugins", () => {
1833
+ function render(plugins: PluginItem[]): string {
1834
+ return renderAgentDetailPage(
1835
+ AGENT,
1836
+ {},
1837
+ [],
1838
+ [],
1839
+ [],
1840
+ plugins,
1841
+ [],
1842
+ USER_NAME,
1843
+ true,
1844
+ { timezone: "UTC" },
1845
+ );
1846
+ }
1847
+
1848
+ test("empty plugins: 'No plugins installed.' empty state", () => {
1849
+ const html = render([]);
1850
+ expect(html).toContain("No plugins installed.");
1851
+ });
1852
+
1853
+ test("plugin name is rendered", () => {
1854
+ const html = render([PLUGIN_ENABLED]);
1855
+ expect(html).toContain("shipwright");
1856
+ });
1857
+
1858
+ test("plugin version is rendered when present", () => {
1859
+ const html = render([PLUGIN_ENABLED]);
1860
+ expect(html).toContain("1.2.3");
1861
+ });
1862
+
1863
+ test("null version renders 'latest'", () => {
1864
+ const html = render([PLUGIN_DISABLED]); // PLUGIN_DISABLED has version: null
1865
+ expect(html).toContain("latest");
1866
+ });
1867
+
1868
+ test("enabled plugin: badge-green and 'enabled' text", () => {
1869
+ const html = render([PLUGIN_ENABLED]);
1870
+ expect(html).toContain("badge-green");
1871
+ expect(html).toContain(">enabled<");
1872
+ });
1873
+
1874
+ test("disabled plugin: badge-gray and 'disabled' text", () => {
1875
+ const html = render([PLUGIN_DISABLED]);
1876
+ expect(html).toContain("badge-gray");
1877
+ expect(html).toContain(">disabled<");
1878
+ });
1879
+
1880
+ test("XSS: plugin name is escaped", () => {
1881
+ const xssPlugin: PluginItem = {
1882
+ ...PLUGIN_ENABLED,
1883
+ name: "<script>bad()</script>",
1884
+ };
1885
+ const html = render([xssPlugin]);
1886
+ // The raw XSS payload must not appear unescaped in the output
1887
+ expect(html).not.toContain(">bad()<");
1888
+ expect(html).toContain("&lt;script&gt;");
1889
+ });
1890
+
1891
+ test("Plugins table is wrapped in .data-table-wrapper", () => {
1892
+ const html = render([PLUGIN_ENABLED]);
1893
+ expect(html).toMatch(
1894
+ /<div class="data-table-wrapper">\s*<table class="data-table">\s*<thead>\s*<tr>\s*<th>Package<\/th>/,
1895
+ );
1896
+ });
1897
+
1898
+ test("Plugins table has a 'Created' column header", () => {
1899
+ const html = render([PLUGIN_ENABLED]);
1900
+ expect(html).toContain("<th>Created</th>");
1901
+ });
1902
+
1903
+ test("plugin createdAt renders in the Created column", () => {
1904
+ const html = render([PLUGIN_ENABLED]);
1905
+ expect(html).toContain(
1906
+ new Date(PLUGIN_ENABLED.createdAt).toLocaleDateString("en-US", {
1907
+ timeZone: "UTC",
1908
+ }),
1909
+ );
1910
+ });
1911
+ });
1912
+
1913
+ // ─── renderProvisionCompletePage ─────────────────────────────────────────────
1914
+
1915
+ describe("renderProvisionCompletePage", () => {
1916
+ test("returns a valid HTML document", () => {
1917
+ const html = renderProvisionCompletePage(USER_NAME, { success: true });
1918
+ expect(html).toContain("<!DOCTYPE html>");
1919
+ expect(html).toContain("<html");
1920
+ expect(html).toContain("</html>");
1921
+ });
1922
+
1923
+ test("success: shows success alert", () => {
1924
+ const html = renderProvisionCompletePage(USER_NAME, {
1925
+ success: true,
1926
+ agentId: "agent-new",
1927
+ });
1928
+ expect(html).toContain('class="alert alert-success"');
1929
+ });
1930
+
1931
+ test("success: 'View Agent' link includes agentId", () => {
1932
+ const html = renderProvisionCompletePage(USER_NAME, {
1933
+ success: true,
1934
+ agentId: "agent-new",
1935
+ });
1936
+ expect(html).toContain("View Agent");
1937
+ expect(html).toContain("/admin/agents/agent-new");
1938
+ });
1939
+
1940
+ test("success without agentId: no 'View Agent' link", () => {
1941
+ const html = renderProvisionCompletePage(USER_NAME, { success: true });
1942
+ expect(html).not.toContain("View Agent");
1943
+ });
1944
+
1945
+ test("failure: shows error alert", () => {
1946
+ const html = renderProvisionCompletePage(USER_NAME, {
1947
+ success: false,
1948
+ error: "Provisioning failed: bad token",
1949
+ });
1950
+ expect(html).toContain('class="alert alert-error"');
1951
+ expect(html).toContain("Provisioning failed: bad token");
1952
+ });
1953
+
1954
+ test("failure: shows 'Try again' link", () => {
1955
+ const html = renderProvisionCompletePage(USER_NAME, { success: false });
1956
+ expect(html).toContain("Try again");
1957
+ expect(html).toContain("/admin/provision");
1958
+ });
1959
+
1960
+ test("failure with no error: shows default 'Provisioning failed.' message", () => {
1961
+ const html = renderProvisionCompletePage(USER_NAME, { success: false });
1962
+ expect(html).toContain("Provisioning failed.");
1963
+ });
1964
+
1965
+ test("XSS: error message is escaped", () => {
1966
+ const html = renderProvisionCompletePage(USER_NAME, {
1967
+ success: false,
1968
+ error: "<script>steal()</script>",
1969
+ });
1970
+ expect(html).not.toContain("<script>steal()</script>");
1971
+ expect(html).toContain("&lt;script&gt;");
1972
+ });
1973
+
1974
+ test("XSS: agentId in View Agent link is escaped", () => {
1975
+ const html = renderProvisionCompletePage(USER_NAME, {
1976
+ success: true,
1977
+ agentId: '"><script>xss()</script>',
1978
+ });
1979
+ expect(html).not.toContain('"><script>xss()</script>');
1980
+ expect(html).toContain("&lt;script&gt;");
1981
+ });
1982
+ });
1983
+
1984
+ // ─── renderAgentDetailPage — repos section ───────────────────────────────────
1985
+
1986
+ describe("renderAgentDetailPage — repos", () => {
1987
+ function render(repos: string[]): string {
1988
+ const agent: AgentDetail = { ...AGENT, repos };
1989
+ return renderAgentDetailPage(
1990
+ agent,
1991
+ {},
1992
+ [],
1993
+ [],
1994
+ [],
1995
+ [],
1996
+ [],
1997
+ USER_NAME,
1998
+ true,
1999
+ { timezone: "UTC" },
2000
+ );
2001
+ }
2002
+
2003
+ test("renders empty repos state", () => {
2004
+ const html = render([]);
2005
+ expect(html).toContain("No repos configured.");
2006
+ });
2007
+
2008
+ test("renders repos list", () => {
2009
+ const html = render(["my-org/my-repo"]);
2010
+ expect(html).toContain("my-org/my-repo");
2011
+ });
2012
+
2013
+ test("repos section has add form", () => {
2014
+ const html = render([]);
2015
+ expect(html).toContain(`action="/admin/agents/${AGENT.id}/repos/add"`);
2016
+ expect(html).toContain('name="repo"');
2017
+ });
2018
+
2019
+ test("repos section has remove button for existing repo", () => {
2020
+ const html = render(["my-org/my-repo"]);
2021
+ expect(html).toContain(`action="/admin/agents/${AGENT.id}/repos/delete"`);
2022
+ expect(html).toContain('value="my-org/my-repo"');
2023
+ });
2024
+
2025
+ test("Repos table is wrapped in .data-table-wrapper", () => {
2026
+ const html = render(["my-org/my-repo"]);
2027
+ expect(html).toMatch(
2028
+ /<div class="data-table-wrapper">\s*<table class="data-table">\s*<thead>\s*<tr>\s*<th>Repo<\/th>/,
2029
+ );
2030
+ });
2031
+ });
2032
+
2033
+ // ─── renderAgentDetailPage — author allowlist section ────────────────────────
2034
+
2035
+ describe("renderAgentDetailPage — author allowlist", () => {
2036
+ function render(authorAllowlist: string[]): string {
2037
+ const agent: AgentDetail = { ...AGENT, authorAllowlist };
2038
+ return renderAgentDetailPage(
2039
+ agent,
2040
+ {},
2041
+ [],
2042
+ [],
2043
+ [],
2044
+ [],
2045
+ [],
2046
+ USER_NAME,
2047
+ true,
2048
+ { timezone: "UTC" },
2049
+ );
2050
+ }
2051
+
2052
+ test("renders empty author allowlist state", () => {
2053
+ const html = render([]);
2054
+ expect(html).toContain("No author allowlist entries configured.");
2055
+ });
2056
+
2057
+ test("renders author allowlist list", () => {
2058
+ const html = render(["octocat"]);
2059
+ expect(html).toContain("octocat");
2060
+ });
2061
+
2062
+ test("card title reads 'Author allowlist (review)'", () => {
2063
+ const html = render([]);
2064
+ expect(html).toContain(
2065
+ '<div class="card-title">Author allowlist (review)</div>',
2066
+ );
2067
+ });
2068
+
2069
+ test("author allowlist section has add form", () => {
2070
+ const html = render([]);
2071
+ expect(html).toContain(
2072
+ `action="/admin/agents/${AGENT.id}/review-author-allowlist/add"`,
2073
+ );
2074
+ expect(html).toContain('name="login"');
2075
+ });
2076
+
2077
+ test("author allowlist section has remove button for existing login", () => {
2078
+ const html = render(["octocat"]);
2079
+ expect(html).toContain(
2080
+ `action="/admin/agents/${AGENT.id}/review-author-allowlist/delete"`,
2081
+ );
2082
+ expect(html).toContain('value="octocat"');
2083
+ });
2084
+
2085
+ test("Author allowlist table is wrapped in .data-table-wrapper", () => {
2086
+ const html = render(["octocat"]);
2087
+ expect(html).toMatch(
2088
+ /<div class="data-table-wrapper">\s*<table class="data-table">\s*<thead>\s*<tr>\s*<th>GitHub login<\/th>/,
2089
+ );
2090
+ });
2091
+ });
2092
+
2093
+ // ─── renderAgentDetailPage — patch author allowlist section ──────────────────
2094
+
2095
+ describe("renderAgentDetailPage — patch author allowlist", () => {
2096
+ function render(patchAuthorAllowlist: string[]): string {
2097
+ const agent: AgentDetail = { ...AGENT, patchAuthorAllowlist };
2098
+ return renderAgentDetailPage(
2099
+ agent,
2100
+ {},
2101
+ [],
2102
+ [],
2103
+ [],
2104
+ [],
2105
+ [],
2106
+ USER_NAME,
2107
+ true,
2108
+ { timezone: "UTC" },
2109
+ );
2110
+ }
2111
+
2112
+ test("renders empty patch author allowlist state", () => {
2113
+ const html = render([]);
2114
+ expect(html).toContain("No patch author allowlist entries configured.");
2115
+ });
2116
+
2117
+ test("renders patch author allowlist list", () => {
2118
+ const html = render(["octocat"]);
2119
+ expect(html).toContain("octocat");
2120
+ });
2121
+
2122
+ test("card title reads 'Author allowlist (patch)'", () => {
2123
+ const html = render([]);
2124
+ expect(html).toContain(
2125
+ '<div class="card-title">Author allowlist (patch)</div>',
2126
+ );
2127
+ });
2128
+
2129
+ test("patch author allowlist section has add form", () => {
2130
+ const html = render([]);
2131
+ expect(html).toContain(
2132
+ `action="/admin/agents/${AGENT.id}/patch-author-allowlist/add"`,
2133
+ );
2134
+ expect(html).toContain('name="login"');
2135
+ });
2136
+
2137
+ test("patch author allowlist section has remove button for existing login", () => {
2138
+ const html = render(["octocat"]);
2139
+ expect(html).toContain(
2140
+ `action="/admin/agents/${AGENT.id}/patch-author-allowlist/delete"`,
2141
+ );
2142
+ expect(html).toContain('value="octocat"');
2143
+ });
2144
+
2145
+ test("patch author allowlist table is wrapped in .data-table-wrapper", () => {
2146
+ const html = render(["octocat"]);
2147
+ expect(html).toMatch(
2148
+ /<div class="data-table-wrapper">\s*<table class="data-table">\s*<thead>\s*<tr>\s*<th>GitHub login<\/th>/,
2149
+ );
2150
+ });
2151
+ });
2152
+
2153
+ // ─── renderAgentDetailPage — accordion groups (AGA-1.1) ──────────────────────
2154
+
2155
+ describe("renderAgentDetailPage — accordion groups", () => {
2156
+ /**
2157
+ * All 4 group `<details class="group">` blocks, in document order.
2158
+ * A naive non-greedy `[\s\S]*?<\/details>` regex breaks here because
2159
+ * several cards (e.g. Slack access's "Connect Slack" popover) nest their
2160
+ * own unrelated `<details>` inside a group's body — this walks nesting
2161
+ * depth so each group's true closing tag is found.
2162
+ */
2163
+ function extractGroups(html: string): string[] {
2164
+ const groups: string[] = [];
2165
+ const openTagRe = /<details class="group">/g;
2166
+ let match: RegExpExecArray | null;
2167
+ // biome-ignore lint/suspicious/noAssignInExpressions: standard regex-loop idiom
2168
+ while ((match = openTagRe.exec(html)) !== null) {
2169
+ const start = match.index;
2170
+ let depth = 1;
2171
+ let cursor = start + match[0].length;
2172
+ const tagRe = /<details\b[^>]*>|<\/details>/g;
2173
+ tagRe.lastIndex = cursor;
2174
+ let tagMatch: RegExpExecArray | null;
2175
+ // biome-ignore lint/suspicious/noAssignInExpressions: standard regex-loop idiom
2176
+ while ((tagMatch = tagRe.exec(html)) !== null) {
2177
+ if (tagMatch[0] === "</details>") {
2178
+ depth--;
2179
+ } else {
2180
+ depth++;
2181
+ }
2182
+ if (depth === 0) {
2183
+ cursor = tagRe.lastIndex;
2184
+ break;
2185
+ }
2186
+ }
2187
+ groups.push(html.slice(start, cursor));
2188
+ }
2189
+ return groups;
2190
+ }
2191
+
2192
+ function render(opts?: Parameters<typeof renderAgentDetailPage>[9]): string {
2193
+ return renderAgentDetailPage(
2194
+ AGENT,
2195
+ {},
2196
+ [SYSTEM_CRON, CUSTOM_CRON],
2197
+ [TOOL_ENABLED, TOOL_DISABLED],
2198
+ [TOKEN_ACTIVE, TOKEN_REVOKED],
2199
+ [PLUGIN_ENABLED, PLUGIN_DISABLED],
2200
+ [
2201
+ {
2202
+ id: "m1",
2203
+ email: "member@example.com",
2204
+ createdAt: new Date("2025-01-01"),
2205
+ },
2206
+ ],
2207
+ USER_NAME,
2208
+ true,
2209
+ { timezone: "UTC", ...opts },
2210
+ );
2211
+ }
2212
+
2213
+ test('renders exactly 4 <details class="group"> elements', () => {
2214
+ const html = render();
2215
+ expect(extractGroups(html)).toHaveLength(4);
2216
+ });
2217
+
2218
+ test("groups are named Automation, Configuration, Access, Plugins & Tools in that order", () => {
2219
+ const html = render();
2220
+ const groups = extractGroups(html);
2221
+ expect(groups[0]).toContain('<span class="group-title">Automation</span>');
2222
+ expect(groups[1]).toContain(
2223
+ '<span class="group-title">Configuration</span>',
2224
+ );
2225
+ expect(groups[2]).toContain('<span class="group-title">Access</span>');
2226
+ expect(groups[3]).toContain(
2227
+ '<span class="group-title">Plugins &amp; Tools</span>',
2228
+ );
2229
+ });
2230
+
2231
+ test("all 4 groups render collapsed (no `open` attribute) by default", () => {
2232
+ const html = render();
2233
+ const groupTags = html.match(/<details class="group"[^>]*>/g) ?? [];
2234
+ expect(groupTags).toHaveLength(4);
2235
+ for (const tag of groupTags) {
2236
+ expect(tag).not.toContain("open");
2237
+ }
2238
+ });
2239
+
2240
+ test("groups stay collapsed even when agent.missingRequiredEnv is non-empty", () => {
2241
+ const agent: AgentDetail = { ...AGENT, missingRequiredEnv: ["GH_TOKEN"] };
2242
+ const html = renderAgentDetailPage(
2243
+ agent,
2244
+ {},
2245
+ [SYSTEM_CRON],
2246
+ [],
2247
+ [],
2248
+ [],
2249
+ [],
2250
+ USER_NAME,
2251
+ true,
2252
+ { timezone: "UTC" },
2253
+ );
2254
+ const groupTags = html.match(/<details class="group"[^>]*>/g) ?? [];
2255
+ expect(groupTags).toHaveLength(4);
2256
+ for (const tag of groupTags) {
2257
+ expect(tag).not.toContain("open");
2258
+ }
2259
+ });
2260
+
2261
+ test("Automation group contains the Cron Jobs card", () => {
2262
+ const html = render();
2263
+ const groups = extractGroups(html);
2264
+ expect(groups[0]).toContain('<div class="card-title">Cron Jobs</div>');
2265
+ expect(groups[0]).not.toContain("Env Vars");
2266
+ });
2267
+
2268
+ test("Configuration group contains Env Vars and Repos cards", () => {
2269
+ const html = render();
2270
+ const groups = extractGroups(html);
2271
+ expect(groups[1]).toContain('id="env-vars"');
2272
+ expect(groups[1]).toContain('<div class="card-title">Repos</div>');
2273
+ expect(groups[1]).not.toContain("Cron Jobs");
2274
+ });
2275
+
2276
+ test("Access group contains Members, both author allowlists, Slack access, and Task Store Tokens cards", () => {
2277
+ const html = render();
2278
+ const groups = extractGroups(html);
2279
+ expect(groups[2]).toContain("member@example.com");
2280
+ expect(groups[2]).toContain(
2281
+ '<div class="card-title">Author allowlist (review)</div>',
2282
+ );
2283
+ expect(groups[2]).toContain(
2284
+ '<div class="card-title">Author allowlist (patch)</div>',
2285
+ );
2286
+ expect(groups[2]).toContain('<div class="card-title">Slack access</div>');
2287
+ expect(groups[2]).toContain(
2288
+ '<div class="card-title">Task Store Tokens</div>',
2289
+ );
2290
+ });
2291
+
2292
+ test("Plugins & Tools group contains Tools and Plugins cards", () => {
2293
+ const html = render();
2294
+ const groups = extractGroups(html);
2295
+ expect(groups[3]).toContain('<div class="card-title">Tools</div>');
2296
+ expect(groups[3]).toContain('<div class="card-title">Plugins</div>');
2297
+ });
2298
+
2299
+ test("Danger Zone stays a bare .card outside any group, after the last group", () => {
2300
+ const html = render();
2301
+ const lastGroupEnd = html.lastIndexOf("</details>");
2302
+ const dangerZoneIndex = html.indexOf("Danger Zone");
2303
+ expect(dangerZoneIndex).toBeGreaterThan(lastGroupEnd);
2304
+ // Sits in its own .card, not nested in a .group-body.
2305
+ const dangerZoneCardIndex = html.indexOf(
2306
+ '<div class="card" style="border:1px solid #fca5a5">',
2307
+ );
2308
+ expect(dangerZoneCardIndex).toBeGreaterThan(lastGroupEnd);
2309
+ });
2310
+
2311
+ test("Danger Zone absent for non-admins, groups still render", () => {
2312
+ const html = renderAgentDetailPage(
2313
+ AGENT,
2314
+ {},
2315
+ [],
2316
+ [],
2317
+ [],
2318
+ [],
2319
+ [],
2320
+ USER_NAME,
2321
+ false,
2322
+ { timezone: "UTC" },
2323
+ );
2324
+ expect(html).not.toContain("Danger Zone");
2325
+ expect(extractGroups(html)).toHaveLength(4);
2326
+ });
2327
+
2328
+ test("each group's summary wraps the toggle+title in one span, separate from the stat span", () => {
2329
+ const html = render();
2330
+ const groups = extractGroups(html);
2331
+ for (const group of groups) {
2332
+ // Exactly one flush-left title span and one flush-right stat span
2333
+ // inside the summary -- not a ::before on <summary> itself.
2334
+ expect(group).toMatch(
2335
+ /<summary class="group-summary">\s*<span class="group-title">[^<]*<\/span>\s*<span class="group-sub">[^<]*<\/span>\s*<\/summary>/,
2336
+ );
2337
+ }
2338
+ });
2339
+
2340
+ test("Automation group's stat string reflects system/custom cron counts", () => {
2341
+ const html = render();
2342
+ const groups = extractGroups(html);
2343
+ expect(groups[0]).toContain(
2344
+ '<span class="group-sub">1 system · 1 custom</span>',
2345
+ );
2346
+ });
2347
+
2348
+ test("Plugins & Tools group's stat string reflects tool/plugin counts", () => {
2349
+ const html = render();
2350
+ const groups = extractGroups(html);
2351
+ expect(groups[3]).toContain(
2352
+ '<span class="group-sub">2 tools · 2 plugins</span>',
2353
+ );
2354
+ });
2355
+
2356
+ test("CSS: toggle glyph is a ::before on .group-title, not on the flex .group-summary", () => {
2357
+ const styles = baseStyles();
2358
+ expect(styles).toContain(".group-title::before");
2359
+ expect(styles).not.toMatch(/\.group-summary\s*::before/);
2360
+ });
2361
+
2362
+ test("CSS: .group-summary is a 2-sided flex row (space-between)", () => {
2363
+ const styles = baseStyles();
2364
+ const match = styles.match(/\.group-summary\s*\{[^}]*\}/);
2365
+ expect(match).not.toBeNull();
2366
+ expect(match?.[0]).toContain("display: flex");
2367
+ expect(match?.[0]).toContain("justify-content: space-between");
2368
+ });
2369
+
2370
+ test("Expand all button is present and renders as a real <button> element", () => {
2371
+ const html = render();
2372
+ expect(html).toContain("<button");
2373
+ expect(html).toContain("Expand all");
2374
+ });
2375
+
2376
+ test("Collapse all button is present and renders as a real <button> element", () => {
2377
+ const html = render();
2378
+ expect(html).toContain("<button");
2379
+ expect(html).toContain("Collapse all");
2380
+ });
2381
+
2382
+ test("inline script with expand/collapse logic is present in the rendered HTML", () => {
2383
+ const html = render();
2384
+ expect(html).toContain("<script>");
2385
+ expect(html).toContain("document.querySelectorAll");
2386
+ expect(html).toContain(".group");
2387
+ });
2388
+
2389
+ test("inline script sets open=true on all .group elements when Expand all is clicked", () => {
2390
+ const html = render();
2391
+ expect(html).toContain("querySelectorAll");
2392
+ expect(html).toContain(".group");
2393
+ expect(html).toContain("el.open = true");
2394
+ });
2395
+
2396
+ test("inline script sets open=false on all .group elements when Collapse all is clicked", () => {
2397
+ const html = render();
2398
+ expect(html).toContain("querySelectorAll");
2399
+ expect(html).toContain(".group");
2400
+ expect(html).toContain("el.open = false");
2401
+ });
2402
+ });
2403
+
2404
+ // ─── renderAgentDetailPage — stat strip (AGA-1.2) ────────────────────────────
2405
+
2406
+ describe("renderAgentDetailPage — stat strip", () => {
2407
+ function render(opts?: Parameters<typeof renderAgentDetailPage>[9]): string {
2408
+ return renderAgentDetailPage(
2409
+ AGENT,
2410
+ {},
2411
+ [SYSTEM_CRON, CUSTOM_CRON],
2412
+ [TOOL_ENABLED, TOOL_DISABLED],
2413
+ [TOKEN_ACTIVE, TOKEN_REVOKED],
2414
+ [PLUGIN_ENABLED, PLUGIN_DISABLED],
2415
+ [
2416
+ {
2417
+ id: "m1",
2418
+ email: "member@example.com",
2419
+ createdAt: new Date("2025-01-01"),
2420
+ },
2421
+ ],
2422
+ USER_NAME,
2423
+ true,
2424
+ { timezone: "UTC", ...opts },
2425
+ );
2426
+ }
2427
+
2428
+ test("stat strip renders exactly 4 stat tiles", () => {
2429
+ const html = render();
2430
+ const tiles = html.match(/<div class="stat-tile"[^>]*>/g) ?? [];
2431
+ expect(tiles).toHaveLength(4);
2432
+ });
2433
+
2434
+ test("stat tiles appear in order: crons active, missing env vars, members, repos", () => {
2435
+ const html = render();
2436
+ const cronsIndex = html.indexOf("crons active");
2437
+ const envIndex = html.indexOf("missing env vars");
2438
+ const membersIndex = html.indexOf("members");
2439
+ const reposIndex = html.indexOf("repos");
2440
+
2441
+ expect(cronsIndex).toBeGreaterThan(-1);
2442
+ expect(envIndex).toBeGreaterThan(-1);
2443
+ expect(membersIndex).toBeGreaterThan(-1);
2444
+ expect(reposIndex).toBeGreaterThan(-1);
2445
+ expect(cronsIndex < envIndex).toBe(true);
2446
+ expect(envIndex < membersIndex).toBe(true);
2447
+ expect(membersIndex < reposIndex).toBe(true);
2448
+ });
2449
+
2450
+ test("crons-active count equals enabled rows across system and custom cron tables", () => {
2451
+ // SYSTEM_CRON is enabled: true, CUSTOM_CRON is enabled: false
2452
+ // So 1 enabled total
2453
+ const html = render();
2454
+ expect(html).toMatch(
2455
+ /<div class="stat-tile"[^>]*>[\s\S]*?crons active[\s\S]*?<span[^>]*>\s*1\s*<\/span>/,
2456
+ );
2457
+ });
2458
+
2459
+ test("crons-active count correctly reflects enabled crons from different configurations", () => {
2460
+ // Test with multiple enabled/disabled crons
2461
+ const enabledCron: CronJobItem = {
2462
+ ...SYSTEM_CRON,
2463
+ id: "cron-sys-2",
2464
+ enabled: true,
2465
+ };
2466
+ const disabledCron: CronJobItem = {
2467
+ ...CUSTOM_CRON,
2468
+ id: "cron-custom-2",
2469
+ enabled: false,
2470
+ };
2471
+ const html = renderAgentDetailPage(
2472
+ AGENT,
2473
+ {},
2474
+ [SYSTEM_CRON, enabledCron, CUSTOM_CRON, disabledCron],
2475
+ [],
2476
+ [],
2477
+ [],
2478
+ [],
2479
+ USER_NAME,
2480
+ true,
2481
+ { timezone: "UTC" },
2482
+ );
2483
+ // Should have 2 enabled crons (SYSTEM_CRON and enabledCron)
2484
+ expect(html).toMatch(
2485
+ /<div class="stat-tile"[^>]*>[\s\S]*?crons active[\s\S]*?<span[^>]*>\s*2\s*<\/span>/,
2486
+ );
2487
+ });
2488
+
2489
+ test("missing-env-vars count equals agent.missingRequiredEnv.length", () => {
2490
+ const html = render();
2491
+ // AGENT fixture has missingRequiredEnv: []
2492
+ expect(html).toMatch(
2493
+ /<div class="stat-tile"[^>]*>[\s\S]*?missing env vars[\s\S]*?<span[^>]*>\s*0\s*<\/span>/,
2494
+ );
2495
+ });
2496
+
2497
+ test("missing-env-vars count shows correct value when missingRequiredEnv is non-empty", () => {
2498
+ const agent: AgentDetail = {
2499
+ ...AGENT,
2500
+ missingRequiredEnv: ["GH_TOKEN", "SLACK_APP_TOKEN"],
2501
+ };
2502
+ const html = renderAgentDetailPage(
2503
+ agent,
2504
+ {},
2505
+ [SYSTEM_CRON, CUSTOM_CRON],
2506
+ [],
2507
+ [],
2508
+ [],
2509
+ [],
2510
+ USER_NAME,
2511
+ true,
2512
+ { timezone: "UTC" },
2513
+ );
2514
+ expect(html).toMatch(
2515
+ /<div class="stat-tile"[^>]*>[\s\S]*?missing env vars[\s\S]*?<span[^>]*>\s*2\s*<\/span>/,
2516
+ );
2517
+ });
2518
+
2519
+ test("members count equals members array length", () => {
2520
+ const html = render();
2521
+ // render() passes 1 member
2522
+ expect(html).toMatch(
2523
+ /<div class="stat-tile"[^>]*>[\s\S]*?members[\s\S]*?<span[^>]*>\s*1\s*<\/span>/,
2524
+ );
2525
+ });
2526
+
2527
+ test("repos count equals agent.repos.length", () => {
2528
+ const agent: AgentDetail = {
2529
+ ...AGENT,
2530
+ repos: ["owner/repo1", "owner/repo2", "owner/repo3"],
2531
+ };
2532
+ const html = renderAgentDetailPage(
2533
+ agent,
2534
+ {},
2535
+ [SYSTEM_CRON, CUSTOM_CRON],
2536
+ [],
2537
+ [],
2538
+ [],
2539
+ [
2540
+ {
2541
+ id: "m1",
2542
+ email: "member@example.com",
2543
+ createdAt: new Date("2025-01-01"),
2544
+ },
2545
+ ],
2546
+ USER_NAME,
2547
+ true,
2548
+ { timezone: "UTC" },
2549
+ );
2550
+ expect(html).toMatch(
2551
+ /<div class="stat-tile"[^>]*>[\s\S]*?repos[\s\S]*?<span[^>]*>\s*3\s*<\/span>/,
2552
+ );
2553
+ });
2554
+
2555
+ test("missing-env-vars tile has warning styling when count > 0", () => {
2556
+ const agent: AgentDetail = {
2557
+ ...AGENT,
2558
+ missingRequiredEnv: ["GH_TOKEN"],
2559
+ };
2560
+ const html = renderAgentDetailPage(
2561
+ agent,
2562
+ {},
2563
+ [SYSTEM_CRON, CUSTOM_CRON],
2564
+ [],
2565
+ [],
2566
+ [],
2567
+ [],
2568
+ USER_NAME,
2569
+ true,
2570
+ { timezone: "UTC" },
2571
+ );
2572
+ // Should contain warning class styling
2573
+ expect(html).toMatch(/stat-tile-warning[\s\S]*?missing env vars/);
2574
+ });
2575
+
2576
+ test("missing-env-vars tile has default styling when count is 0", () => {
2577
+ const html = render();
2578
+ // AGENT fixture has empty missingRequiredEnv
2579
+ const tileSectionMatch = html.match(
2580
+ /<div class="stat-tile"[^>]*>[\s\S]*?missing env vars[\s\S]*?<\/div>\s*<\/div>/,
2581
+ );
2582
+ expect(tileSectionMatch).not.toBeNull();
2583
+ // Should NOT have stat-tile-warning class
2584
+ const tileSection = tileSectionMatch?.[0] ?? "";
2585
+ expect(tileSection).not.toContain("stat-tile-warning");
2586
+ });
2587
+
2588
+ test("stat strip renders before accordion groups", () => {
2589
+ const html = render();
2590
+ const statStripIndex = html.indexOf('class="stat-strip"');
2591
+ const automationGroupIndex = html.indexOf(
2592
+ '<span class="group-title">Automation</span>',
2593
+ );
2594
+
2595
+ expect(statStripIndex).toBeGreaterThan(-1);
2596
+ expect(automationGroupIndex).toBeGreaterThan(-1);
2597
+ expect(statStripIndex < automationGroupIndex).toBe(true);
2598
+ });
2599
+
2600
+ test("CSS: .stat-strip and .stat-tile classes exist in baseStyles", () => {
2601
+ const styles = baseStyles();
2602
+ expect(styles).toContain(".stat-strip");
2603
+ expect(styles).toContain(".stat-tile");
2604
+ });
2605
+
2606
+ test("CSS: .stat-tile-warning class exists for warning styling", () => {
2607
+ const styles = baseStyles();
2608
+ expect(styles).toContain(".stat-tile-warning");
2609
+ });
2610
+ });
2611
+
2612
+ // ─── renderAdminToolbar — active nav highlight ────────────────────────────────
2613
+
2614
+ describe("renderAdminToolbar — active nav highlight", () => {
2615
+ test("activePath /admin/agents: Agents link is active", () => {
2616
+ const html = renderAdminToolbar(USER_NAME, "/admin/agents");
2617
+ expect(html).toContain('href="/admin/agents" class="vos-nav-link active"');
2618
+ });
2619
+
2620
+ test("activePath sub-path /admin/agents/agent-id: Agents link is still active (startsWith)", () => {
2621
+ const html = renderAdminToolbar(USER_NAME, "/admin/agents/agent-id");
2622
+ expect(html).toContain('href="/admin/agents" class="vos-nav-link active"');
2623
+ });
2624
+
2625
+ test("activePath '' (default): neither link is active", () => {
2626
+ const html = renderAdminToolbar(USER_NAME);
2627
+ expect(html).not.toContain('class="vos-nav-link active"');
2628
+ expect(html).toContain('href="/admin/agents" class="vos-nav-link"');
2629
+ });
2630
+ });
2631
+
2632
+ // ─── renderChatPage ───────────────────────────────────────────────────────────
2633
+
2634
+ describe("renderChatPage", () => {
2635
+ const AGENTS = [
2636
+ { id: "agent-1", name: "Agent One" },
2637
+ { id: "agent-2", name: "Agent Two" },
2638
+ ];
2639
+
2640
+ const THREADS: ChatThread[] = [
2641
+ {
2642
+ id: "thread-1",
2643
+ agentId: "agent-1",
2644
+ title: "First Thread",
2645
+ memberId: null,
2646
+ createdAt: "2024-01-01T00:00:00.000Z",
2647
+ updatedAt: "2024-01-01T00:00:00.000Z",
2648
+ },
2649
+ ];
2650
+
2651
+ test("renders agent selector", () => {
2652
+ const html = renderChatPage(AGENTS, undefined, null, "alice");
2653
+ expect(html).toContain("Agent One");
2654
+ expect(html).toContain("Agent Two");
2655
+ });
2656
+
2657
+ test("degraded mode: renders not-configured notice when threads is null", () => {
2658
+ const html = renderChatPage(AGENTS, "agent-1", null, "alice");
2659
+ expect(html).toContain("SHIPWRIGHT_CHAT_SERVICE_URL");
2660
+ });
2661
+
2662
+ test("renders thread list when threads are provided", () => {
2663
+ const html = renderChatPage(AGENTS, "agent-1", THREADS, "alice");
2664
+ expect(html).toContain("First Thread");
2665
+ });
2666
+
2667
+ test("responsive: page includes a @media CSS rule for mobile", () => {
2668
+ const html = renderChatPage(AGENTS, "agent-1", THREADS, "alice");
2669
+ expect(html).toContain("@media");
2670
+ });
2671
+
2672
+ test("responsive: layout wrapper has chat-list-layout class", () => {
2673
+ const html = renderChatPage(AGENTS, "agent-1", THREADS, "alice");
2674
+ expect(html).toContain("chat-list-layout");
2675
+ });
2676
+ });
2677
+
2678
+ // ─── renderChatThreadPage ─────────────────────────────────────────────────────
2679
+
2680
+ describe("renderChatThreadPage", () => {
2681
+ const THREAD: ChatThread = {
2682
+ id: "thread-abc",
2683
+ agentId: "agent-xyz",
2684
+ title: "My Test Thread",
2685
+ memberId: null,
2686
+ createdAt: "2024-01-01T00:00:00.000Z",
2687
+ updatedAt: "2024-01-01T00:00:00.000Z",
2688
+ };
2689
+
2690
+ const USER_MSG: ChatMessage = {
2691
+ id: "msg-1",
2692
+ threadId: "thread-abc",
2693
+ role: "user",
2694
+ body: "Hello, agent!",
2695
+ createdAt: "2024-01-01T00:00:00.000Z",
2696
+ claimedBy: null,
2697
+ repliedAt: null,
2698
+ tokens: null,
2699
+ costUsd: null,
2700
+ errorKind: null,
2701
+ attachmentFilename: null,
2702
+ attachmentSize: null,
2703
+ };
2704
+
2705
+ const ASSISTANT_MSG: ChatMessage = {
2706
+ id: "msg-2",
2707
+ threadId: "thread-abc",
2708
+ role: "assistant",
2709
+ body: "Here is **bold text** and `inline code`.",
2710
+ createdAt: "2024-01-01T00:01:00.000Z",
2711
+ claimedBy: null,
2712
+ repliedAt: "2024-01-01T00:01:05.000Z",
2713
+ tokens: {
2714
+ input_tokens: 100,
2715
+ output_tokens: 50,
2716
+ cache_read_input_tokens: 0,
2717
+ cache_creation_input_tokens: 0,
2718
+ },
2719
+ costUsd: 0.001,
2720
+ errorKind: null,
2721
+ attachmentFilename: null,
2722
+ attachmentSize: null,
2723
+ };
2724
+
2725
+ const ERROR_MSG: ChatMessage = {
2726
+ id: "msg-3",
2727
+ threadId: "thread-abc",
2728
+ role: "assistant",
2729
+ body: "",
2730
+ createdAt: "2024-01-01T00:02:00.000Z",
2731
+ claimedBy: null,
2732
+ repliedAt: null,
2733
+ tokens: null,
2734
+ costUsd: null,
2735
+ errorKind: "rate-limited",
2736
+ attachmentFilename: null,
2737
+ attachmentSize: null,
2738
+ };
2739
+
2740
+ test("degraded mode: renders not-configured notice when thread is null", () => {
2741
+ const html = renderChatThreadPage("agent-xyz", null, null, "alice");
2742
+ expect(html.toLowerCase()).toMatch(/not configured|unavailable|degraded/);
2743
+ });
2744
+
2745
+ test("renders thread title in page", () => {
2746
+ const html = renderChatThreadPage("agent-xyz", THREAD, [USER_MSG], "alice");
2747
+ expect(html).toContain("My Test Thread");
2748
+ });
2749
+
2750
+ test("renders user message body", () => {
2751
+ const html = renderChatThreadPage("agent-xyz", THREAD, [USER_MSG], "alice");
2752
+ expect(html).toContain("Hello, agent!");
2753
+ });
2754
+
2755
+ test("user messages are right-aligned with indigo/blue background", () => {
2756
+ const html = renderChatThreadPage("agent-xyz", THREAD, [USER_MSG], "alice");
2757
+ // right-aligned and indigo/blue background (#eef2ff)
2758
+ expect(html).toContain("#eef2ff");
2759
+ expect(html).toContain("flex-end");
2760
+ });
2761
+
2762
+ test("assistant messages are left-aligned with green background", () => {
2763
+ const html = renderChatThreadPage(
2764
+ "agent-xyz",
2765
+ THREAD,
2766
+ [ASSISTANT_MSG],
2767
+ "alice",
2768
+ );
2769
+ expect(html).toContain("#f0fdf4");
2770
+ expect(html).toContain("flex-start");
2771
+ });
2772
+
2773
+ test("assistant messages render markdown: bold text becomes <strong>", () => {
2774
+ const html = renderChatThreadPage(
2775
+ "agent-xyz",
2776
+ THREAD,
2777
+ [ASSISTANT_MSG],
2778
+ "alice",
2779
+ );
2780
+ expect(html).toContain("<strong>bold text</strong>");
2781
+ });
2782
+
2783
+ test("assistant messages render markdown: inline code becomes <code>", () => {
2784
+ const html = renderChatThreadPage(
2785
+ "agent-xyz",
2786
+ THREAD,
2787
+ [ASSISTANT_MSG],
2788
+ "alice",
2789
+ );
2790
+ expect(html).toContain("<code>inline code</code>");
2791
+ });
2792
+
2793
+ test("errorKind rate-limited shows human-readable error state", () => {
2794
+ const html = renderChatThreadPage(
2795
+ "agent-xyz",
2796
+ THREAD,
2797
+ [ERROR_MSG],
2798
+ "alice",
2799
+ );
2800
+ expect(html).toContain("Rate limited");
2801
+ });
2802
+
2803
+ test("errorKind message renders a red/error badge or indicator", () => {
2804
+ const html = renderChatThreadPage(
2805
+ "agent-xyz",
2806
+ THREAD,
2807
+ [ERROR_MSG],
2808
+ "alice",
2809
+ );
2810
+ // should have some red / error styling
2811
+ expect(html.toLowerCase()).toMatch(/error|#ef4444|#fee2e2|#b91c1c|#dc2626/);
2812
+ });
2813
+
2814
+ test("errorKind cancelled renders a human-readable 'Cancelled' badge", () => {
2815
+ const msg: ChatMessage = { ...ERROR_MSG, errorKind: "cancelled" };
2816
+ const html = renderChatThreadPage("agent-xyz", THREAD, [msg], "alice");
2817
+ expect(html).toContain("Cancelled");
2818
+ });
2819
+
2820
+ test("errorKind incomplete renders a human-readable 'Incomplete' badge", () => {
2821
+ const msg: ChatMessage = { ...ERROR_MSG, errorKind: "incomplete" };
2822
+ const html = renderChatThreadPage("agent-xyz", THREAD, [msg], "alice");
2823
+ expect(html).toContain("Incomplete");
2824
+ });
2825
+
2826
+ test("errorKind stalled renders a human-readable 'Stalled' badge", () => {
2827
+ const msg: ChatMessage = { ...ERROR_MSG, errorKind: "stalled" };
2828
+ const html = renderChatThreadPage("agent-xyz", THREAD, [msg], "alice");
2829
+ expect(html).toContain("Stalled");
2830
+ });
2831
+
2832
+ test("cancelled/incomplete/stalled errorKinds render a Retry action wired to resend the originating user message", () => {
2833
+ for (const kind of ["cancelled", "incomplete", "stalled"]) {
2834
+ const errMsg: ChatMessage = { ...ERROR_MSG, errorKind: kind };
2835
+ const html = renderChatThreadPage(
2836
+ "agent-xyz",
2837
+ THREAD,
2838
+ [USER_MSG, errMsg],
2839
+ "alice",
2840
+ );
2841
+ expect(html).toContain("Retry");
2842
+ expect(html).toContain('class="chat-retry-btn"');
2843
+ expect(html).toContain(`data-retry-body="${USER_MSG.body}"`);
2844
+ }
2845
+ });
2846
+
2847
+ test("Retry action is omitted when there is no preceding user message to resend", () => {
2848
+ const errMsg: ChatMessage = { ...ERROR_MSG, errorKind: "cancelled" };
2849
+ const html = renderChatThreadPage("agent-xyz", THREAD, [errMsg], "alice");
2850
+ // The inline JS statically wires up any '.chat-retry-btn' elements that
2851
+ // exist, so it always contains the class name — assert no button element
2852
+ // with that class was actually rendered into the message markup instead.
2853
+ expect(html).not.toContain('class="chat-retry-btn"');
2854
+ });
2855
+
2856
+ test("Retry click handler resends the button's data-retry-body via the shared send path", () => {
2857
+ const html = renderChatThreadPage(
2858
+ "agent-xyz",
2859
+ THREAD,
2860
+ [USER_MSG, { ...ERROR_MSG, errorKind: "stalled" }],
2861
+ "alice",
2862
+ );
2863
+ expect(html).toContain("querySelectorAll('.chat-retry-btn')");
2864
+ expect(html).toContain("getAttribute('data-retry-body')");
2865
+ expect(html).toContain("sendText(body, null)");
2866
+ });
2867
+
2868
+ test("empty thread shows empty state message", () => {
2869
+ const html = renderChatThreadPage("agent-xyz", THREAD, [], "alice");
2870
+ expect(html).toContain("No messages");
2871
+ });
2872
+
2873
+ test("page includes messages-container element for JS polling", () => {
2874
+ const html = renderChatThreadPage("agent-xyz", THREAD, [USER_MSG], "alice");
2875
+ expect(html).toContain("messages-container");
2876
+ });
2877
+
2878
+ test("page includes the live status bubble id in the inline JS", () => {
2879
+ const html = renderChatThreadPage("agent-xyz", THREAD, [USER_MSG], "alice");
2880
+ expect(html).toContain("live-status-bubble");
2881
+ });
2882
+
2883
+ test("page includes send-btn id for send button", () => {
2884
+ const html = renderChatThreadPage("agent-xyz", THREAD, [USER_MSG], "alice");
2885
+ expect(html).toContain("send-btn");
2886
+ });
2887
+
2888
+ test("page includes messages.json polling endpoint reference in JS", () => {
2889
+ const html = renderChatThreadPage("agent-xyz", THREAD, [USER_MSG], "alice");
2890
+ expect(html).toContain("messages.json");
2891
+ });
2892
+
2893
+ test("page's poll loop uses the CFB-2.3 stall/absolute-max model, not the old poll-count timeouts", () => {
2894
+ const html = renderChatThreadPage("agent-xyz", THREAD, [USER_MSG], "alice");
2895
+ // The new millisecond-based model replaces the old poll-count constants.
2896
+ expect(html).toContain("STALL_WARN_AFTER_MS");
2897
+ expect(html).toContain("ABSOLUTE_MAX_MS");
2898
+ expect(html).not.toContain("IDLE_TIMEOUT_POLLS");
2899
+ expect(html).not.toContain("ABSOLUTE_MAX_POLLS");
2900
+ });
2901
+
2902
+ test("XSS: user message body is escaped", () => {
2903
+ const xssMsg: ChatMessage = {
2904
+ ...USER_MSG,
2905
+ body: '<script>alert("xss")</script>',
2906
+ };
2907
+ const html = renderChatThreadPage("agent-xyz", THREAD, [xssMsg], "alice");
2908
+ expect(html).not.toContain('<script>alert("xss")</script>');
2909
+ expect(html).toContain("&lt;script&gt;");
2910
+ });
2911
+
2912
+ test("renders [upload:/path/file] marker as artifact badge with filename", () => {
2913
+ const msgWithUpload: ChatMessage = {
2914
+ ...ASSISTANT_MSG,
2915
+ body: "Here is the report [upload:/tmp/report.pdf]",
2916
+ };
2917
+ const html = renderChatThreadPage(
2918
+ "agent-xyz",
2919
+ THREAD,
2920
+ [msgWithUpload],
2921
+ "alice",
2922
+ );
2923
+ // [upload:...] should be stripped from body text
2924
+ expect(html).not.toContain("[upload:");
2925
+ // Filename "report.pdf" should appear (not the full path)
2926
+ expect(html).toContain("report.pdf");
2927
+ // Should show as a badge (check for artifact/attachment styling)
2928
+ expect(html).toContain("📎");
2929
+ });
2930
+
2931
+ test("renders [plan:url] marker as clickable link", () => {
2932
+ const msgWithPlan: ChatMessage = {
2933
+ ...ASSISTANT_MSG,
2934
+ body: "See the plan [plan:https://example.com/plan]",
2935
+ };
2936
+ const html = renderChatThreadPage(
2937
+ "agent-xyz",
2938
+ THREAD,
2939
+ [msgWithPlan],
2940
+ "alice",
2941
+ );
2942
+ // [plan:...] should be stripped from body text
2943
+ expect(html).not.toContain("[plan:");
2944
+ // Should render as a link
2945
+ expect(html).toContain("href=");
2946
+ expect(html).toContain("https://example.com/plan");
2947
+ // Should show link text
2948
+ expect(html).toContain("View plan");
2949
+ });
2950
+
2951
+ test("strips [silent] marker from displayed text", () => {
2952
+ const msgWithSilent: ChatMessage = {
2953
+ ...ASSISTANT_MSG,
2954
+ body: "All done [silent]",
2955
+ };
2956
+ const html = renderChatThreadPage(
2957
+ "agent-xyz",
2958
+ THREAD,
2959
+ [msgWithSilent],
2960
+ "alice",
2961
+ );
2962
+ expect(html).not.toContain("[silent]");
2963
+ expect(html).toContain("All done");
2964
+ });
2965
+
2966
+ test("strips [react:emoji] marker from displayed text", () => {
2967
+ const msgWithReact: ChatMessage = {
2968
+ ...ASSISTANT_MSG,
2969
+ body: "Great work [react:thumbsup]",
2970
+ };
2971
+ const html = renderChatThreadPage(
2972
+ "agent-xyz",
2973
+ THREAD,
2974
+ [msgWithReact],
2975
+ "alice",
2976
+ );
2977
+ expect(html).not.toContain("[react:");
2978
+ expect(html).not.toContain("thumbsup");
2979
+ expect(html).toContain("Great work");
2980
+ });
2981
+
2982
+ test("strips [speak:text] marker from displayed text", () => {
2983
+ const msgWithSpeak: ChatMessage = {
2984
+ ...ASSISTANT_MSG,
2985
+ body: "Done with the task [speak:all work complete]",
2986
+ };
2987
+ const html = renderChatThreadPage(
2988
+ "agent-xyz",
2989
+ THREAD,
2990
+ [msgWithSpeak],
2991
+ "alice",
2992
+ );
2993
+ expect(html).not.toContain("[speak:");
2994
+ expect(html).not.toContain("all work complete");
2995
+ expect(html).toContain("Done with the task");
2996
+ });
2997
+
2998
+ test("HTML-escapes marker content (XSS protection on paths/URLs)", () => {
2999
+ const msgWithXss: ChatMessage = {
3000
+ ...ASSISTANT_MSG,
3001
+ body: "File saved [upload:/tmp/file<script>.pdf]",
3002
+ };
3003
+ const html = renderChatThreadPage(
3004
+ "agent-xyz",
3005
+ THREAD,
3006
+ [msgWithXss],
3007
+ "alice",
3008
+ );
3009
+ // The raw XSS payload must not appear verbatim
3010
+ expect(html).not.toContain("file<script>.pdf");
3011
+ // Should still show the filename (escaped)
3012
+ expect(html).toContain("&lt;script&gt;");
3013
+ });
3014
+
3015
+ test("handles multiple markers in one message", () => {
3016
+ const msgWithMultiple: ChatMessage = {
3017
+ ...ASSISTANT_MSG,
3018
+ body: "Report: [upload:/tmp/report.pdf] Plan: [plan:https://example.com/plan] [react:eyes] [silent]",
3019
+ };
3020
+ const html = renderChatThreadPage(
3021
+ "agent-xyz",
3022
+ THREAD,
3023
+ [msgWithMultiple],
3024
+ "alice",
3025
+ );
3026
+ // All markers should be stripped
3027
+ expect(html).not.toContain("[upload:");
3028
+ expect(html).not.toContain("[plan:");
3029
+ expect(html).not.toContain("[react:");
3030
+ expect(html).not.toContain("[silent]");
3031
+ // But content should be present
3032
+ expect(html).toContain("report.pdf");
3033
+ expect(html).toContain("https://example.com/plan");
3034
+ expect(html).toContain("Report:");
3035
+ expect(html).toContain("Plan:");
3036
+ });
3037
+
3038
+ test("renders multiple uploads and plans from one message", () => {
3039
+ const msgWithMultipleMarkers: ChatMessage = {
3040
+ ...ASSISTANT_MSG,
3041
+ body: "[upload:/a.pdf] [upload:/b.pdf] [plan:http://x] [plan:http://y]",
3042
+ };
3043
+ const html = renderChatThreadPage(
3044
+ "agent-xyz",
3045
+ THREAD,
3046
+ [msgWithMultipleMarkers],
3047
+ "alice",
3048
+ );
3049
+ // Should render both filenames
3050
+ expect(html).toContain("a.pdf");
3051
+ expect(html).toContain("b.pdf");
3052
+ // Should render both links
3053
+ expect(html).toContain("http://x");
3054
+ expect(html).toContain("http://y");
3055
+ });
3056
+
3057
+ test("responsive: page includes a @media CSS rule for mobile", () => {
3058
+ const html = renderChatThreadPage("agent-xyz", THREAD, [USER_MSG], "alice");
3059
+ expect(html).toContain("@media");
3060
+ });
3061
+
3062
+ test("responsive: thread sidebar has chat-thread-sidebar class", () => {
3063
+ const THREADS_LIST: ChatThread[] = [
3064
+ {
3065
+ id: "thread-other",
3066
+ agentId: "agent-xyz",
3067
+ title: "Other Thread",
3068
+ memberId: null,
3069
+ createdAt: "2024-01-01T00:00:00.000Z",
3070
+ updatedAt: "2024-01-01T00:00:00.000Z",
3071
+ },
3072
+ ];
3073
+ const html = renderChatThreadPage(
3074
+ "agent-xyz",
3075
+ THREAD,
3076
+ [USER_MSG],
3077
+ THREADS_LIST,
3078
+ "alice",
3079
+ );
3080
+ expect(html).toContain("chat-thread-sidebar");
3081
+ });
3082
+ });
3083
+
3084
+ // ─── renderChatThreadPage — inline styles moved to classes (CFB-1.3) ────────
3085
+ // Migrated elements must carry the new classes and no longer carry a `style=`
3086
+ // attribute of their own (background/text styling on nested badges etc. is
3087
+ // out of scope and untouched).
3088
+
3089
+ describe("renderChatThreadPage — CFB-1.3 class migration", () => {
3090
+ const THREAD: ChatThread = {
3091
+ id: "thread-abc",
3092
+ agentId: "agent-xyz",
3093
+ title: "My Test Thread",
3094
+ memberId: null,
3095
+ createdAt: "2024-01-01T00:00:00.000Z",
3096
+ updatedAt: "2024-01-01T00:00:00.000Z",
3097
+ };
3098
+
3099
+ const USER_MSG: ChatMessage = {
3100
+ id: "msg-1",
3101
+ threadId: "thread-abc",
3102
+ role: "user",
3103
+ body: "Hello, agent!",
3104
+ createdAt: "2024-01-01T00:00:00.000Z",
3105
+ claimedBy: null,
3106
+ repliedAt: null,
3107
+ tokens: null,
3108
+ costUsd: null,
3109
+ errorKind: null,
3110
+ attachmentFilename: null,
3111
+ attachmentSize: null,
3112
+ };
3113
+
3114
+ const SYSTEM_MSG: ChatMessage = {
3115
+ id: "msg-sys",
3116
+ threadId: "thread-abc",
3117
+ role: "system",
3118
+ body: "System notice",
3119
+ createdAt: "2024-01-01T00:00:00.000Z",
3120
+ claimedBy: null,
3121
+ repliedAt: null,
3122
+ tokens: null,
3123
+ costUsd: null,
3124
+ errorKind: null,
3125
+ attachmentFilename: null,
3126
+ attachmentSize: null,
3127
+ };
3128
+
3129
+ test("page wrapper (.vos-page) carries no inline style attribute", () => {
3130
+ const html = renderChatThreadPage("agent-xyz", THREAD, [USER_MSG], "alice");
3131
+ const match = html.match(/<div class="vos-page[^"]*"[^>]*>/);
3132
+ expect(match).not.toBeNull();
3133
+ expect(match?.[0]).not.toContain("style=");
3134
+ expect(match?.[0]).toContain("chat-thread-page");
3135
+ });
3136
+
3137
+ test("header (.page-header) carries no inline style attribute", () => {
3138
+ const html = renderChatThreadPage("agent-xyz", THREAD, [USER_MSG], "alice");
3139
+ const match = html.match(/<div class="page-header[^"]*"[^>]*>/);
3140
+ expect(match).not.toBeNull();
3141
+ expect(match?.[0]).not.toContain("style=");
3142
+ });
3143
+
3144
+ test("messages container carries no inline style attribute", () => {
3145
+ const html = renderChatThreadPage("agent-xyz", THREAD, [USER_MSG], "alice");
3146
+ const match = html.match(/<div id="messages-container"[^>]*>/);
3147
+ expect(match).not.toBeNull();
3148
+ expect(match?.[0]).not.toContain("style=");
3149
+ });
3150
+
3151
+ test("bubble wrapper carries chat-bubble + chat-bubble--user classes, no inline style", () => {
3152
+ const html = renderChatThreadPage("agent-xyz", THREAD, [USER_MSG], "alice");
3153
+ const match = html.match(
3154
+ /<div class="chat-bubble chat-bubble--user"[^>]*>/,
3155
+ );
3156
+ expect(match).not.toBeNull();
3157
+ expect(match?.[0]).not.toContain("style=");
3158
+ });
3159
+
3160
+ test("bubble wrapper for assistant role carries chat-bubble--assistant class", () => {
3161
+ const html = renderChatThreadPage(
3162
+ "agent-xyz",
3163
+ THREAD,
3164
+ [{ ...USER_MSG, id: "msg-a", role: "assistant" }],
3165
+ "alice",
3166
+ );
3167
+ const match = html.match(
3168
+ /<div class="chat-bubble chat-bubble--assistant"[^>]*>/,
3169
+ );
3170
+ expect(match).not.toBeNull();
3171
+ expect(match?.[0]).not.toContain("style=");
3172
+ });
3173
+
3174
+ test("bubble wrapper for system role carries chat-bubble--system class", () => {
3175
+ const html = renderChatThreadPage(
3176
+ "agent-xyz",
3177
+ THREAD,
3178
+ [SYSTEM_MSG],
3179
+ "alice",
3180
+ );
3181
+ const match = html.match(
3182
+ /<div class="chat-bubble chat-bubble--system"[^>]*>/,
3183
+ );
3184
+ expect(match).not.toBeNull();
3185
+ expect(match?.[0]).not.toContain("style=");
3186
+ });
3187
+
3188
+ test("bubble inner carries chat-bubble-inner class and no inline max-width", () => {
3189
+ const html = renderChatThreadPage("agent-xyz", THREAD, [USER_MSG], "alice");
3190
+ const match = html.match(/<div class="chat-bubble-inner[^"]*"[^>]*>/);
3191
+ expect(match).not.toBeNull();
3192
+ expect(match?.[0]).not.toContain("max-width");
3193
+ });
3194
+
3195
+ test("no inline max-width appears in any server-rendered bubble wrapper/inner", () => {
3196
+ const html = renderChatThreadPage(
3197
+ "agent-xyz",
3198
+ THREAD,
3199
+ [USER_MSG, SYSTEM_MSG],
3200
+ "alice",
3201
+ );
3202
+ const bubbleMatches = html.match(/<div class="chat-bubble[^>]*>/g) ?? [];
3203
+ expect(bubbleMatches.length).toBeGreaterThan(0);
3204
+ for (const bubbleTag of bubbleMatches) {
3205
+ expect(bubbleTag).not.toContain("max-width");
3206
+ }
3207
+ });
3208
+
3209
+ test("composer form and row carry no inline style attribute", () => {
3210
+ const html = renderChatThreadPage("agent-xyz", THREAD, [USER_MSG], "alice");
3211
+ const formMatch = html.match(/<form id="send-form"[^>]*>/);
3212
+ expect(formMatch).not.toBeNull();
3213
+ expect(formMatch?.[0]).not.toContain("style=");
3214
+ });
3215
+
3216
+ test("message input carries no inline style attribute", () => {
3217
+ const html = renderChatThreadPage("agent-xyz", THREAD, [USER_MSG], "alice");
3218
+ const match = html.match(/<textarea[^>]*id="message-input"[^>]*>/);
3219
+ expect(match).not.toBeNull();
3220
+ expect(match?.[0]).not.toContain("style=");
3221
+ });
3222
+
3223
+ test("attach and send buttons carry no inline style attribute", () => {
3224
+ const html = renderChatThreadPage("agent-xyz", THREAD, [USER_MSG], "alice");
3225
+ const attachMatch = html.match(/<button[^>]*id="attach-btn"[^>]*>/);
3226
+ const sendMatch = html.match(/<button[^>]*id="send-btn"[^>]*>/);
3227
+ expect(attachMatch).not.toBeNull();
3228
+ expect(sendMatch).not.toBeNull();
3229
+ expect(attachMatch?.[0]).not.toContain("style=");
3230
+ expect(sendMatch?.[0]).not.toContain("style=");
3231
+ });
3232
+
3233
+ test("chatThreadStyles is included in extraStyles and defines chat-bubble rules", () => {
3234
+ const html = renderChatThreadPage("agent-xyz", THREAD, [USER_MSG], "alice");
3235
+ expect(html).toContain(".chat-bubble--user");
3236
+ expect(html).toContain(".chat-bubble--assistant");
3237
+ expect(html).toContain("justify-content:flex-end");
3238
+ expect(html).toContain("justify-content:flex-start");
3239
+ });
3240
+
3241
+ test("inline JS optimistic user bubble uses the same chat-bubble class constants as the server renderer", () => {
3242
+ const html = renderChatThreadPage("agent-xyz", THREAD, [USER_MSG], "alice");
3243
+ // The class names baked into server-rendered HTML must also appear inside
3244
+ // the inline <script> block, proving both come from the same source.
3245
+ expect(html).toContain(
3246
+ "bubble.className = 'chat-bubble chat-bubble--user';",
3247
+ );
3248
+ expect(html).toContain('class="chat-bubble-inner"');
3249
+ expect(html).not.toContain("bubble.style.cssText");
3250
+ });
3251
+ });
3252
+
3253
+ // ─── VM-3.1 — mic-record button + SSR audio-player rendering ────────────────
3254
+
3255
+ describe("renderChatThreadPage — mic input + audio playback (VM-3.1)", () => {
3256
+ const THREAD: ChatThread = {
3257
+ id: "thread-vm31",
3258
+ agentId: "agent-vm31",
3259
+ title: "Voice Thread",
3260
+ memberId: null,
3261
+ createdAt: "2024-01-01T00:00:00.000Z",
3262
+ updatedAt: "2024-01-01T00:00:00.000Z",
3263
+ };
3264
+
3265
+ const USER_MSG: ChatMessage = {
3266
+ id: "msg-1",
3267
+ threadId: "thread-vm31",
3268
+ role: "user",
3269
+ body: "Hello, agent!",
3270
+ createdAt: "2024-01-01T00:00:00.000Z",
3271
+ claimedBy: null,
3272
+ repliedAt: null,
3273
+ tokens: null,
3274
+ costUsd: null,
3275
+ errorKind: null,
3276
+ attachmentFilename: null,
3277
+ attachmentSize: null,
3278
+ };
3279
+
3280
+ test("renders a mic-record button beside the hidden file input", () => {
3281
+ const html = renderChatThreadPage(
3282
+ "agent-vm31",
3283
+ THREAD,
3284
+ [USER_MSG],
3285
+ "alice",
3286
+ );
3287
+ expect(html).toContain('id="mic-btn"');
3288
+ const fileInputIdx = html.indexOf('id="file-input"');
3289
+ const micBtnIdx = html.indexOf('id="mic-btn"');
3290
+ const sendFormEndIdx = html.indexOf('id="send-form"');
3291
+ expect(fileInputIdx).toBeGreaterThan(-1);
3292
+ expect(micBtnIdx).toBeGreaterThan(fileInputIdx);
3293
+ expect(sendFormEndIdx).toBeGreaterThan(-1);
3294
+ });
3295
+
3296
+ test("mic handler offers audio/mp4, uses .m4a, and normalizes non-audio types (VMF-1.1)", () => {
3297
+ const html = renderChatThreadPage(
3298
+ "agent-vm31",
3299
+ THREAD,
3300
+ [USER_MSG],
3301
+ "alice",
3302
+ );
3303
+ expect(html).toContain("'audio/mp4'");
3304
+ expect(html).toContain("'m4a'");
3305
+ expect(html).toContain("rawType.replace(");
3306
+ });
3307
+
3308
+ test("inline script references MediaRecorder / getUserMedia for the mic flow", () => {
3309
+ const html = renderChatThreadPage(
3310
+ "agent-vm31",
3311
+ THREAD,
3312
+ [USER_MSG],
3313
+ "alice",
3314
+ );
3315
+ expect(html).toContain("MediaRecorder");
3316
+ expect(html).toContain("getUserMedia");
3317
+ });
3318
+
3319
+ test("SSR-rendered bubble for a user message with an audio attachment uses the attachment-proxy URL for this agent/thread", () => {
3320
+ const html = renderChatThreadPage(
3321
+ "agent-vm31",
3322
+ THREAD,
3323
+ [{ ...USER_MSG, attachmentFilename: "recording-123.webm" }],
3324
+ "alice",
3325
+ );
3326
+ expect(html).toContain("<audio");
3327
+ expect(html).toContain(
3328
+ "/admin/chat/agent-vm31/threads/thread-vm31/messages/msg-1/attachment",
3329
+ );
3330
+ });
3331
+
3332
+ test("SSR-rendered bubble for a non-audio attachment still uses the filename badge", () => {
3333
+ const html = renderChatThreadPage(
3334
+ "agent-vm31",
3335
+ THREAD,
3336
+ [{ ...USER_MSG, attachmentFilename: "report.pdf" }],
3337
+ "alice",
3338
+ );
3339
+ // The page's own inline <script> legitimately contains the literal
3340
+ // "<audio" (the mic-recording preview markup) regardless of this
3341
+ // message's attachment — so assert on the rendered message bubble
3342
+ // specifically, not page-wide absence.
3343
+ const bubbleMatch = html.match(
3344
+ /<div class="chat-bubble[^>]*data-message-id="msg-1"[\s\S]*?<\/div>\s*<\/div>/,
3345
+ );
3346
+ expect(bubbleMatch).not.toBeNull();
3347
+ expect(bubbleMatch?.[0]).toContain("📎 report.pdf");
3348
+ expect(bubbleMatch?.[0]).not.toContain("<audio");
3349
+ });
3350
+ });
3351
+
3352
+ // ─── CFB-2.3 — live progress, elapsed timer, stall state ─────────────────────
3353
+
3354
+ describe("renderChatMessageBubble (hoisted module-level renderer)", () => {
3355
+ const BASE_MSG: ChatMessage = {
3356
+ id: "msg-cfb23-1",
3357
+ threadId: "thread-abc",
3358
+ role: "assistant",
3359
+ body: "Here is **bold** and `code`.",
3360
+ createdAt: "2024-01-01T00:00:00.000Z",
3361
+ claimedBy: null,
3362
+ repliedAt: "2024-01-01T00:00:05.000Z",
3363
+ tokens: null,
3364
+ costUsd: null,
3365
+ errorKind: null,
3366
+ attachmentFilename: null,
3367
+ attachmentSize: null,
3368
+ };
3369
+
3370
+ test("renders a data-message-id attribute for id-based dedupe", () => {
3371
+ const html = renderChatMessageBubble(BASE_MSG);
3372
+ expect(html).toContain('data-message-id="msg-cfb23-1"');
3373
+ });
3374
+
3375
+ test("assistant markdown is rendered (bold → <strong>, code → <code>)", () => {
3376
+ const html = renderChatMessageBubble(BASE_MSG);
3377
+ expect(html).toContain("<strong>bold</strong>");
3378
+ expect(html).toContain("<code>code</code>");
3379
+ });
3380
+
3381
+ test("user body is escaped, not markdown-rendered", () => {
3382
+ const html = renderChatMessageBubble({
3383
+ ...BASE_MSG,
3384
+ role: "user",
3385
+ body: "<script>alert(1)</script> **not bold**",
3386
+ });
3387
+ expect(html).not.toContain("<script>alert(1)</script>");
3388
+ expect(html).toContain("&lt;script&gt;");
3389
+ // user bodies are plain-text/pre-wrap — no markdown conversion
3390
+ expect(html).not.toContain("<strong>not bold</strong>");
3391
+ });
3392
+
3393
+ test("errorKind uses the shared ERROR_KIND_LABELS mapping", () => {
3394
+ for (const [kind, label] of Object.entries(ERROR_KIND_LABELS)) {
3395
+ const html = renderChatMessageBubble({ ...BASE_MSG, errorKind: kind });
3396
+ expect(html).toContain(label);
3397
+ }
3398
+ });
3399
+
3400
+ test("unknown errorKind falls back to the default label", () => {
3401
+ const html = renderChatMessageBubble({
3402
+ ...BASE_MSG,
3403
+ errorKind: "something-weird",
3404
+ });
3405
+ expect(html).toContain("Error");
3406
+ });
3407
+ });
3408
+
3409
+ // ─── VM-3.1 — mic input + audio playback: audio-vs-badge rendering branch ────
3410
+ // renderChatMessageBubble is the single hoisted renderer shared by BOTH the
3411
+ // SSR thread-detail page (renderChatThreadPage) and the polled bubbleHtml
3412
+ // path (messages.json, in admin-ui.ts) — see the module comment above this
3413
+ // function. Testing it directly therefore covers both render paths at once;
3414
+ // renderChatThreadPage is also exercised below to confirm the SSR call site
3415
+ // actually threads agentId through.
3416
+
3417
+ describe("renderChatMessageBubble — audio attachment vs. filename badge (VM-3.1)", () => {
3418
+ const BASE_MSG: ChatMessage = {
3419
+ id: "msg-vm31-1",
3420
+ threadId: "thread-vm31",
3421
+ role: "user",
3422
+ body: "voice note",
3423
+ createdAt: "2024-01-01T00:00:00.000Z",
3424
+ claimedBy: null,
3425
+ repliedAt: null,
3426
+ tokens: null,
3427
+ costUsd: null,
3428
+ errorKind: null,
3429
+ attachmentFilename: null,
3430
+ attachmentSize: null,
3431
+ };
3432
+
3433
+ test("a .webm attachment (mic recording) renders an inline <audio> player, not a badge", () => {
3434
+ const html = renderChatMessageBubble(
3435
+ { ...BASE_MSG, attachmentFilename: "recording-123.webm" },
3436
+ null,
3437
+ "agent-1",
3438
+ );
3439
+ expect(html).toContain("<audio");
3440
+ expect(html).toContain("controls");
3441
+ expect(html).not.toContain("📎");
3442
+ });
3443
+
3444
+ test("a .ogg attachment renders an inline <audio> player", () => {
3445
+ const html = renderChatMessageBubble(
3446
+ { ...BASE_MSG, attachmentFilename: "recording-123.ogg" },
3447
+ null,
3448
+ "agent-1",
3449
+ );
3450
+ expect(html).toContain("<audio");
3451
+ });
3452
+
3453
+ test("a .wav assistant-reply attachment (VM-2.1 TTS) renders an inline <audio> player", () => {
3454
+ const html = renderChatMessageBubble(
3455
+ {
3456
+ ...BASE_MSG,
3457
+ role: "assistant",
3458
+ attachmentFilename: "1700000000000-response.wav",
3459
+ },
3460
+ null,
3461
+ "agent-1",
3462
+ );
3463
+ expect(html).toContain("<audio");
3464
+ expect(html).not.toContain("📎");
3465
+ });
3466
+
3467
+ test("the <audio> src points at the per-message attachment proxy route, using agentId + threadId + id", () => {
3468
+ const html = renderChatMessageBubble(
3469
+ { ...BASE_MSG, attachmentFilename: "recording-123.webm" },
3470
+ null,
3471
+ "agent-1",
3472
+ );
3473
+ expect(html).toContain(
3474
+ "/admin/chat/agent-1/threads/thread-vm31/messages/msg-vm31-1/attachment",
3475
+ );
3476
+ });
3477
+
3478
+ test("a non-audio attachment (e.g. a PDF) still renders as the plain filename badge", () => {
3479
+ const html = renderChatMessageBubble(
3480
+ { ...BASE_MSG, attachmentFilename: "report.pdf" },
3481
+ null,
3482
+ "agent-1",
3483
+ );
3484
+ expect(html).toContain("📎 report.pdf");
3485
+ expect(html).not.toContain("<audio");
3486
+ });
3487
+
3488
+ test("no attachment renders neither an audio player nor a badge", () => {
3489
+ const html = renderChatMessageBubble(BASE_MSG, null, "agent-1");
3490
+ expect(html).not.toContain("<audio");
3491
+ expect(html).not.toContain("📎");
3492
+ });
3493
+
3494
+ test("works without an agentId argument (back-compat with existing call sites/tests)", () => {
3495
+ expect(() => renderChatMessageBubble(BASE_MSG)).not.toThrow();
3496
+ });
3497
+ });
3498
+
3499
+ describe("renderChatThreadPage — CFB-3.2 mobile drawer", () => {
3500
+ const THREAD: ChatThread = {
3501
+ id: "thread-abc",
3502
+ agentId: "agent-xyz",
3503
+ title: "Drawer Thread",
3504
+ memberId: null,
3505
+ createdAt: "2024-01-01T00:00:00.000Z",
3506
+ updatedAt: "2024-01-01T00:00:00.000Z",
3507
+ };
3508
+
3509
+ const USER_MSG: ChatMessage = {
3510
+ id: "msg-1",
3511
+ threadId: "thread-abc",
3512
+ role: "user",
3513
+ body: "Hello",
3514
+ createdAt: "2024-01-01T00:00:00.000Z",
3515
+ claimedBy: null,
3516
+ repliedAt: null,
3517
+ tokens: null,
3518
+ costUsd: null,
3519
+ errorKind: null,
3520
+ attachmentFilename: null,
3521
+ attachmentSize: null,
3522
+ };
3523
+
3524
+ const THREADS_LIST: ChatThread[] = [
3525
+ {
3526
+ id: "thread-other",
3527
+ agentId: "agent-xyz",
3528
+ title: "Other Thread",
3529
+ memberId: null,
3530
+ createdAt: "2024-01-01T00:00:00.000Z",
3531
+ updatedAt: "2024-01-01T00:00:00.000Z",
3532
+ },
3533
+ ];
3534
+
3535
+ // Criterion #2: DOM order is load-bearing — the ~ sibling combinator that
3536
+ // reveals the drawer off its :checked state requires the checkbox to appear
3537
+ // BEFORE the sidebar in source order.
3538
+ test("drawer checkbox appears before the sidebar in DOM order", () => {
3539
+ const html = renderChatThreadPage(
3540
+ "agent-xyz",
3541
+ THREAD,
3542
+ [USER_MSG],
3543
+ THREADS_LIST,
3544
+ "alice",
3545
+ );
3546
+ const checkboxIndex = html.indexOf('id="chat-drawer-toggle"');
3547
+ // Match the sidebar's rendered markup, not the class name in the <style>
3548
+ // block (which always appears earlier). The sidebar div carries both the
3549
+ // `card` and `chat-thread-sidebar` classes in the body.
3550
+ const sidebarIndex = html.indexOf('class="card chat-thread-sidebar"');
3551
+ expect(checkboxIndex).toBeGreaterThanOrEqual(0);
3552
+ expect(sidebarIndex).toBeGreaterThanOrEqual(0);
3553
+ expect(checkboxIndex).toBeLessThan(sidebarIndex);
3554
+ });
3555
+
3556
+ test("renders a scrim label bound to the drawer toggle", () => {
3557
+ const html = renderChatThreadPage(
3558
+ "agent-xyz",
3559
+ THREAD,
3560
+ [USER_MSG],
3561
+ THREADS_LIST,
3562
+ "alice",
3563
+ );
3564
+ // The scrim is a <label> in the body bound to the toggle; assert on the
3565
+ // rendered element, not the class name that also appears in the <style>.
3566
+ expect(html).toContain('class="chat-drawer-scrim"');
3567
+ expect(html).toContain('for="chat-drawer-toggle"');
3568
+ });
3569
+
3570
+ test("mobile media block no longer hides the sidebar with display:none", () => {
3571
+ const html = renderChatThreadPage(
3572
+ "agent-xyz",
3573
+ THREAD,
3574
+ [USER_MSG],
3575
+ THREADS_LIST,
3576
+ "alice",
3577
+ );
3578
+ expect(html).not.toContain(".chat-thread-sidebar { display:none }");
3579
+ });
3580
+
3581
+ test("emits a standalone keyboard-inset script separate from the progress IIFE", () => {
3582
+ const html = renderChatThreadPage(
3583
+ "agent-xyz",
3584
+ THREAD,
3585
+ [USER_MSG],
3586
+ THREADS_LIST,
3587
+ "alice",
3588
+ );
3589
+ // The visualViewport listener sets a --kb-inset custom property and must
3590
+ // guard on visualViewport absence exactly as the brief specifies.
3591
+ expect(html).toContain("--kb-inset");
3592
+ expect(html).toContain("window.visualViewport");
3593
+ });
3594
+
3595
+ test("composer applies sticky positioning and safe-area padding", () => {
3596
+ const html = renderChatThreadPage(
3597
+ "agent-xyz",
3598
+ THREAD,
3599
+ [USER_MSG],
3600
+ THREADS_LIST,
3601
+ "alice",
3602
+ );
3603
+ expect(html).toContain("position:sticky");
3604
+ expect(html).toContain("env(safe-area-inset-bottom)");
3605
+ });
3606
+
3607
+ test("bubble inner wraps long words and code fences scroll horizontally", () => {
3608
+ const html = renderChatThreadPage(
3609
+ "agent-xyz",
3610
+ THREAD,
3611
+ [USER_MSG],
3612
+ THREADS_LIST,
3613
+ "alice",
3614
+ );
3615
+ expect(html).toContain("overflow-wrap:anywhere");
3616
+ expect(html).toContain("overflow-x:auto");
3617
+ });
3618
+
3619
+ // Degraded/no-threads mode: sidebar ternary yields "" — the drawer chrome
3620
+ // (checkbox/scrim/hamburger) must not render orphaned without a sidebar.
3621
+ test("omits drawer chrome when there is no thread list", () => {
3622
+ const html = renderChatThreadPage("agent-xyz", THREAD, [USER_MSG], "alice");
3623
+ // The drawer input/scrim/hamburger elements must not render without a
3624
+ // sidebar (the class names still appear in the always-emitted <style>).
3625
+ expect(html).not.toContain('id="chat-drawer-toggle"');
3626
+ expect(html).not.toContain('class="chat-drawer-scrim"');
3627
+ expect(html).not.toContain('class="chat-drawer-hamburger"');
3628
+ });
3629
+ });
3630
+
3631
+ describe("renderChatThreadPage — CFB-2.3 live progress inline JS + status bubble", () => {
3632
+ const THREAD: ChatThread = {
3633
+ id: "thread-abc",
3634
+ agentId: "agent-xyz",
3635
+ title: "Live Progress Thread",
3636
+ memberId: null,
3637
+ createdAt: "2024-01-01T00:00:00.000Z",
3638
+ updatedAt: "2024-01-01T00:00:00.000Z",
3639
+ };
3640
+
3641
+ const PENDING_USER_MSG: ChatMessage = {
3642
+ id: "msg-pending-1",
3643
+ threadId: "thread-abc",
3644
+ role: "user",
3645
+ body: "do a thing",
3646
+ createdAt: "2024-01-01T00:00:00.000Z",
3647
+ claimedBy: "agent-xyz",
3648
+ claimedAt: "2024-01-01T00:00:01.000Z",
3649
+ heartbeatAt: "2024-01-01T00:00:02.000Z",
3650
+ repliedAt: null,
3651
+ tokens: null,
3652
+ costUsd: null,
3653
+ errorKind: null,
3654
+ attachmentFilename: null,
3655
+ attachmentSize: null,
3656
+ progressPhase: "reading",
3657
+ progressSeq: 3,
3658
+ cancelRequestedAt: null,
3659
+ };
3660
+
3661
+ const REPLIED_MSG: ChatMessage = {
3662
+ ...PENDING_USER_MSG,
3663
+ id: "msg-replied-1",
3664
+ repliedAt: "2024-01-01T00:00:10.000Z",
3665
+ };
3666
+
3667
+ test("simpleMarkdown is deleted from the inline JS", () => {
3668
+ const html = renderChatThreadPage("agent-xyz", THREAD, [REPLIED_MSG], "u");
3669
+ expect(html).not.toContain("simpleMarkdown");
3670
+ });
3671
+
3672
+ test("inline JS contains a 1s ticker (setInterval(..., 1000)) for the elapsed timer", () => {
3673
+ const html = renderChatThreadPage("agent-xyz", THREAD, [REPLIED_MSG], "u");
3674
+ expect(html).toContain("setInterval(tick, 1000)");
3675
+ });
3676
+
3677
+ test("inline JS uses an id-based renderedIds dedupe set", () => {
3678
+ const html = renderChatThreadPage("agent-xyz", THREAD, [REPLIED_MSG], "u");
3679
+ expect(html).toContain("renderedIds");
3680
+ expect(html).toContain("data-message-id");
3681
+ });
3682
+
3683
+ test("inline JS seeds renderedIds from the upload response", () => {
3684
+ const html = renderChatThreadPage("agent-xyz", THREAD, [REPLIED_MSG], "u");
3685
+ // The success branch of the fetch handler should parse JSON and seed renderedIds
3686
+ // from data.message.id to prevent duplicate rendering.
3687
+ expect(html).toContain("renderedIds[data.message.id]");
3688
+ });
3689
+
3690
+ test("inline JS renders every new server message, not just the last", () => {
3691
+ const html = renderChatThreadPage("agent-xyz", THREAD, [REPLIED_MSG], "u");
3692
+ // renderServerBubble is called in a loop over all msgs (the old code took
3693
+ // replies[replies.length - 1]).
3694
+ expect(html).toContain("renderServerBubble");
3695
+ expect(html).not.toContain("replies[replies.length - 1]");
3696
+ });
3697
+
3698
+ test("inline JS serializes ERROR_KIND_LABELS from the shared source", () => {
3699
+ const html = renderChatThreadPage("agent-xyz", THREAD, [REPLIED_MSG], "u");
3700
+ expect(html).toContain("var ERROR_KIND_LABELS =");
3701
+ expect(html).toContain(JSON.stringify(ERROR_KIND_LABELS));
3702
+ });
3703
+
3704
+ test("STALL_WARN_AFTER_MS defaults to 120000 and ABSOLUTE_MAX_MS to 3900000", () => {
3705
+ const html = renderChatThreadPage("agent-xyz", THREAD, [REPLIED_MSG], "u");
3706
+ expect(html).toContain("var STALL_WARN_AFTER_MS = 120000");
3707
+ expect(html).toContain("var ABSOLUTE_MAX_MS = 3900000");
3708
+ });
3709
+
3710
+ test("stallWarnAfterMs override is threaded into the inline JS", () => {
3711
+ const html = renderChatThreadPage(
3712
+ "agent-xyz",
3713
+ THREAD,
3714
+ [REPLIED_MSG],
3715
+ null,
3716
+ "u",
3717
+ null,
3718
+ { stallWarnAfterMs: 500 },
3719
+ );
3720
+ expect(html).toContain("var STALL_WARN_AFTER_MS = 500");
3721
+ });
3722
+
3723
+ test("server-renders the live status bubble when the last user message is unreplied", () => {
3724
+ const html = renderChatThreadPage(
3725
+ "agent-xyz",
3726
+ THREAD,
3727
+ [PENDING_USER_MSG],
3728
+ "u",
3729
+ );
3730
+ expect(html).toContain('id="live-status-bubble"');
3731
+ // milestone from the progressPhase label + elapsed seed
3732
+ expect(html).toContain("Reading files");
3733
+ expect(html).toContain('id="live-status-elapsed"');
3734
+ // data-created-at drives the zero-network ticker
3735
+ expect(html).toContain("data-created-at=");
3736
+ expect(html).toContain("data-progress-seq=");
3737
+ });
3738
+
3739
+ test("no live status bubble when the last message is already replied", () => {
3740
+ const html = renderChatThreadPage("agent-xyz", THREAD, [REPLIED_MSG], "u");
3741
+ expect(html).not.toContain('id="live-status-bubble"');
3742
+ });
3743
+
3744
+ test("pending message with null progressPhase renders elapsed but no milestone text", () => {
3745
+ const html = renderChatThreadPage(
3746
+ "agent-xyz",
3747
+ THREAD,
3748
+ [{ ...PENDING_USER_MSG, progressPhase: null }],
3749
+ "u",
3750
+ );
3751
+ expect(html).toContain('id="live-status-bubble"');
3752
+ expect(html).toContain('id="live-status-elapsed"');
3753
+ // milestone span present but empty
3754
+ expect(html).toContain('id="live-status-milestone"></span>');
3755
+ });
3756
+
3757
+ test("stall CSS honors prefers-reduced-motion", () => {
3758
+ const html = renderChatThreadPage(
3759
+ "agent-xyz",
3760
+ THREAD,
3761
+ [PENDING_USER_MSG],
3762
+ "u",
3763
+ );
3764
+ expect(html).toContain("prefers-reduced-motion: reduce");
3765
+ expect(html).toContain("chat-stall-indicator");
3766
+ });
3767
+ });
3768
+
3769
+ // ─── Drawer/scrim z-index vs sticky toolbar (DNZ-1.1) ─────────────────────────
3770
+ //
3771
+ // Drawers and their scrims must render above the sticky toolbar (z-index: 100)
3772
+ // so they're never obscured by the sticky top nav. Each drawer's scrim should
3773
+ // render below the drawer itself (z-index: scrim < drawer), but both must be
3774
+ // above the toolbar (z-index > 100). Toolbar z-index is 100 per lib/web/toolbar.ts.
3775
+
3776
+ describe("drawer z-index above sticky toolbar (DNZ-1.1)", () => {
3777
+ const TOOLBAR_ZINDEX = 100;
3778
+
3779
+ const THREAD: ChatThread = {
3780
+ id: "thread-dnz-1",
3781
+ agentId: "agent-xyz",
3782
+ title: "DNZ-1 Thread",
3783
+ memberId: null,
3784
+ createdAt: "2026-06-01T09:00:00Z",
3785
+ updatedAt: "2026-06-01T09:00:00Z",
3786
+ };
3787
+
3788
+ const MSG: ChatMessage = {
3789
+ id: "msg-dnz-1",
3790
+ threadId: "thread-dnz-1",
3791
+ role: "user",
3792
+ body: "test",
3793
+ createdAt: "2026-06-01T09:00:00Z",
3794
+ claimedBy: null,
3795
+ repliedAt: null,
3796
+ tokens: null,
3797
+ costUsd: null,
3798
+ errorKind: null,
3799
+ attachmentFilename: null,
3800
+ attachmentSize: null,
3801
+ };
3802
+
3803
+ test("chat thread sidebar and scrim z-indexes are above toolbar (> 100) with scrim < sidebar", () => {
3804
+ const html = renderChatThreadPage("agent-xyz", THREAD, [MSG], USER_NAME);
3805
+
3806
+ const sidebarMatch = html.match(
3807
+ /\.chat-thread-sidebar\s*\{[^}]*z-index:(\d+)/,
3808
+ );
3809
+ const scrimMatch = html.match(/\.chat-drawer-scrim\s*\{[^}]*z-index:(\d+)/);
3810
+
3811
+ expect(sidebarMatch).not.toBeNull();
3812
+ expect(scrimMatch).not.toBeNull();
3813
+
3814
+ const sidebarZIndex = sidebarMatch
3815
+ ? Number.parseInt(sidebarMatch[1], 10)
3816
+ : 0;
3817
+ const scrimZIndex = scrimMatch ? Number.parseInt(scrimMatch[1], 10) : 0;
3818
+
3819
+ expect(sidebarZIndex).toBeGreaterThan(TOOLBAR_ZINDEX);
3820
+ expect(scrimZIndex).toBeGreaterThan(TOOLBAR_ZINDEX);
3821
+ expect(scrimZIndex).toBeLessThan(sidebarZIndex);
3822
+ });
3823
+ });
3824
+
3825
+ // ─── All page renderers — shared head via renderAdminPage (CFB-1.2) ─────────
3826
+ //
3827
+ // Every render*Page-style function in this file builds its <!DOCTYPE html>
3828
+ // head via the shared admin-ui-layout.ts#renderAdminPage() helper. This loop
3829
+ // calls each of the platform render sites (renderChatThreadPage has two
3830
+ // distinct DOCTYPE blocks: its degraded early-return and its main return;
3831
+ // the coding pages' sites are covered in coding/admin-ui-work-item-pages.unit.test.ts)
3832
+ // with minimal valid fixtures and asserts each output has exactly one
3833
+ // DOCTYPE and exactly one viewport meta tag, guarding against any call site
3834
+ // drifting back to a hand-rolled head or double-wrapping.
3835
+
3836
+ describe("all page renderers — single DOCTYPE and viewport meta (CFB-1.2)", () => {
3837
+ const MINIMAL_THREAD: ChatThread = {
3838
+ id: "thread-loop-1",
3839
+ agentId: "agent-123",
3840
+ title: "Loop thread",
3841
+ memberId: null,
3842
+ createdAt: "2026-06-01T09:00:00Z",
3843
+ updatedAt: "2026-06-01T09:00:00Z",
3844
+ };
3845
+
3846
+ const MINIMAL_MSG: ChatMessage = {
3847
+ id: "msg-loop-1",
3848
+ threadId: "thread-loop-1",
3849
+ role: "user",
3850
+ body: "hello",
3851
+ createdAt: "2026-06-01T09:00:00Z",
3852
+ claimedBy: null,
3853
+ repliedAt: null,
3854
+ tokens: null,
3855
+ costUsd: null,
3856
+ attachmentFilename: null,
3857
+ attachmentSize: null,
3858
+ };
3859
+
3860
+ const renderers: Array<[string, () => string]> = [
3861
+ ["renderLoginPage", () => renderLoginPage()],
3862
+ ["renderAgentsPage", () => renderAgentsPage([], USER_NAME, true, "UTC")],
3863
+ [
3864
+ "renderNewLocalAgentPage",
3865
+ () =>
3866
+ renderNewLocalAgentPage(USER_NAME, [
3867
+ { name: "coding", displayName: "Coding Agent" },
3868
+ ]),
3869
+ ],
3870
+ [
3871
+ "renderAgentDetailPage",
3872
+ () =>
3873
+ renderAgentDetailPage(AGENT, {}, [], [], [], [], [], USER_NAME, true, {
3874
+ timezone: "UTC",
3875
+ }),
3876
+ ],
3877
+ [
3878
+ "renderGithubAppManifestRedirectPage",
3879
+ () =>
3880
+ renderGithubAppManifestRedirectPage(USER_NAME, {
3881
+ githubOrg: "acme",
3882
+ manifest: { name: "shipwright" },
3883
+ }),
3884
+ ],
3885
+ [
3886
+ "renderGithubAppInstallPage",
3887
+ () =>
3888
+ renderGithubAppInstallPage(USER_NAME, {
3889
+ installUrl: "https://github.com/apps/shipwright/installations/new",
3890
+ }),
3891
+ ],
3892
+ [
3893
+ "renderGithubAppInstalledPage",
3894
+ () => renderGithubAppInstalledPage(USER_NAME, { success: true }),
3895
+ ],
3896
+ [
3897
+ "renderProvisionXappTokenPage",
3898
+ () => renderProvisionXappTokenPage(USER_NAME, { agentId: "agent-123" }),
3899
+ ],
3900
+ [
3901
+ "renderProvisionCompletePage",
3902
+ () => renderProvisionCompletePage(USER_NAME, { success: true }),
3903
+ ],
3904
+ ["renderChatPage", () => renderChatPage([], undefined, null, USER_NAME)],
3905
+ [
3906
+ "renderChatThreadPage (degraded — thread/messages null)",
3907
+ () => renderChatThreadPage("agent-123", null, null, USER_NAME),
3908
+ ],
3909
+ [
3910
+ "renderChatThreadPage (main)",
3911
+ () =>
3912
+ renderChatThreadPage(
3913
+ "agent-123",
3914
+ MINIMAL_THREAD,
3915
+ [MINIMAL_MSG],
3916
+ USER_NAME,
3917
+ ),
3918
+ ],
3919
+ ];
3920
+
3921
+ test("all 12 render sites are covered by this loop", () => {
3922
+ expect(renderers.length).toBe(12);
3923
+ });
3924
+
3925
+ for (const [name, render] of renderers) {
3926
+ test(`${name}: exactly one DOCTYPE and one viewport meta tag`, () => {
3927
+ const html = render();
3928
+ const doctypeMatches = html.match(/<!DOCTYPE html>/g) ?? [];
3929
+ const viewportMatches = html.match(/<meta name="viewport"/g) ?? [];
3930
+ expect(doctypeMatches.length).toBe(1);
3931
+ expect(viewportMatches.length).toBe(1);
3932
+ expect(html).toContain(
3933
+ 'content="width=device-width, initial-scale=1, viewport-fit=cover"',
3934
+ );
3935
+ });
3936
+ }
3937
+ });
3938
+
3939
+ describe("renderAddAnotherOrgAction", () => {
3940
+ test("links to the add-org route when GH_APP_SLUG is set", () => {
3941
+ const out = renderAddAnotherOrgAction("a1", {
3942
+ GH_APP_ID: "1",
3943
+ GH_APP_SLUG: "my-app",
3944
+ });
3945
+ expect(out).toContain("/admin/agents/a1/connect-github/add-org");
3946
+ expect(out).toContain("Add another org");
3947
+ });
3948
+
3949
+ test("points at the docs when GH_APP_SLUG is absent", () => {
3950
+ const out = renderAddAnotherOrgAction("a1", { GH_APP_ID: "1" });
3951
+ expect(out).not.toContain("connect-github/add-org");
3952
+ expect(out).toContain("configuration-agent.md");
3953
+ });
3954
+
3955
+ test("renders nothing without a GitHub App", () => {
3956
+ expect(renderAddAnotherOrgAction("a1", {})).toBe("");
3957
+ });
3958
+ });
3959
+
3960
+ describe("renderChatThreadPage — poll auto-scroll (CSR-1.1)", () => {
3961
+ const THREAD: ChatThread = {
3962
+ id: "thread-abc",
3963
+ agentId: "agent-xyz",
3964
+ title: "T",
3965
+ memberId: null,
3966
+ createdAt: "2024-01-01T00:00:00.000Z",
3967
+ updatedAt: "2024-01-01T00:00:00.000Z",
3968
+ };
3969
+
3970
+ // Runs the page's inline script against an injected stub DOM/fetch (no
3971
+ // global overrides) and returns the messages container plus the first poll.
3972
+ function boot(scrollTop: number) {
3973
+ const html = renderChatThreadPage("agent-xyz", THREAD, [], "alice");
3974
+ const script = html.match(
3975
+ /<script>\n\(function\(\) \{[\s\S]*?\n<\/script>/,
3976
+ );
3977
+ if (!script) throw new Error("inline chat script not found");
3978
+ const code = script[0].replace(/^<script>/, "").replace(/<\/script>$/, "");
3979
+
3980
+ const makeEl = (): Record<string, unknown> => ({
3981
+ style: {},
3982
+ classList: { add() {}, remove() {}, toggle() {} },
3983
+ addEventListener() {},
3984
+ setAttribute() {},
3985
+ getAttribute: () => null,
3986
+ appendChild() {},
3987
+ insertBefore() {},
3988
+ querySelectorAll: () => [],
3989
+ querySelector: () => null,
3990
+ });
3991
+ const container = {
3992
+ ...makeEl(),
3993
+ scrollTop,
3994
+ scrollHeight: 1000,
3995
+ clientHeight: 400,
3996
+ };
3997
+ const document = {
3998
+ getElementById: (id: string) =>
3999
+ id === "messages-container"
4000
+ ? container
4001
+ : id === "live-status-bubble"
4002
+ ? null
4003
+ : makeEl(),
4004
+ createElement: makeEl,
4005
+ querySelectorAll: () => [],
4006
+ };
4007
+ const fetchStub = () =>
4008
+ Promise.resolve({ json: () => Promise.resolve({ messages: [] }) });
4009
+ const timers: Array<() => void> = [];
4010
+ new Function(
4011
+ "document",
4012
+ "fetch",
4013
+ "setTimeout",
4014
+ "clearTimeout",
4015
+ "setInterval",
4016
+ "clearInterval",
4017
+ "window",
4018
+ code,
4019
+ )(
4020
+ document,
4021
+ fetchStub,
4022
+ (fn: () => void) => {
4023
+ timers.push(fn);
4024
+ return timers.length;
4025
+ },
4026
+ () => {},
4027
+ () => 0,
4028
+ () => {},
4029
+ { addEventListener() {} },
4030
+ );
4031
+ return { container, timers };
4032
+ }
4033
+
4034
+ test("user scrolled up -> poll leaves scrollTop unchanged", async () => {
4035
+ const { container, timers } = boot(100);
4036
+ container.scrollTop = 100; // user scrolled up (900 - 100 gap > 80px)
4037
+ timers[timers.length - 1]();
4038
+ await new Promise((r) => setTimeout(r, 0));
4039
+ expect(container.scrollTop).toBe(100);
4040
+ });
4041
+
4042
+ test("near bottom -> poll scrolls to bottom", async () => {
4043
+ const { container, timers } = boot(0);
4044
+ container.scrollTop = 560; // 1000 - 560 - 400 = 40px from bottom
4045
+ timers[timers.length - 1]();
4046
+ await new Promise((r) => setTimeout(r, 0));
4047
+ expect(container.scrollTop).toBe(1000);
4048
+ });
4049
+
4050
+ test("initial page load still scrolls to the bottom unconditionally", () => {
4051
+ const { container } = boot(0);
4052
+ expect(container.scrollTop).toBe(1000);
4053
+ });
4054
+ });
4055
+
4056
+ describe("renderAgentDetailPage — canManage gating (SSP-4.3)", () => {
4057
+ const render = (
4058
+ isAdmin: boolean,
4059
+ opts?: Parameters<typeof renderAgentDetailPage>[9],
4060
+ ) =>
4061
+ renderAgentDetailPage(
4062
+ AGENT,
4063
+ { env: {}, secretKeys: [] },
4064
+ [],
4065
+ [],
4066
+ [],
4067
+ [],
4068
+ [],
4069
+ "user@example.com",
4070
+ isAdmin,
4071
+ opts,
4072
+ );
4073
+
4074
+ test("non-admin without canManage: no Danger Zone / Slack access / Members cards", () => {
4075
+ const html = render(false);
4076
+ expect(html).not.toContain("Danger Zone");
4077
+ expect(html).not.toContain('<div class="card-title">Slack access</div>');
4078
+ expect(html).not.toContain("/members");
4079
+ });
4080
+
4081
+ test("non-admin with canManage: all three cards render", () => {
4082
+ const html = render(false, { canManage: true });
4083
+ expect(html).toContain("Danger Zone");
4084
+ expect(html).toContain('<div class="card-title">Slack access</div>');
4085
+ expect(html).toContain(`/admin/agents/${AGENT.id}/members`);
4086
+ });
4087
+
4088
+ test("admin defaults to canManage", () => {
4089
+ expect(render(true)).toContain("Danger Zone");
4090
+ });
4091
+ });
4092
+
4093
+ describe("renderNewLocalAgentPage — self-serve account user (SSP-4.2)", () => {
4094
+ const CODING_TYPE = { name: "coding", displayName: "Coding Agent" };
4095
+ const selfServe = { remaining: 2, max: 3, contactEmail: "help@example.com" };
4096
+
4097
+ test("shows remaining quota and the credential prerequisites", () => {
4098
+ const html = renderNewLocalAgentPage(USER_NAME, [CODING_TYPE], {
4099
+ selfServe,
4100
+ });
4101
+ expect(html).toContain("2 of 3");
4102
+ expect(html).toContain("Slack App Configuration Token");
4103
+ expect(html).toContain("GitHub");
4104
+ expect(html).toContain("Claude API key");
4105
+ });
4106
+
4107
+ test("forces the coding type via a hidden input instead of a type select", () => {
4108
+ const html = renderNewLocalAgentPage(USER_NAME, [CODING_TYPE], {
4109
+ selfServe,
4110
+ });
4111
+ expect(html).toContain('<input type="hidden" name="type" value="coding"');
4112
+ expect(html).not.toMatch(/<select[^>]*name="type"/);
4113
+ });
4114
+
4115
+ test("at zero remaining, explains the limit and points at the contact email", () => {
4116
+ const html = renderNewLocalAgentPage(USER_NAME, [CODING_TYPE], {
4117
+ selfServe: { ...selfServe, remaining: 0 },
4118
+ });
4119
+ expect(html).toContain("agent limit");
4120
+ expect(html).toContain("help@example.com");
4121
+ });
4122
+
4123
+ test("admin form (no selfServe opt) is unchanged", () => {
4124
+ const html = renderNewLocalAgentPage(USER_NAME, [CODING_TYPE]);
4125
+ expect(html).not.toContain("Agent quota");
4126
+ expect(html).toMatch(/<select[^>]*name="type"/);
4127
+ });
4128
+ });
4129
+
4130
+ describe("renderAgentsPage zero-quota notice (SSP-3.2)", () => {
4131
+ test("replaces the create CTA with the notice and a disabled button", () => {
4132
+ const html = renderAgentsPage([], "u@x.com", true, "UTC", {
4133
+ zeroQuotaNotice: renderZeroQuotaNotice("dan@app-vitals.com"),
4134
+ });
4135
+ expect(html).toContain("Email dan@app-vitals.com to request a trial");
4136
+ expect(html).toContain("disabled");
4137
+ expect(html).not.toContain(
4138
+ '<a href="/admin/agents/new" class="btn btn-primary">',
4139
+ );
4140
+ });
4141
+ });
4142
+
4143
+ describe("renderNewLocalAgentPage — Anthropic federation (WIF-2.1)", () => {
4144
+ const types = [{ name: "coding", displayName: "Coding Agent" }];
4145
+
4146
+ test("shows the Claude credential fields by default", () => {
4147
+ const html = renderNewLocalAgentPage(USER_NAME, types);
4148
+ expect(html).toContain('name="claudeCodeOauthToken"');
4149
+ expect(html).toContain('name="anthropicApiKey"');
4150
+ });
4151
+
4152
+ test("omits the Claude credential fields when federation is enabled", () => {
4153
+ const html = renderNewLocalAgentPage(USER_NAME, types, {
4154
+ anthropicFederationEnabled: true,
4155
+ });
4156
+ expect(html).not.toContain('name="claudeCodeOauthToken"');
4157
+ expect(html).not.toContain('name="anthropicApiKey"');
4158
+ });
4159
+ });
4160
+
4161
+ // ─── renderAgentDetailPage — extension card slots (AEP-2.1) ──────────────────
4162
+
4163
+ describe("renderAgentDetailPage — extension card slots", () => {
4164
+ const render = (extensionCardsHtml?: string[]) =>
4165
+ renderAgentDetailPage(AGENT, {}, [], [], [], [], [], USER_NAME, true, {
4166
+ timezone: "UTC",
4167
+ now: new Date("2025-01-15T00:00:00Z"),
4168
+ ...(extensionCardsHtml ? { extensionCardsHtml } : {}),
4169
+ });
4170
+
4171
+ test("no or empty extension cards renders the page byte-identically", () => {
4172
+ expect(render([])).toBe(render());
4173
+ });
4174
+
4175
+ test("extension cards render in order", () => {
4176
+ const html = render([
4177
+ '<div class="card">ext-one</div>',
4178
+ '<div class="card">ext-two</div>',
4179
+ ]);
4180
+ expect(html).toContain("ext-one");
4181
+ expect(html.indexOf("ext-one")).toBeLessThan(html.indexOf("ext-two"));
4182
+ });
4183
+ });
4184
+
4185
+ // ─── renderAgentDetailPage — platform-only page + configuration slots (AEP-3.2)
4186
+
4187
+ describe("renderAgentDetailPage — platform-only page (no extension cards)", () => {
4188
+ const html = renderAgentDetailPage(
4189
+ AGENT,
4190
+ {},
4191
+ [],
4192
+ [],
4193
+ [],
4194
+ [],
4195
+ [],
4196
+ USER_NAME,
4197
+ true,
4198
+ { timezone: "UTC", now: new Date("2025-01-15T00:00:00Z") },
4199
+ );
4200
+
4201
+ test("renders without the coding extension's cards", () => {
4202
+ expect(html).not.toContain("Phase Methodology");
4203
+ expect(html).not.toContain("GitHub Installations");
4204
+ expect(html).not.toContain("Recent Verification Activity");
4205
+ });
4206
+
4207
+ test("still renders every platform group", () => {
4208
+ for (const group of [
4209
+ "Automation",
4210
+ "Configuration",
4211
+ "Access",
4212
+ "Plugins &amp; Tools",
4213
+ ]) {
4214
+ expect(html).toContain(`<span class="group-title">${group}</span>`);
4215
+ }
4216
+ });
4217
+ });
4218
+
4219
+ describe("renderAgentDetailPage — configuration card slots", () => {
4220
+ const render = (configurationCardsHtml?: string[]) =>
4221
+ renderAgentDetailPage(AGENT, {}, [], [], [], [], [], USER_NAME, true, {
4222
+ timezone: "UTC",
4223
+ now: new Date("2025-01-15T00:00:00Z"),
4224
+ ...(configurationCardsHtml ? { configurationCardsHtml } : {}),
4225
+ });
4226
+
4227
+ test("no or empty configuration cards renders the page byte-identically", () => {
4228
+ expect(render([])).toBe(render());
4229
+ });
4230
+
4231
+ test("configuration cards render in order between Repos and Agent Policy", () => {
4232
+ const html = render([
4233
+ '<div class="card">cfg-one</div>',
4234
+ '<div class="card">cfg-two</div>',
4235
+ ]);
4236
+ const repos = html.indexOf('<div class="card-title">Repos</div>');
4237
+ const one = html.indexOf("cfg-one");
4238
+ const two = html.indexOf("cfg-two");
4239
+ const policy = html.indexOf('<div class="card-title">Agent Policy</div>');
4240
+ expect(repos).toBeGreaterThan(-1);
4241
+ expect(repos).toBeLessThan(one);
4242
+ expect(one).toBeLessThan(two);
4243
+ expect(two).toBeLessThan(policy);
4244
+ });
4245
+
4246
+ test("header cards render above the stat strip, configuration cards inside the Configuration group", () => {
4247
+ const html = renderAgentDetailPage(
4248
+ AGENT,
4249
+ {},
4250
+ [],
4251
+ [],
4252
+ [],
4253
+ [],
4254
+ [],
4255
+ USER_NAME,
4256
+ true,
4257
+ {
4258
+ timezone: "UTC",
4259
+ extensionCardsHtml: ['<div class="card">hdr</div>'],
4260
+ configurationCardsHtml: ['<div class="card">cfg</div>'],
4261
+ },
4262
+ );
4263
+ const statStrip = html.indexOf('<div class="stat-strip">');
4264
+ const configuration = html.indexOf(
4265
+ '<span class="group-title">Configuration</span>',
4266
+ );
4267
+ expect(html.indexOf("hdr")).toBeLessThan(statStrip);
4268
+ expect(html.indexOf("cfg")).toBeGreaterThan(configuration);
4269
+ });
4270
+ });
4271
+
4272
+ describe("queue-activity links (AEP-3.6)", () => {
4273
+ const listHtml = (queueActivityLinks?: boolean) =>
4274
+ renderAgentsPage([AGENT_LIST_ITEM], USER_NAME, true, "UTC", {
4275
+ queueActivityLinks,
4276
+ });
4277
+ const detailHtml = (queueActivityLinks?: boolean) =>
4278
+ renderAgentDetailPage(
4279
+ AGENT,
4280
+ {},
4281
+ [CUSTOM_CRON],
4282
+ [],
4283
+ [],
4284
+ [],
4285
+ [],
4286
+ USER_NAME,
4287
+ true,
4288
+ { timezone: "UTC", queueActivityLinks },
4289
+ );
4290
+
4291
+ test("agents list omits the Queue & Activity button without the coding extension", () => {
4292
+ const html = listHtml();
4293
+ expect(html).not.toContain(`/agents/${AGENT_LIST_ITEM.id}/queue-activity`);
4294
+ expect(html).toContain(">Manage</a>");
4295
+ });
4296
+
4297
+ test("agents list renders the Queue & Activity button with the coding extension", () => {
4298
+ const html = listHtml(true);
4299
+ expect(html).toContain(
4300
+ `/admin/agents/${AGENT_LIST_ITEM.id}/queue-activity`,
4301
+ );
4302
+ expect(html).toContain('margin-left:4px">Queue &amp; Activity</a>');
4303
+ });
4304
+
4305
+ test("agent detail omits the cron Logs link without the coding extension", () => {
4306
+ const html = detailHtml();
4307
+ expect(html).not.toContain(`/agents/${AGENT.id}/queue-activity`);
4308
+ expect(html).not.toContain(">Logs</a>");
4309
+ expect(html).toContain(`/crons/${CUSTOM_CRON.id}/toggle`);
4310
+ });
4311
+
4312
+ test("agent detail renders the cron Logs link with the coding extension", () => {
4313
+ const html = detailHtml(true);
4314
+ expect(html).toContain(
4315
+ `/agents/${AGENT.id}/queue-activity?cronId=${CUSTOM_CRON.id}`,
4316
+ );
4317
+ expect(html).toContain(">Logs</a>");
4318
+ });
4319
+ });
4320
+
4321
+ describe("renderAgentDetailPage — signed commits toggle", () => {
4322
+ const render = (envVars: Record<string, string>) =>
4323
+ renderAgentDetailPage(
4324
+ AGENT,
4325
+ envVars,
4326
+ [],
4327
+ [],
4328
+ [],
4329
+ [],
4330
+ [],
4331
+ USER_NAME,
4332
+ true,
4333
+ { timezone: "UTC" },
4334
+ );
4335
+
4336
+ test("hidden when the agent has no GH_APP_ID", () => {
4337
+ expect(render({ SHIPWRIGHT_SIGNED_COMMITS: "1" })).not.toContain(
4338
+ 'name="signedCommits"',
4339
+ );
4340
+ });
4341
+
4342
+ test("shown unchecked when GH_APP_ID is set without the env row", () => {
4343
+ const html = render({ GH_APP_ID: "123" });
4344
+ expect(html).toContain('id="signedCommits"');
4345
+ expect(html).toContain("Signed commits");
4346
+ const checkbox = html.match(/<input\s[^>]*id="signedCommits"[^>]*>/s)?.[0];
4347
+ expect(checkbox).not.toContain("checked");
4348
+ });
4349
+
4350
+ test("shown checked when SHIPWRIGHT_SIGNED_COMMITS=1", () => {
4351
+ const html = render({ GH_APP_ID: "123", SHIPWRIGHT_SIGNED_COMMITS: "1" });
4352
+ const checkbox = html.match(/<input\s[^>]*id="signedCommits"[^>]*>/s)?.[0];
4353
+ expect(checkbox).toContain("checked");
4354
+ });
4355
+ });