kcg-code 0.0.0-stage → 0.2.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 (307) hide show
  1. package/README.md +230 -2
  2. package/bin/kcgcode.ts +13 -0
  3. package/bun-env.d.ts +19 -0
  4. package/kcg-code.config.example.json +3 -0
  5. package/package.json +84 -4
  6. package/public/logo.svg +1 -0
  7. package/public/manifest.json +18 -0
  8. package/public/sw.js +49 -0
  9. package/src/App.tsx +39 -0
  10. package/src/__tests__/manifest.test.ts +61 -0
  11. package/src/build-config.ts +9 -0
  12. package/src/cli/__tests__/daemon.test.ts +84 -0
  13. package/src/cli/__tests__/dashboard.test.ts +224 -0
  14. package/src/cli/__tests__/keep-awake.test.ts +44 -0
  15. package/src/cli/__tests__/port.test.ts +86 -0
  16. package/src/cli/args.ts +186 -0
  17. package/src/cli/attach.ts +154 -0
  18. package/src/cli/commands.ts +327 -0
  19. package/src/cli/daemon.ts +275 -0
  20. package/src/cli/dashboard.tsx +1060 -0
  21. package/src/cli/index.ts +47 -0
  22. package/src/cli/keep-awake.ts +148 -0
  23. package/src/cli/port.ts +92 -0
  24. package/src/cli/theme.ts +100 -0
  25. package/src/components/auth/AuthGate.tsx +182 -0
  26. package/src/components/auth/AvatarEditor.tsx +236 -0
  27. package/src/components/auth/LockScreen.tsx +714 -0
  28. package/src/components/auth/ProfileAvatar.tsx +60 -0
  29. package/src/components/auth/WelcomeScreen.tsx +672 -0
  30. package/src/components/auth/__tests__/WelcomeScreen.test.tsx +42 -0
  31. package/src/components/auth/avatar-icons.tsx +114 -0
  32. package/src/components/layout/AppShell.tsx +147 -0
  33. package/src/components/layout/AppSidebar.tsx +419 -0
  34. package/src/components/layout/SearchDialog.tsx +189 -0
  35. package/src/components/layout/SidebarAccount.tsx +111 -0
  36. package/src/components/layout/SidebarSessionMenu.tsx +163 -0
  37. package/src/components/layout/ThemeToggle.tsx +67 -0
  38. package/src/components/projects/FolderBrowser.tsx +142 -0
  39. package/src/components/projects/HomeChat.tsx +287 -0
  40. package/src/components/projects/HomeRecentSessions.tsx +90 -0
  41. package/src/components/projects/NewProjectDialog.tsx +249 -0
  42. package/src/components/projects/ProjectInstructions.tsx +218 -0
  43. package/src/components/projects/ProjectRow.tsx +58 -0
  44. package/src/components/projects/ProjectSidePanel.tsx +514 -0
  45. package/src/components/sessions/AgentLogo.tsx +42 -0
  46. package/src/components/sessions/AgentModeList.tsx +265 -0
  47. package/src/components/sessions/AgentPicker.tsx +132 -0
  48. package/src/components/sessions/AssistantTurn.tsx +145 -0
  49. package/src/components/sessions/AttachmentTile.tsx +161 -0
  50. package/src/components/sessions/CodeBlock.tsx +107 -0
  51. package/src/components/sessions/ComposerActionSheet.tsx +96 -0
  52. package/src/components/sessions/ConfirmDeleteProjectDialog.tsx +80 -0
  53. package/src/components/sessions/ConfirmSessionDeleteDialog.tsx +92 -0
  54. package/src/components/sessions/ContextPanel.tsx +661 -0
  55. package/src/components/sessions/DropPlate.tsx +165 -0
  56. package/src/components/sessions/FileDropOverlay.tsx +232 -0
  57. package/src/components/sessions/FileMentionDropdown.tsx +47 -0
  58. package/src/components/sessions/ImageLightbox.tsx +173 -0
  59. package/src/components/sessions/MarkdownContent.tsx +187 -0
  60. package/src/components/sessions/Mascot.tsx +47 -0
  61. package/src/components/sessions/MessageActions.tsx +60 -0
  62. package/src/components/sessions/MessageRail.tsx +121 -0
  63. package/src/components/sessions/ModelPicker.tsx +247 -0
  64. package/src/components/sessions/ModelSearchList.tsx +231 -0
  65. package/src/components/sessions/NewSessionDialog.tsx +172 -0
  66. package/src/components/sessions/PendingAttachments.tsx +127 -0
  67. package/src/components/sessions/PromptCard.tsx +1085 -0
  68. package/src/components/sessions/PromptPanel.tsx +112 -0
  69. package/src/components/sessions/SessionComposer.tsx +641 -0
  70. package/src/components/sessions/SessionHeader.tsx +176 -0
  71. package/src/components/sessions/SessionList.tsx +503 -0
  72. package/src/components/sessions/SessionRow.tsx +237 -0
  73. package/src/components/sessions/SessionTimeline.tsx +275 -0
  74. package/src/components/sessions/SessionTimelineSkeleton.tsx +83 -0
  75. package/src/components/sessions/SessionView.tsx +240 -0
  76. package/src/components/sessions/ThoughtProcessBlock.tsx +199 -0
  77. package/src/components/sessions/UserMessage.tsx +177 -0
  78. package/src/components/sessions/__tests__/ContextPanel.test.tsx +256 -0
  79. package/src/components/sessions/__tests__/MarkdownContent.test.tsx +89 -0
  80. package/src/components/sessions/__tests__/MessageRail.test.tsx +40 -0
  81. package/src/components/sessions/__tests__/PermissionCard.test.tsx +79 -0
  82. package/src/components/sessions/__tests__/PromptCard.test.ts +166 -0
  83. package/src/components/sessions/__tests__/QuestionCard.test.tsx +166 -0
  84. package/src/components/sessions/__tests__/SessionTimelineSkeleton.test.tsx +32 -0
  85. package/src/components/sessions/__tests__/SessionView.test.ts +386 -0
  86. package/src/components/settings/AboutGroup.tsx +170 -0
  87. package/src/components/settings/AccountGroup.tsx +122 -0
  88. package/src/components/settings/DevicesGroup.tsx +151 -0
  89. package/src/components/settings/LhrRemoteAccess.tsx +257 -0
  90. package/src/components/settings/PasskeysGroup.tsx +551 -0
  91. package/src/components/settings/RemoteAccessGroup.tsx +1201 -0
  92. package/src/components/settings/SecurityGroup.tsx +566 -0
  93. package/src/components/settings/WorkspaceGroup.tsx +370 -0
  94. package/src/components/settings/__tests__/AboutGroup.test.tsx +47 -0
  95. package/src/components/settings/__tests__/PasskeysGroup.test.tsx +109 -0
  96. package/src/components/settings/__tests__/WorkspaceGroup.test.tsx +49 -0
  97. package/src/components/settings/prefs.tsx +333 -0
  98. package/src/components/skills/InstallNotifications.tsx +326 -0
  99. package/src/components/skills/InstallSkillDialog.tsx +390 -0
  100. package/src/components/skills/SkillCards.tsx +254 -0
  101. package/src/components/skills/SkillsProjectPicker.tsx +178 -0
  102. package/src/components/ui/adw-dialog.tsx +114 -0
  103. package/src/components/ui/adw-toggle-group.tsx +75 -0
  104. package/src/components/ui/alert-dialog.tsx +179 -0
  105. package/src/components/ui/alert.tsx +60 -0
  106. package/src/components/ui/badge.tsx +46 -0
  107. package/src/components/ui/bubble.tsx +125 -0
  108. package/src/components/ui/button.tsx +62 -0
  109. package/src/components/ui/card.tsx +75 -0
  110. package/src/components/ui/collapsible.tsx +19 -0
  111. package/src/components/ui/dialog.tsx +142 -0
  112. package/src/components/ui/dropdown-menu.tsx +225 -0
  113. package/src/components/ui/empty.tsx +94 -0
  114. package/src/components/ui/field.tsx +234 -0
  115. package/src/components/ui/input-group.tsx +161 -0
  116. package/src/components/ui/input.tsx +21 -0
  117. package/src/components/ui/label.tsx +19 -0
  118. package/src/components/ui/message-scroller.tsx +131 -0
  119. package/src/components/ui/message.tsx +85 -0
  120. package/src/components/ui/popover.tsx +46 -0
  121. package/src/components/ui/search-entry.tsx +70 -0
  122. package/src/components/ui/select.tsx +172 -0
  123. package/src/components/ui/separator.tsx +28 -0
  124. package/src/components/ui/sheet.tsx +138 -0
  125. package/src/components/ui/sidebar.tsx +699 -0
  126. package/src/components/ui/skeleton.tsx +15 -0
  127. package/src/components/ui/sonner.tsx +45 -0
  128. package/src/components/ui/spinner.tsx +16 -0
  129. package/src/components/ui/switch.tsx +32 -0
  130. package/src/components/ui/textarea.tsx +18 -0
  131. package/src/components/ui/tooltip.tsx +51 -0
  132. package/src/config.ts +114 -0
  133. package/src/db.ts +146 -0
  134. package/src/frontend.tsx +51 -0
  135. package/src/hooks/__tests__/useMention.test.ts +79 -0
  136. package/src/hooks/__tests__/useResyncOnReconnect.test.ts +128 -0
  137. package/src/hooks/__tests__/useRouter.test.ts +17 -0
  138. package/src/hooks/__tests__/useSmoothText.react.test.ts +194 -0
  139. package/src/hooks/__tests__/useSmoothText.test.ts +61 -0
  140. package/src/hooks/__tests__/useTheme.test.ts +118 -0
  141. package/src/hooks/use-mobile.ts +16 -0
  142. package/src/hooks/useAgentPicker.ts +101 -0
  143. package/src/hooks/useMediaQuery.ts +49 -0
  144. package/src/hooks/useMention.ts +236 -0
  145. package/src/hooks/useProjects.ts +88 -0
  146. package/src/hooks/useResyncOnReconnect.ts +28 -0
  147. package/src/hooks/useRouter.ts +92 -0
  148. package/src/hooks/useSessionChat.ts +427 -0
  149. package/src/hooks/useSessionContext.ts +106 -0
  150. package/src/hooks/useSessionList.ts +327 -0
  151. package/src/hooks/useSidebarData.ts +108 -0
  152. package/src/hooks/useSmoothText.ts +122 -0
  153. package/src/hooks/useTheme.ts +164 -0
  154. package/src/hooks/useWebSocket.ts +196 -0
  155. package/src/index.css +27 -0
  156. package/src/index.html +14 -0
  157. package/src/index.ts +49 -0
  158. package/src/lib/__tests__/attachments.test.ts +58 -0
  159. package/src/lib/__tests__/avatar-presets.test.ts +25 -0
  160. package/src/lib/__tests__/collapsible.test.ts +101 -0
  161. package/src/lib/__tests__/composer.test.ts +118 -0
  162. package/src/lib/__tests__/highlight.test.ts +42 -0
  163. package/src/lib/__tests__/home-chat.test.ts +64 -0
  164. package/src/lib/__tests__/lock-screen.test.ts +64 -0
  165. package/src/lib/__tests__/model-picker.test.ts +205 -0
  166. package/src/lib/__tests__/onboarding.test.ts +41 -0
  167. package/src/lib/__tests__/permission-info.test.ts +61 -0
  168. package/src/lib/__tests__/project-extensions.test.ts +83 -0
  169. package/src/lib/__tests__/project-overview.test.ts +188 -0
  170. package/src/lib/__tests__/references.test.ts +121 -0
  171. package/src/lib/__tests__/routes.test.ts +134 -0
  172. package/src/lib/__tests__/session-selection.test.ts +41 -0
  173. package/src/lib/__tests__/session-summary.test.ts +115 -0
  174. package/src/lib/__tests__/session-title.test.ts +58 -0
  175. package/src/lib/__tests__/sidebar-groups.test.ts +100 -0
  176. package/src/lib/__tests__/skill-installs.test.ts +135 -0
  177. package/src/lib/__tests__/skills-catalog.test.ts +124 -0
  178. package/src/lib/__tests__/tone.test.ts +37 -0
  179. package/src/lib/__tests__/turns-delta.test.ts +55 -0
  180. package/src/lib/agent-types.ts +49 -0
  181. package/src/lib/api.ts +306 -0
  182. package/src/lib/attachments.ts +203 -0
  183. package/src/lib/auth.ts +92 -0
  184. package/src/lib/avatar-presets.ts +31 -0
  185. package/src/lib/collapsible.ts +75 -0
  186. package/src/lib/composer.ts +89 -0
  187. package/src/lib/data-events.ts +17 -0
  188. package/src/lib/headlines.ts +30 -0
  189. package/src/lib/highlight.ts +186 -0
  190. package/src/lib/layout.ts +20 -0
  191. package/src/lib/lock-screen.ts +54 -0
  192. package/src/lib/model-picker.ts +133 -0
  193. package/src/lib/onboarding.ts +38 -0
  194. package/src/lib/passkeys.ts +113 -0
  195. package/src/lib/pending-prompt.ts +53 -0
  196. package/src/lib/permission-info.ts +101 -0
  197. package/src/lib/project-extensions.ts +136 -0
  198. package/src/lib/project-overview.ts +123 -0
  199. package/src/lib/prompts.ts +87 -0
  200. package/src/lib/references.ts +106 -0
  201. package/src/lib/routes.ts +124 -0
  202. package/src/lib/session-handoff.ts +25 -0
  203. package/src/lib/session-selection.ts +40 -0
  204. package/src/lib/session-status.ts +45 -0
  205. package/src/lib/session-summary.ts +68 -0
  206. package/src/lib/session-title.ts +63 -0
  207. package/src/lib/sidebar-groups.ts +78 -0
  208. package/src/lib/skill-installs.ts +362 -0
  209. package/src/lib/skills-catalog.ts +138 -0
  210. package/src/lib/time.ts +22 -0
  211. package/src/lib/tone.ts +80 -0
  212. package/src/lib/turns.ts +320 -0
  213. package/src/lib/utils.ts +6 -0
  214. package/src/logo.svg +31 -0
  215. package/src/pages/projects/ProjectDetailPage.tsx +131 -0
  216. package/src/pages/projects/ProjectsPage.tsx +97 -0
  217. package/src/pages/sessions/SessionPage.tsx +122 -0
  218. package/src/pages/settings/SettingsPage.tsx +366 -0
  219. package/src/pages/settings/__tests__/SettingsPage.test.ts +16 -0
  220. package/src/pages/skills/SkillsPage.tsx +745 -0
  221. package/src/paths.ts +121 -0
  222. package/src/runtime.ts +24 -0
  223. package/src/server/__tests__/app.test.ts +1044 -0
  224. package/src/server/__tests__/auth.test.ts +370 -0
  225. package/src/server/__tests__/config.test.ts +101 -0
  226. package/src/server/__tests__/db.test.ts +485 -0
  227. package/src/server/__tests__/host.test.ts +14 -0
  228. package/src/server/__tests__/tunnel-config.test.ts +38 -0
  229. package/src/server/app.ts +427 -0
  230. package/src/server/db/auth.ts +311 -0
  231. package/src/server/db/messages.ts +50 -0
  232. package/src/server/db/projects.ts +106 -0
  233. package/src/server/db/prompts.ts +78 -0
  234. package/src/server/db/rows.ts +150 -0
  235. package/src/server/db/schema.ts +221 -0
  236. package/src/server/db/sessions.ts +239 -0
  237. package/src/server/db/tunnel.ts +124 -0
  238. package/src/server/host.ts +12 -0
  239. package/src/server/result.ts +15 -0
  240. package/src/server/routes/auth-guard.ts +156 -0
  241. package/src/server/routes/auth.routes.ts +287 -0
  242. package/src/server/routes/helpers.ts +165 -0
  243. package/src/server/routes/meta.routes.ts +24 -0
  244. package/src/server/routes/passkeys.routes.ts +161 -0
  245. package/src/server/routes/projects.routes.ts +167 -0
  246. package/src/server/routes/sessions.routes.ts +197 -0
  247. package/src/server/routes/skills.routes.ts +136 -0
  248. package/src/server/routes/tunnel.routes.ts +132 -0
  249. package/src/server/routes/types.ts +35 -0
  250. package/src/server/routes/uploads.routes.ts +54 -0
  251. package/src/server/routes/workspace.routes.ts +127 -0
  252. package/src/server/services/__tests__/app-info.test.ts +18 -0
  253. package/src/server/services/__tests__/attachments.test.ts +159 -0
  254. package/src/server/services/__tests__/auth-passkeys.test.ts +358 -0
  255. package/src/server/services/__tests__/auth.test.ts +341 -0
  256. package/src/server/services/__tests__/claude-code.test.ts +609 -0
  257. package/src/server/services/__tests__/claude-mcp.test.ts +90 -0
  258. package/src/server/services/__tests__/claude-skills.test.ts +63 -0
  259. package/src/server/services/__tests__/frpc-installer.test.ts +83 -0
  260. package/src/server/services/__tests__/git-info.test.ts +74 -0
  261. package/src/server/services/__tests__/opencode.test.ts +320 -0
  262. package/src/server/services/__tests__/project-manager.test.ts +190 -0
  263. package/src/server/services/__tests__/sandbox.test.ts +141 -0
  264. package/src/server/services/__tests__/session-manager.test.ts +3509 -0
  265. package/src/server/services/__tests__/skill-install-jobs.test.ts +245 -0
  266. package/src/server/services/__tests__/skills-registry.test.ts +365 -0
  267. package/src/server/services/__tests__/tunnel-lhr.test.ts +234 -0
  268. package/src/server/services/__tests__/tunnel.test.ts +367 -0
  269. package/src/server/services/__tests__/websocket-gateway.test.ts +677 -0
  270. package/src/server/services/__tests__/workspace.test.ts +249 -0
  271. package/src/server/services/app-info.ts +29 -0
  272. package/src/server/services/attachments.ts +193 -0
  273. package/src/server/services/auth-passkeys.ts +459 -0
  274. package/src/server/services/auth.ts +633 -0
  275. package/src/server/services/claude-code-client.ts +1093 -0
  276. package/src/server/services/claude-code-translate.ts +370 -0
  277. package/src/server/services/claude-mcp.ts +122 -0
  278. package/src/server/services/claude-skills.ts +102 -0
  279. package/src/server/services/frpc-installer.ts +183 -0
  280. package/src/server/services/git-info.ts +87 -0
  281. package/src/server/services/opencode-client.ts +714 -0
  282. package/src/server/services/opencode-server.ts +319 -0
  283. package/src/server/services/opencode-sse.ts +82 -0
  284. package/src/server/services/project-manager.ts +147 -0
  285. package/src/server/services/sandbox.ts +83 -0
  286. package/src/server/services/session-events.ts +256 -0
  287. package/src/server/services/session-manager.ts +1439 -0
  288. package/src/server/services/skill-install-jobs.ts +275 -0
  289. package/src/server/services/skills-registry.ts +480 -0
  290. package/src/server/services/tunnel-api.ts +207 -0
  291. package/src/server/services/tunnel-config.ts +45 -0
  292. package/src/server/services/tunnel-lhr.ts +387 -0
  293. package/src/server/services/tunnel.ts +633 -0
  294. package/src/server/services/turn-stream.ts +339 -0
  295. package/src/server/services/websocket-gateway.ts +374 -0
  296. package/src/server/services/workspace.ts +223 -0
  297. package/src/server/services/ws-transport.ts +121 -0
  298. package/src/types/context.ts +69 -0
  299. package/src/types/index.ts +13 -0
  300. package/src/types/message.ts +58 -0
  301. package/src/types/project.ts +16 -0
  302. package/src/types/prompt.ts +118 -0
  303. package/src/types/session.ts +52 -0
  304. package/src/types/turns.ts +47 -0
  305. package/src/ws-protocol.ts +93 -0
  306. package/styles/globals.css +311 -0
  307. package/tsconfig.json +37 -0
@@ -0,0 +1,188 @@
1
+ /**
2
+ * Unit test ringkasan Project homepage (src/lib/project-overview.ts).
3
+ *
4
+ * Yang diuji: penggabungan Project + Session, urutan aktivitas terbaru,
5
+ * filter pencarian, dan label waktu relatif — semuanya logika murni
6
+ * (pola yang sama dengan `routes.test.ts`).
7
+ */
8
+ import { describe, expect, test } from "bun:test";
9
+ import type { Project, Session, SessionStatus } from "@/types";
10
+ import {
11
+ buildProjectOverviews,
12
+ filterProjectOverviews,
13
+ formatRelativeTime,
14
+ RECENT_SESSION_LIMIT,
15
+ totalRunningSessions,
16
+ } from "../project-overview";
17
+
18
+ function project(id: string, name: string, createdAt: number, path = `/sandbox/${name}`): Project {
19
+ return { id, name, path, createdAt };
20
+ }
21
+
22
+ function session(id: string, projectId: string, status: SessionStatus, updatedAt: number): Session {
23
+ return {
24
+ id,
25
+ projectId,
26
+ agentType: "opencode",
27
+ cwd: "/sandbox",
28
+ status,
29
+ ocSessionId: null,
30
+ title: null,
31
+ model: null,
32
+ agent: null,
33
+ createdAt: updatedAt,
34
+ updatedAt,
35
+ };
36
+ }
37
+
38
+ describe("buildProjectOverviews", () => {
39
+ test("menghitung jumlah Session dan Session berjalan per Project", () => {
40
+ const projects = [project("p1", "alpha", 1_000), project("p2", "beta", 2_000)];
41
+ const sessions = [
42
+ session("s1", "p1", "running", 5_000),
43
+ session("s2", "p1", "stopped", 4_000),
44
+ session("s3", "p2", "crashed", 3_000),
45
+ ];
46
+
47
+ const [first, second] = buildProjectOverviews(projects, sessions);
48
+
49
+ expect(first?.project.id).toBe("p1");
50
+ expect(first?.sessionCount).toBe(2);
51
+ expect(first?.runningCount).toBe(1);
52
+ expect(first?.lastSession?.id).toBe("s1");
53
+ expect(first?.lastActivityAt).toBe(5_000);
54
+
55
+ expect(second?.project.id).toBe("p2");
56
+ expect(second?.runningCount).toBe(0);
57
+ expect(second?.lastSession?.id).toBe("s3");
58
+ });
59
+
60
+ test("Project tanpa Session memakai createdAt sebagai aktivitas terakhir", () => {
61
+ const [only] = buildProjectOverviews([project("p1", "alpha", 7_000)], []);
62
+ expect(only?.sessionCount).toBe(0);
63
+ expect(only?.lastSession).toBeNull();
64
+ expect(only?.lastActivityAt).toBe(7_000);
65
+ });
66
+
67
+ test("recentSessions: maksimal RECENT_SESSION_LIMIT, terbaru di depan", () => {
68
+ const sessions = [
69
+ session("s1", "p1", "stopped", 1_000),
70
+ session("s2", "p1", "running", 4_000),
71
+ session("s3", "p1", "stopped", 2_000),
72
+ session("s4", "p1", "stopped", 3_000),
73
+ ];
74
+ const [only] = buildProjectOverviews([project("p1", "alpha", 0)], sessions);
75
+ expect(only?.recentSessions.map((s) => s.id)).toEqual(
76
+ ["s2", "s4", "s3"].slice(0, RECENT_SESSION_LIMIT),
77
+ );
78
+ expect(only?.lastSession?.id).toBe("s2");
79
+ });
80
+
81
+ test("urutan: aktivitas terbaru di atas", () => {
82
+ const projects = [
83
+ project("p1", "alpha", 1_000),
84
+ project("p2", "beta", 2_000),
85
+ project("p3", "gamma", 3_000),
86
+ ];
87
+ const sessions = [session("s1", "p1", "stopped", 9_000)];
88
+
89
+ const ids = buildProjectOverviews(projects, sessions).map((o) => o.project.id);
90
+ expect(ids).toEqual(["p1", "p3", "p2"]);
91
+ });
92
+
93
+ test("aktivitas sama diurut berdasarkan nama (case-insensitive)", () => {
94
+ const projects = [project("p1", "Zeta", 1_000), project("p2", "alpha", 1_000)];
95
+ const ids = buildProjectOverviews(projects, []).map((o) => o.project.id);
96
+ expect(ids).toEqual(["p2", "p1"]);
97
+ });
98
+
99
+ test("Session dengan projectId tak dikenal diabaikan", () => {
100
+ const overviews = buildProjectOverviews(
101
+ [project("p1", "alpha", 1_000)],
102
+ [session("s1", "ghost", "running", 9_000)],
103
+ );
104
+ expect(overviews).toHaveLength(1);
105
+ expect(overviews[0]?.sessionCount).toBe(0);
106
+ });
107
+
108
+ test("daftar Project kosong -> hasil kosong", () => {
109
+ expect(buildProjectOverviews([], [session("s1", "p1", "running", 1)])).toEqual([]);
110
+ });
111
+ });
112
+
113
+ describe("filterProjectOverviews", () => {
114
+ const overviews = buildProjectOverviews(
115
+ [
116
+ project("p1", "web-app", 1_000, "/sandbox/web-app"),
117
+ project("p2", "api-service", 2_000, "/sandbox/backend/api"),
118
+ ],
119
+ [],
120
+ );
121
+
122
+ test("query kosong mengembalikan semua", () => {
123
+ expect(filterProjectOverviews(overviews, "")).toHaveLength(2);
124
+ expect(filterProjectOverviews(overviews, " ")).toHaveLength(2);
125
+ });
126
+
127
+ test("cocok pada nama tanpa membedakan huruf besar/kecil", () => {
128
+ const found = filterProjectOverviews(overviews, "WEB");
129
+ expect(found.map((o) => o.project.id)).toEqual(["p1"]);
130
+ });
131
+
132
+ test("cocok pada path", () => {
133
+ const found = filterProjectOverviews(overviews, "backend");
134
+ expect(found.map((o) => o.project.id)).toEqual(["p2"]);
135
+ });
136
+
137
+ test("tidak ada yang cocok -> kosong", () => {
138
+ expect(filterProjectOverviews(overviews, "zzz")).toEqual([]);
139
+ });
140
+ });
141
+
142
+ describe("totalRunningSessions", () => {
143
+ test("menjumlahkan Session berjalan seluruh Project", () => {
144
+ const overviews = buildProjectOverviews(
145
+ [project("p1", "alpha", 1), project("p2", "beta", 2)],
146
+ [
147
+ session("s1", "p1", "running", 10),
148
+ session("s2", "p1", "running", 11),
149
+ session("s3", "p2", "stopped", 12),
150
+ ],
151
+ );
152
+ expect(totalRunningSessions(overviews)).toBe(2);
153
+ });
154
+
155
+ test("tanpa Session -> 0", () => {
156
+ expect(totalRunningSessions([])).toBe(0);
157
+ });
158
+ });
159
+
160
+ describe("formatRelativeTime", () => {
161
+ const now = Date.UTC(2026, 0, 15, 12, 0, 0);
162
+ const minute = 60_000;
163
+
164
+ test("kurang dari satu menit -> just now", () => {
165
+ expect(formatRelativeTime(now - 30_000, now)).toBe("just now");
166
+ });
167
+
168
+ test("timestamp di masa depan tetap just now", () => {
169
+ expect(formatRelativeTime(now + minute, now)).toBe("just now");
170
+ });
171
+
172
+ test("skala menit, jam, dan hari", () => {
173
+ expect(formatRelativeTime(now - 5 * minute, now)).toBe("5 minutes ago");
174
+ expect(formatRelativeTime(now - 3 * 60 * minute, now)).toBe("3 hours ago");
175
+ expect(formatRelativeTime(now - 2 * 24 * 60 * minute, now)).toBe("2 days ago");
176
+ });
177
+
178
+ test("lebih dari sepekan -> tanggal absolut", () => {
179
+ const old = now - 30 * 24 * 60 * minute;
180
+ expect(formatRelativeTime(old, now)).toBe(
181
+ new Date(old).toLocaleDateString(undefined, {
182
+ day: "2-digit",
183
+ month: "short",
184
+ year: "numeric",
185
+ }),
186
+ );
187
+ });
188
+ });
@@ -0,0 +1,121 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { SessionMessage } from "@/types";
3
+ import { baseName, collectReferences, toProjectPath } from "../references";
4
+
5
+ function msg(parts: SessionMessage["parts"]): SessionMessage {
6
+ return { id: "m1", sessionId: "s1", role: "assistant", parts, createdAt: 0 };
7
+ }
8
+
9
+ function tool(name: string, filePath: string) {
10
+ return { type: "tool", tool: name, state: { input: { filePath } } };
11
+ }
12
+
13
+ describe("toProjectPath", () => {
14
+ const cwd = "/home/u/proj";
15
+
16
+ test("relatif diterima apa adanya (./ dibuang)", () => {
17
+ expect(toProjectPath("src/App.tsx", cwd)).toBe("src/App.tsx");
18
+ expect(toProjectPath("./src/App.tsx", cwd)).toBe("src/App.tsx");
19
+ });
20
+
21
+ test("absolut di dalam project -> relatif; di luar project -> null", () => {
22
+ expect(toProjectPath("/home/u/proj/src/a.ts", cwd)).toBe("src/a.ts");
23
+ expect(toProjectPath("/tmp/x.ts", cwd)).toBeNull();
24
+ // Prefix mirip tapi project lain.
25
+ expect(toProjectPath("/home/u/proj-lain/a.ts", cwd)).toBeNull();
26
+ // Tanpa cwd, path absolut tak bisa dipastikan milik project.
27
+ expect(toProjectPath("/home/u/proj/src/a.ts", null)).toBeNull();
28
+ });
29
+
30
+ test("direktori, glob, keluar project, dan kosong -> null", () => {
31
+ expect(toProjectPath("src/", cwd)).toBeNull();
32
+ expect(toProjectPath("src/**/*.ts", cwd)).toBeNull();
33
+ expect(toProjectPath("../rahasia.ts", cwd)).toBeNull();
34
+ expect(toProjectPath(" ", cwd)).toBeNull();
35
+ });
36
+ });
37
+
38
+ describe("collectReferences", () => {
39
+ test("hanya file yang DIBACA; write/edit/patch tidak dihitung", () => {
40
+ const refs = collectReferences([
41
+ msg([
42
+ tool("read", "src/a.ts"),
43
+ tool("write", "src/baru.ts"),
44
+ tool("edit", "src/diubah.ts"),
45
+ tool("patch", "src/patched.ts"),
46
+ tool("multiedit", "src/multi.ts"),
47
+ ]),
48
+ ]);
49
+ expect(refs.map((r) => r.path)).toEqual(["src/a.ts"]);
50
+ });
51
+
52
+ test("file yang dibaca lalu diubah tetap muncul (karena dibaca)", () => {
53
+ const refs = collectReferences([
54
+ msg([tool("read", "a.ts"), tool("edit", "a.ts")]),
55
+ msg([tool("write", "hanya-ditulis.ts")]),
56
+ ]);
57
+ expect(refs.map((r) => r.path)).toEqual(["a.ts"]);
58
+ });
59
+
60
+ test("bash/grep/glob diabaikan", () => {
61
+ const refs = collectReferences([
62
+ msg([
63
+ tool("read", "src/a.ts"),
64
+ { type: "tool", tool: "bash", state: { input: { command: "ls -la" } } },
65
+ { type: "tool", tool: "grep", state: { input: { pattern: "foo" } } },
66
+ { type: "tool", tool: "glob", state: { input: { pattern: "src/**" } } },
67
+ ]),
68
+ ]);
69
+ expect(refs.map((r) => r.path)).toEqual(["src/a.ts"]);
70
+ });
71
+
72
+ test("terbaru di atas & tanpa duplikat", () => {
73
+ const refs = collectReferences([
74
+ msg([tool("read", "a.ts"), tool("read", "b.ts"), tool("read", "c.ts")]),
75
+ ]);
76
+ expect(refs.map((r) => r.path)).toEqual(["c.ts", "b.ts", "a.ts"]);
77
+ });
78
+
79
+ test("file yang dibaca lagi naik kembali ke atas", () => {
80
+ const refs = collectReferences([
81
+ msg([tool("read", "a.ts"), tool("read", "b.ts")]),
82
+ msg([tool("read", "a.ts")]),
83
+ ]);
84
+ expect(refs.map((r) => r.path)).toEqual(["a.ts", "b.ts"]);
85
+ });
86
+
87
+ test("urutan terbaru berlaku lintas pesan", () => {
88
+ const refs = collectReferences([
89
+ msg([tool("read", "lama.ts")]),
90
+ msg([tool("read", "tengah.ts")]),
91
+ msg([tool("read", "baru.ts")]),
92
+ ]);
93
+ expect(refs[0]?.path).toBe("baru.ts");
94
+ });
95
+
96
+ test("echo @file ikut (dibaca); lampiran upload tidak", () => {
97
+ const refs = collectReferences([
98
+ msg([
99
+ { type: "file", filename: "docs/spec.md" },
100
+ { type: "file", filename: "foto.png", attachmentId: "abc", mime: "image/png" },
101
+ ]),
102
+ ]);
103
+ expect(refs).toEqual([{ path: "docs/spec.md" }]);
104
+ });
105
+
106
+ test("path absolut tool dinormalkan dengan cwd", () => {
107
+ const refs = collectReferences([msg([tool("read", "/home/u/proj/src/a.ts")])], "/home/u/proj");
108
+ expect(refs).toEqual([{ path: "src/a.ts" }]);
109
+ });
110
+
111
+ test("tanpa pesan / tanpa pembacaan file -> kosong", () => {
112
+ expect(collectReferences([])).toEqual([]);
113
+ expect(collectReferences([msg([{ type: "text", text: "halo" }])])).toEqual([]);
114
+ expect(collectReferences([msg([tool("write", "x.ts")])])).toEqual([]);
115
+ });
116
+ });
117
+
118
+ test("baseName mengambil nama file terakhir", () => {
119
+ expect(baseName("src/components/App.tsx")).toBe("App.tsx");
120
+ expect(baseName("README.md")).toBe("README.md");
121
+ });
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Unit test logika rute murni (src/lib/routes.ts).
3
+ *
4
+ * `bun test` tidak menyediakan DOM, sehingga yang diuji adalah
5
+ * `parseRoute()` dan pembangun path (`projectsPath`, `projectPath`,
6
+ * `sessionPath`) — pola yang sama dengan test `useTheme.ts`. Hook
7
+ * `useRouter` dan `navigate` (History API) diuji manual via dev server.
8
+ */
9
+ import { describe, expect, test } from "bun:test";
10
+ import {
11
+ parseRoute,
12
+ parseSkillsProject,
13
+ projectPath,
14
+ projectsPath,
15
+ sessionPath,
16
+ settingsPath,
17
+ skillsPath,
18
+ } from "../routes";
19
+
20
+ describe("pembangun path", () => {
21
+ test("projectsPath menghasilkan /", () => {
22
+ expect(projectsPath()).toBe("/");
23
+ });
24
+
25
+ test("projectPath menyisipkan id Project", () => {
26
+ expect(projectPath("abc-123")).toBe("/projects/abc-123");
27
+ });
28
+
29
+ test("sessionPath menyisipkan id Project dan id Session", () => {
30
+ expect(sessionPath("p1", "s2")).toBe("/projects/p1/sessions/s2");
31
+ });
32
+ });
33
+
34
+ describe("parseRoute", () => {
35
+ test("pathname kosong / root -> projects", () => {
36
+ expect(parseRoute("/")).toEqual({ name: "projects" });
37
+ expect(parseRoute("")).toEqual({ name: "projects" });
38
+ });
39
+
40
+ test("trailing slash diabaikan", () => {
41
+ expect(parseRoute("/projects/p1/")).toEqual({ name: "project", projectId: "p1" });
42
+ expect(parseRoute("/projects/p1/sessions/s2/")).toEqual({
43
+ name: "session",
44
+ projectId: "p1",
45
+ sessionId: "s2",
46
+ });
47
+ });
48
+
49
+ test("URL daftar Session Project", () => {
50
+ expect(parseRoute("/projects/p1")).toEqual({ name: "project", projectId: "p1" });
51
+ });
52
+
53
+ test("URL Session view", () => {
54
+ expect(parseRoute("/projects/p1/sessions/s2")).toEqual({
55
+ name: "session",
56
+ projectId: "p1",
57
+ sessionId: "s2",
58
+ });
59
+ });
60
+
61
+ test("id UUID tetap opaque (tanpa decoding khusus)", () => {
62
+ const id = "6f0a2b1e-9c4d-4f6a-8e1b-2c3d4e5f6a7b";
63
+ expect(parseRoute(`/projects/${id}`)).toEqual({ name: "project", projectId: id });
64
+ expect(parseRoute(`/projects/${id}/sessions/${id}`)).toEqual({
65
+ name: "session",
66
+ projectId: id,
67
+ sessionId: id,
68
+ });
69
+ });
70
+
71
+ test("path tak dikenal -> not-found", () => {
72
+ expect(parseRoute("/preferences")).toEqual({ name: "not-found" });
73
+ expect(parseRoute("/projects")).toEqual({ name: "not-found" });
74
+ expect(parseRoute("/projects/p1/extra")).toEqual({ name: "not-found" });
75
+ expect(parseRoute("/projects/p1/other/s2")).toEqual({ name: "not-found" });
76
+ // Trailing slash dikonsumsi, jadi ini bukan case "segmen kosong".
77
+ expect(parseRoute("/projects//sessions/s2")).toEqual({ name: "not-found" });
78
+ });
79
+ });
80
+
81
+ describe("rute skills", () => {
82
+ test("skillsPath menghasilkan /skills", () => {
83
+ expect(skillsPath()).toBe("/skills");
84
+ });
85
+
86
+ test("/skills (dengan/tanpa trailing slash) -> skills", () => {
87
+ expect(parseRoute("/skills")).toEqual({ name: "skills" });
88
+ expect(parseRoute("/skills/")).toEqual({ name: "skills" });
89
+ });
90
+
91
+ test("skillsPath dengan Project menambahkan ?project=", () => {
92
+ expect(skillsPath("p1")).toBe("/skills?project=p1");
93
+ expect(skillsPath(null)).toBe("/skills");
94
+ });
95
+
96
+ test("parseSkillsProject membaca ?project=", () => {
97
+ expect(parseSkillsProject("?project=p1")).toBe("p1");
98
+ expect(parseSkillsProject("")).toBeNull();
99
+ expect(parseSkillsProject("?project=")).toBeNull();
100
+ });
101
+
102
+ test("sub-path skills tidak dikenal -> not-found", () => {
103
+ expect(parseRoute("/skills/x")).toEqual({ name: "not-found" });
104
+ });
105
+ });
106
+
107
+ describe("rute settings", () => {
108
+ test("settingsPath & parseRoute", () => {
109
+ expect(settingsPath()).toBe("/settings");
110
+ expect(parseRoute("/settings")).toEqual({ name: "settings", section: "profile", root: true });
111
+ expect(parseRoute("/settings/")).toEqual({ name: "settings", section: "profile", root: true });
112
+ expect(parseRoute("/settings/x")).toEqual({ name: "not-found" });
113
+ expect(parseRoute("/settings/security/x")).toEqual({ name: "not-found" });
114
+ });
115
+
116
+ test("URL lama /settings/devices -> Security, grup devices", () => {
117
+ expect(parseRoute("/settings/devices")).toEqual({
118
+ name: "settings",
119
+ section: "security",
120
+ anchor: "devices",
121
+ });
122
+ // Kunci prototipe bukan alias.
123
+ expect(parseRoute("/settings/constructor")).toEqual({ name: "not-found" });
124
+ expect(parseRoute("/settings/toString")).toEqual({ name: "not-found" });
125
+ });
126
+
127
+ test("sub-halaman settings", () => {
128
+ for (const section of ["profile", "security", "workspace", "remote", "about"] as const) {
129
+ expect(settingsPath(section)).toBe(`/settings/${section}`);
130
+ expect(parseRoute(`/settings/${section}`)).toEqual({ name: "settings", section });
131
+ expect(parseRoute(`/settings/${section}/`)).toEqual({ name: "settings", section });
132
+ }
133
+ });
134
+ });
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Unit test selection mode daftar Session (src/lib/session-selection.ts).
3
+ */
4
+ import { describe, expect, test } from "bun:test";
5
+ import {
6
+ describeSelection,
7
+ pluralSessions,
8
+ pruneSelection,
9
+ toggleSelection,
10
+ } from "../session-selection";
11
+
12
+ describe("toggleSelection", () => {
13
+ test("menambah lalu membuang id tanpa memutasi input", () => {
14
+ const empty = new Set<string>();
15
+ const one = toggleSelection(empty, "a");
16
+ expect([...one]).toEqual(["a"]);
17
+ expect(empty.size).toBe(0);
18
+ expect([...toggleSelection(one, "a")]).toEqual([]);
19
+ });
20
+ });
21
+
22
+ describe("pruneSelection", () => {
23
+ test("membuang id yang Session-nya sudah tidak ada", () => {
24
+ const selected = new Set(["a", "b", "c"]);
25
+ expect([...pruneSelection(selected, [{ id: "a" }, { id: "c" }])]).toEqual(["a", "c"]);
26
+ });
27
+
28
+ test("tidak ada yang dibuang -> Set yang sama (hindari render ulang)", () => {
29
+ const selected = new Set(["a"]);
30
+ expect(pruneSelection(selected, [{ id: "a" }, { id: "b" }])).toBe(selected);
31
+ });
32
+ });
33
+
34
+ describe("describeSelection / pluralSessions", () => {
35
+ test("teks ringkas", () => {
36
+ expect(describeSelection(0, 5)).toBe("Tap sessions to select them");
37
+ expect(describeSelection(3, 7)).toBe("3 of 7 selected");
38
+ expect(pluralSessions(1)).toBe("1 session");
39
+ expect(pluralSessions(4)).toBe("4 sessions");
40
+ });
41
+ });
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Unit test ringkasan Session halaman detail Project
3
+ * (src/lib/session-summary.ts) — logika murni, pola sama dengan
4
+ * `routes.test.ts` dan `project-overview.test.ts`.
5
+ */
6
+ import { describe, expect, test } from "bun:test";
7
+ import type { Session, SessionStatus } from "@/types";
8
+ import {
9
+ describeSessionModel,
10
+ describeSessionSummary,
11
+ sortSessions,
12
+ summarizeSessions,
13
+ } from "../session-summary";
14
+
15
+ function session(id: string, status: SessionStatus, updatedAt: number): Session {
16
+ return {
17
+ id,
18
+ projectId: "p1",
19
+ agentType: "opencode",
20
+ cwd: "/sandbox/p1",
21
+ status,
22
+ ocSessionId: null,
23
+ title: null,
24
+ model: null,
25
+ agent: null,
26
+ createdAt: updatedAt,
27
+ updatedAt,
28
+ };
29
+ }
30
+
31
+ describe("sortSessions", () => {
32
+ test("running di atas crashed, crashed di atas stopped", () => {
33
+ const sorted = sortSessions([
34
+ session("stopped", "stopped", 9_000),
35
+ session("crashed", "crashed", 1_000),
36
+ session("running", "running", 500),
37
+ ]);
38
+ expect(sorted.map((s) => s.id)).toEqual(["running", "crashed", "stopped"]);
39
+ });
40
+
41
+ test("dalam status yang sama, terakhir diperbarui di atas", () => {
42
+ const sorted = sortSessions([
43
+ session("lama", "running", 1_000),
44
+ session("baru", "running", 5_000),
45
+ ]);
46
+ expect(sorted.map((s) => s.id)).toEqual(["baru", "lama"]);
47
+ });
48
+
49
+ test("updatedAt sama -> urut berdasarkan id (stabil antar refresh)", () => {
50
+ const sorted = sortSessions([session("b", "running", 1_000), session("a", "running", 1_000)]);
51
+ expect(sorted.map((s) => s.id)).toEqual(["a", "b"]);
52
+ });
53
+
54
+ test("input tidak dimutasi", () => {
55
+ const input = [session("stopped", "stopped", 1), session("running", "running", 2)];
56
+ sortSessions(input);
57
+ expect(input.map((s) => s.id)).toEqual(["stopped", "running"]);
58
+ });
59
+
60
+ test("daftar kosong -> kosong", () => {
61
+ expect(sortSessions([])).toEqual([]);
62
+ });
63
+ });
64
+
65
+ describe("summarizeSessions", () => {
66
+ test("menghitung total dan tiap status", () => {
67
+ const summary = summarizeSessions([
68
+ session("a", "running", 1),
69
+ session("b", "running", 2),
70
+ session("c", "stopped", 3),
71
+ session("d", "crashed", 4),
72
+ ]);
73
+ expect(summary).toEqual({ total: 4, running: 2, stopped: 1, crashed: 1 });
74
+ });
75
+
76
+ test("daftar kosong -> semua nol", () => {
77
+ expect(summarizeSessions([])).toEqual({ total: 0, running: 0, stopped: 0, crashed: 0 });
78
+ });
79
+ });
80
+
81
+ describe("describeSessionSummary", () => {
82
+ test("tanpa Session -> teks pengarah", () => {
83
+ expect(describeSessionSummary({ total: 0, running: 0, stopped: 0, crashed: 0 })).toBe(
84
+ "No sessions yet",
85
+ );
86
+ });
87
+
88
+ test("hanya total bila tidak ada yang berjalan/crash", () => {
89
+ expect(describeSessionSummary({ total: 2, running: 0, stopped: 2, crashed: 0 })).toBe(
90
+ "2 sessions",
91
+ );
92
+ });
93
+
94
+ test("menyebut yang berjalan dan crash", () => {
95
+ expect(describeSessionSummary({ total: 3, running: 1, stopped: 1, crashed: 1 })).toBe(
96
+ "3 sessions · 1 running · 1 crashed",
97
+ );
98
+ });
99
+ });
100
+
101
+ describe("describeSessionModel", () => {
102
+ test("model kosong -> model default", () => {
103
+ expect(describeSessionModel(session("a", "running", 1))).toBe("default model");
104
+ });
105
+
106
+ test("model terpilih -> modelID", () => {
107
+ const s = session("a", "running", 1);
108
+ expect(
109
+ describeSessionModel({
110
+ ...s,
111
+ model: { providerID: "kcgcode", modelID: "kiro/claude-opus-5" },
112
+ }),
113
+ ).toBe("kiro/claude-opus-5");
114
+ });
115
+ });
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Unit test helper judul Session (src/lib/session-title.ts).
3
+ */
4
+ import { describe, expect, test } from "bun:test";
5
+ import {
6
+ deriveSessionTitle,
7
+ displaySessionTitle,
8
+ isOpencodeDefaultTitle,
9
+ isPlaceholderTitle,
10
+ } from "../session-title";
11
+
12
+ describe("isOpencodeDefaultTitle", () => {
13
+ test("pola default opencode (parent & child) dikenali", () => {
14
+ expect(isOpencodeDefaultTitle("New session - 2026-09-29T10:59:18.048Z")).toBe(true);
15
+ expect(isOpencodeDefaultTitle("Child session - 2026-09-29T10:59:18.048Z")).toBe(true);
16
+ });
17
+
18
+ test("judul nyata / pola mirip tidak dianggap default", () => {
19
+ expect(isOpencodeDefaultTitle("Cara Kerja Rate Limiting di Express")).toBe(false);
20
+ expect(isOpencodeDefaultTitle("New session")).toBe(false);
21
+ expect(isOpencodeDefaultTitle("New session - kemarin")).toBe(false);
22
+ expect(isOpencodeDefaultTitle("New session - 2026-09-29")).toBe(false);
23
+ });
24
+ });
25
+
26
+ describe("isPlaceholderTitle", () => {
27
+ test("null, kosong, placeholder lama KCG Code, dan default opencode", () => {
28
+ expect(isPlaceholderTitle(null)).toBe(true);
29
+ expect(isPlaceholderTitle(undefined)).toBe(true);
30
+ expect(isPlaceholderTitle(" ")).toBe(true);
31
+ expect(isPlaceholderTitle("KCG Code Session")).toBe(true);
32
+ expect(isPlaceholderTitle("KCG Code Session (resumed)")).toBe(true);
33
+ expect(isPlaceholderTitle("New session - 2026-09-29T10:59:18.048Z")).toBe(true);
34
+ expect(isPlaceholderTitle("Refactor auth module")).toBe(false);
35
+ });
36
+ });
37
+
38
+ describe("displaySessionTitle", () => {
39
+ test("placeholder -> 'New session'; judul nyata dirapikan", () => {
40
+ expect(displaySessionTitle("New session - 2026-09-29T10:59:18.048Z")).toBe("New session");
41
+ expect(displaySessionTitle(null)).toBe("New session");
42
+ expect(displaySessionTitle(" Rate limiting ")).toBe("Rate limiting");
43
+ });
44
+ });
45
+
46
+ describe("deriveSessionTitle", () => {
47
+ test("baris pertama, spasi dirapatkan, dipotong di batas kata", () => {
48
+ expect(deriveSessionTitle(" halo, project apa ini?\nbaris dua")).toBe(
49
+ "halo, project apa ini?",
50
+ );
51
+ expect(deriveSessionTitle("")).toBeNull();
52
+ const long = deriveSessionTitle(
53
+ "tolong jelaskan cara kerja rate limiting di express dan nestjs",
54
+ );
55
+ expect(long?.endsWith("…")).toBe(true);
56
+ expect((long ?? "").length).toBeLessThanOrEqual(51);
57
+ });
58
+ });