kcg-code 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.
- package/README.md +228 -2
- package/bin/kcgcode.ts +13 -0
- package/bun-env.d.ts +19 -0
- package/kcg-code.config.example.json +3 -0
- package/package.json +83 -4
- package/public/logo.svg +1 -0
- package/public/manifest.json +18 -0
- package/public/sw.js +49 -0
- package/src/App.tsx +39 -0
- package/src/__tests__/manifest.test.ts +61 -0
- package/src/build-config.ts +9 -0
- package/src/cli/__tests__/dashboard.test.ts +62 -0
- package/src/cli/args.ts +123 -0
- package/src/cli/commands.ts +174 -0
- package/src/cli/dashboard.tsx +343 -0
- package/src/cli/index.ts +37 -0
- package/src/cli/theme.ts +100 -0
- package/src/components/auth/AuthGate.tsx +182 -0
- package/src/components/auth/AvatarEditor.tsx +236 -0
- package/src/components/auth/LockScreen.tsx +714 -0
- package/src/components/auth/ProfileAvatar.tsx +60 -0
- package/src/components/auth/WelcomeScreen.tsx +672 -0
- package/src/components/auth/__tests__/WelcomeScreen.test.tsx +42 -0
- package/src/components/auth/avatar-icons.tsx +114 -0
- package/src/components/layout/AppShell.tsx +142 -0
- package/src/components/layout/AppSidebar.tsx +419 -0
- package/src/components/layout/SearchDialog.tsx +189 -0
- package/src/components/layout/SidebarAccount.tsx +111 -0
- package/src/components/layout/SidebarSessionMenu.tsx +163 -0
- package/src/components/layout/ThemeToggle.tsx +67 -0
- package/src/components/projects/FolderBrowser.tsx +142 -0
- package/src/components/projects/HomeChat.tsx +250 -0
- package/src/components/projects/NewProjectDialog.tsx +249 -0
- package/src/components/projects/PanelSection.tsx +102 -0
- package/src/components/projects/ProjectInstructions.tsx +229 -0
- package/src/components/projects/ProjectRow.tsx +58 -0
- package/src/components/projects/ProjectSidePanel.tsx +448 -0
- package/src/components/sessions/AgentModeList.tsx +265 -0
- package/src/components/sessions/AgentPicker.tsx +123 -0
- package/src/components/sessions/AssistantTurn.tsx +145 -0
- package/src/components/sessions/AttachmentTile.tsx +161 -0
- package/src/components/sessions/CodeBlock.tsx +107 -0
- package/src/components/sessions/ComposerActionSheet.tsx +96 -0
- package/src/components/sessions/ConfirmDeleteProjectDialog.tsx +80 -0
- package/src/components/sessions/ConfirmSessionDeleteDialog.tsx +72 -0
- package/src/components/sessions/ContextPanel.tsx +561 -0
- package/src/components/sessions/DropPlate.tsx +165 -0
- package/src/components/sessions/FileDropOverlay.tsx +232 -0
- package/src/components/sessions/FileMentionDropdown.tsx +47 -0
- package/src/components/sessions/ImageLightbox.tsx +173 -0
- package/src/components/sessions/MarkdownContent.tsx +187 -0
- package/src/components/sessions/Mascot.tsx +47 -0
- package/src/components/sessions/MessageActions.tsx +60 -0
- package/src/components/sessions/MessageRail.tsx +121 -0
- package/src/components/sessions/ModelPicker.tsx +209 -0
- package/src/components/sessions/ModelSearchList.tsx +151 -0
- package/src/components/sessions/NewSessionDialog.tsx +189 -0
- package/src/components/sessions/PendingAttachments.tsx +127 -0
- package/src/components/sessions/PromptCard.tsx +1085 -0
- package/src/components/sessions/PromptPanel.tsx +112 -0
- package/src/components/sessions/SessionComposer.tsx +640 -0
- package/src/components/sessions/SessionHeader.tsx +175 -0
- package/src/components/sessions/SessionList.tsx +396 -0
- package/src/components/sessions/SessionRow.tsx +137 -0
- package/src/components/sessions/SessionTimeline.tsx +275 -0
- package/src/components/sessions/SessionTimelineSkeleton.tsx +83 -0
- package/src/components/sessions/SessionView.tsx +236 -0
- package/src/components/sessions/ThoughtProcessBlock.tsx +199 -0
- package/src/components/sessions/UserMessage.tsx +177 -0
- package/src/components/sessions/__tests__/ContextPanel.test.tsx +220 -0
- package/src/components/sessions/__tests__/MarkdownContent.test.tsx +89 -0
- package/src/components/sessions/__tests__/MessageRail.test.tsx +40 -0
- package/src/components/sessions/__tests__/PermissionCard.test.tsx +79 -0
- package/src/components/sessions/__tests__/PromptCard.test.ts +166 -0
- package/src/components/sessions/__tests__/QuestionCard.test.tsx +166 -0
- package/src/components/sessions/__tests__/SessionTimelineSkeleton.test.tsx +32 -0
- package/src/components/sessions/__tests__/SessionView.test.ts +386 -0
- package/src/components/settings/AboutGroup.tsx +170 -0
- package/src/components/settings/AccountGroup.tsx +122 -0
- package/src/components/settings/DevicesGroup.tsx +151 -0
- package/src/components/settings/LhrRemoteAccess.tsx +262 -0
- package/src/components/settings/PasskeysGroup.tsx +551 -0
- package/src/components/settings/RemoteAccessGroup.tsx +1226 -0
- package/src/components/settings/SecurityGroup.tsx +566 -0
- package/src/components/settings/WorkspaceGroup.tsx +370 -0
- package/src/components/settings/__tests__/AboutGroup.test.tsx +47 -0
- package/src/components/settings/__tests__/PasskeysGroup.test.tsx +109 -0
- package/src/components/settings/__tests__/WorkspaceGroup.test.tsx +49 -0
- package/src/components/settings/prefs.tsx +265 -0
- package/src/components/skills/InstallNotifications.tsx +325 -0
- package/src/components/skills/InstallSkillDialog.tsx +233 -0
- package/src/components/skills/SkillCards.tsx +210 -0
- package/src/components/skills/SkillsProjectPicker.tsx +178 -0
- package/src/components/ui/alert-dialog.tsx +179 -0
- package/src/components/ui/alert.tsx +60 -0
- package/src/components/ui/badge.tsx +46 -0
- package/src/components/ui/bubble.tsx +125 -0
- package/src/components/ui/button.tsx +62 -0
- package/src/components/ui/card.tsx +75 -0
- package/src/components/ui/collapsible.tsx +19 -0
- package/src/components/ui/dialog.tsx +142 -0
- package/src/components/ui/dropdown-menu.tsx +225 -0
- package/src/components/ui/empty.tsx +94 -0
- package/src/components/ui/field.tsx +234 -0
- package/src/components/ui/input-group.tsx +161 -0
- package/src/components/ui/input.tsx +21 -0
- package/src/components/ui/label.tsx +19 -0
- package/src/components/ui/message-scroller.tsx +131 -0
- package/src/components/ui/message.tsx +85 -0
- package/src/components/ui/popover.tsx +46 -0
- package/src/components/ui/select.tsx +172 -0
- package/src/components/ui/separator.tsx +28 -0
- package/src/components/ui/sheet.tsx +138 -0
- package/src/components/ui/sidebar.tsx +699 -0
- package/src/components/ui/skeleton.tsx +15 -0
- package/src/components/ui/sonner.tsx +45 -0
- package/src/components/ui/spinner.tsx +16 -0
- package/src/components/ui/switch.tsx +32 -0
- package/src/components/ui/textarea.tsx +18 -0
- package/src/components/ui/tooltip.tsx +51 -0
- package/src/config.ts +85 -0
- package/src/db.ts +146 -0
- package/src/frontend.tsx +51 -0
- package/src/hooks/__tests__/useMention.test.ts +79 -0
- package/src/hooks/__tests__/useResyncOnReconnect.test.ts +128 -0
- package/src/hooks/__tests__/useRouter.test.ts +17 -0
- package/src/hooks/__tests__/useSmoothText.react.test.ts +194 -0
- package/src/hooks/__tests__/useSmoothText.test.ts +61 -0
- package/src/hooks/__tests__/useTheme.test.ts +118 -0
- package/src/hooks/use-mobile.ts +16 -0
- package/src/hooks/useAgentPicker.ts +97 -0
- package/src/hooks/useMediaQuery.ts +49 -0
- package/src/hooks/useMention.ts +236 -0
- package/src/hooks/useProjects.ts +88 -0
- package/src/hooks/useResyncOnReconnect.ts +28 -0
- package/src/hooks/useRouter.ts +92 -0
- package/src/hooks/useSessionChat.ts +427 -0
- package/src/hooks/useSessionContext.ts +106 -0
- package/src/hooks/useSessionList.ts +234 -0
- package/src/hooks/useSidebarData.ts +108 -0
- package/src/hooks/useSmoothText.ts +122 -0
- package/src/hooks/useTheme.ts +164 -0
- package/src/hooks/useWebSocket.ts +196 -0
- package/src/index.css +27 -0
- package/src/index.html +14 -0
- package/src/index.ts +49 -0
- package/src/lib/__tests__/attachments.test.ts +58 -0
- package/src/lib/__tests__/avatar-presets.test.ts +25 -0
- package/src/lib/__tests__/collapsible.test.ts +101 -0
- package/src/lib/__tests__/composer.test.ts +118 -0
- package/src/lib/__tests__/highlight.test.ts +42 -0
- package/src/lib/__tests__/home-chat.test.ts +64 -0
- package/src/lib/__tests__/lock-screen.test.ts +64 -0
- package/src/lib/__tests__/model-picker.test.ts +205 -0
- package/src/lib/__tests__/onboarding.test.ts +41 -0
- package/src/lib/__tests__/permission-info.test.ts +61 -0
- package/src/lib/__tests__/project-extensions.test.ts +52 -0
- package/src/lib/__tests__/project-overview.test.ts +173 -0
- package/src/lib/__tests__/references.test.ts +121 -0
- package/src/lib/__tests__/routes.test.ts +134 -0
- package/src/lib/__tests__/session-summary.test.ts +115 -0
- package/src/lib/__tests__/session-title.test.ts +58 -0
- package/src/lib/__tests__/sidebar-groups.test.ts +100 -0
- package/src/lib/__tests__/skill-installs.test.ts +134 -0
- package/src/lib/__tests__/skills-catalog.test.ts +96 -0
- package/src/lib/__tests__/tone.test.ts +37 -0
- package/src/lib/__tests__/turns-delta.test.ts +55 -0
- package/src/lib/api.ts +306 -0
- package/src/lib/attachments.ts +203 -0
- package/src/lib/auth.ts +92 -0
- package/src/lib/avatar-presets.ts +31 -0
- package/src/lib/collapsible.ts +75 -0
- package/src/lib/composer.ts +89 -0
- package/src/lib/data-events.ts +17 -0
- package/src/lib/headlines.ts +30 -0
- package/src/lib/highlight.ts +186 -0
- package/src/lib/layout.ts +20 -0
- package/src/lib/lock-screen.ts +54 -0
- package/src/lib/model-picker.ts +133 -0
- package/src/lib/onboarding.ts +38 -0
- package/src/lib/passkeys.ts +113 -0
- package/src/lib/pending-prompt.ts +53 -0
- package/src/lib/permission-info.ts +101 -0
- package/src/lib/project-extensions.ts +74 -0
- package/src/lib/project-overview.ts +117 -0
- package/src/lib/prompts.ts +87 -0
- package/src/lib/references.ts +106 -0
- package/src/lib/routes.ts +115 -0
- package/src/lib/session-handoff.ts +25 -0
- package/src/lib/session-status.ts +45 -0
- package/src/lib/session-summary.ts +68 -0
- package/src/lib/session-title.ts +63 -0
- package/src/lib/sidebar-groups.ts +78 -0
- package/src/lib/skill-installs.ts +360 -0
- package/src/lib/skills-catalog.ts +99 -0
- package/src/lib/time.ts +22 -0
- package/src/lib/tone.ts +80 -0
- package/src/lib/turns.ts +320 -0
- package/src/lib/utils.ts +6 -0
- package/src/logo.svg +31 -0
- package/src/pages/projects/ProjectDetailPage.tsx +130 -0
- package/src/pages/projects/ProjectsPage.tsx +97 -0
- package/src/pages/sessions/SessionPage.tsx +122 -0
- package/src/pages/settings/SettingsPage.tsx +299 -0
- package/src/pages/settings/__tests__/SettingsPage.test.ts +9 -0
- package/src/pages/skills/SkillsPage.tsx +701 -0
- package/src/paths.ts +121 -0
- package/src/runtime.ts +24 -0
- package/src/server/__tests__/app.test.ts +1031 -0
- package/src/server/__tests__/auth.test.ts +370 -0
- package/src/server/__tests__/config.test.ts +101 -0
- package/src/server/__tests__/db.test.ts +485 -0
- package/src/server/__tests__/host.test.ts +14 -0
- package/src/server/__tests__/tunnel-config.test.ts +38 -0
- package/src/server/app.ts +415 -0
- package/src/server/db/auth.ts +311 -0
- package/src/server/db/messages.ts +50 -0
- package/src/server/db/projects.ts +106 -0
- package/src/server/db/prompts.ts +78 -0
- package/src/server/db/rows.ts +150 -0
- package/src/server/db/schema.ts +221 -0
- package/src/server/db/sessions.ts +239 -0
- package/src/server/db/tunnel.ts +124 -0
- package/src/server/host.ts +12 -0
- package/src/server/result.ts +15 -0
- package/src/server/routes/auth-guard.ts +156 -0
- package/src/server/routes/auth.routes.ts +287 -0
- package/src/server/routes/helpers.ts +165 -0
- package/src/server/routes/meta.routes.ts +24 -0
- package/src/server/routes/passkeys.routes.ts +161 -0
- package/src/server/routes/projects.routes.ts +157 -0
- package/src/server/routes/sessions.routes.ts +197 -0
- package/src/server/routes/skills.routes.ts +117 -0
- package/src/server/routes/tunnel.routes.ts +132 -0
- package/src/server/routes/types.ts +35 -0
- package/src/server/routes/uploads.routes.ts +54 -0
- package/src/server/routes/workspace.routes.ts +127 -0
- package/src/server/services/__tests__/app-info.test.ts +18 -0
- package/src/server/services/__tests__/attachments.test.ts +159 -0
- package/src/server/services/__tests__/auth-passkeys.test.ts +358 -0
- package/src/server/services/__tests__/auth.test.ts +341 -0
- package/src/server/services/__tests__/frpc-installer.test.ts +83 -0
- package/src/server/services/__tests__/git-info.test.ts +74 -0
- package/src/server/services/__tests__/opencode.test.ts +320 -0
- package/src/server/services/__tests__/project-manager.test.ts +190 -0
- package/src/server/services/__tests__/sandbox.test.ts +141 -0
- package/src/server/services/__tests__/session-manager.test.ts +3469 -0
- package/src/server/services/__tests__/skill-install-jobs.test.ts +226 -0
- package/src/server/services/__tests__/skills-registry.test.ts +359 -0
- package/src/server/services/__tests__/tunnel-lhr.test.ts +234 -0
- package/src/server/services/__tests__/tunnel.test.ts +367 -0
- package/src/server/services/__tests__/websocket-gateway.test.ts +677 -0
- package/src/server/services/__tests__/workspace.test.ts +249 -0
- package/src/server/services/app-info.ts +29 -0
- package/src/server/services/attachments.ts +193 -0
- package/src/server/services/auth-passkeys.ts +459 -0
- package/src/server/services/auth.ts +633 -0
- package/src/server/services/frpc-installer.ts +183 -0
- package/src/server/services/git-info.ts +87 -0
- package/src/server/services/opencode-client.ts +714 -0
- package/src/server/services/opencode-server.ts +319 -0
- package/src/server/services/opencode-sse.ts +82 -0
- package/src/server/services/project-manager.ts +147 -0
- package/src/server/services/sandbox.ts +83 -0
- package/src/server/services/session-events.ts +256 -0
- package/src/server/services/session-manager.ts +1374 -0
- package/src/server/services/skill-install-jobs.ts +262 -0
- package/src/server/services/skills-registry.ts +468 -0
- package/src/server/services/tunnel-api.ts +207 -0
- package/src/server/services/tunnel-config.ts +45 -0
- package/src/server/services/tunnel-lhr.ts +387 -0
- package/src/server/services/tunnel.ts +633 -0
- package/src/server/services/turn-stream.ts +339 -0
- package/src/server/services/websocket-gateway.ts +374 -0
- package/src/server/services/workspace.ts +223 -0
- package/src/server/services/ws-transport.ts +121 -0
- package/src/types/context.ts +69 -0
- package/src/types/index.ts +13 -0
- package/src/types/message.ts +58 -0
- package/src/types/project.ts +16 -0
- package/src/types/prompt.ts +118 -0
- package/src/types/session.ts +52 -0
- package/src/types/turns.ts +47 -0
- package/src/ws-protocol.ts +93 -0
- package/styles/globals.css +311 -0
- package/tsconfig.json +37 -0
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/** Smoke: kartu konteks — Environment, MCP, Task list, Reference. */
|
|
2
|
+
import { expect, test } from "bun:test";
|
|
3
|
+
import { renderToStaticMarkup } from "react-dom/server";
|
|
4
|
+
import type { FileReference } from "@/lib/references";
|
|
5
|
+
import type { SessionContext } from "@/types";
|
|
6
|
+
import {
|
|
7
|
+
ContextPanel,
|
|
8
|
+
ContextPanelTrigger,
|
|
9
|
+
ContextSheet,
|
|
10
|
+
REFERENCE_PREVIEW,
|
|
11
|
+
} from "../ContextPanel";
|
|
12
|
+
|
|
13
|
+
function html(
|
|
14
|
+
context: SessionContext | null,
|
|
15
|
+
references: FileReference[] = [],
|
|
16
|
+
loading = false,
|
|
17
|
+
): string {
|
|
18
|
+
return renderToStaticMarkup(
|
|
19
|
+
<ContextPanel context={context} references={references} loading={loading} onClose={() => {}} />,
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const base: SessionContext = {
|
|
24
|
+
git: { branch: "main", ahead: 0, behind: 0, hasRemote: true },
|
|
25
|
+
changes: [],
|
|
26
|
+
todos: [],
|
|
27
|
+
mcp: [],
|
|
28
|
+
live: true,
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
// ---- Environment ----
|
|
32
|
+
|
|
33
|
+
test("perubahan file: jumlah file + total +tambah / -hapus", () => {
|
|
34
|
+
const out = html({
|
|
35
|
+
...base,
|
|
36
|
+
changes: [
|
|
37
|
+
{ path: "a.ts", added: 3, removed: 1, status: "modified" },
|
|
38
|
+
{ path: "b.ts", added: 2, removed: 4, status: "modified" },
|
|
39
|
+
],
|
|
40
|
+
});
|
|
41
|
+
expect(out).toContain("2 files changed");
|
|
42
|
+
expect(out).toContain("+5");
|
|
43
|
+
expect(out).toContain("-5");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("tanpa perubahan -> 'No changes' tanpa angka diff", () => {
|
|
47
|
+
const out = html(base);
|
|
48
|
+
expect(out).toContain("No changes");
|
|
49
|
+
expect(out).not.toContain("+0");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("cabang git & status sinkron upstream", () => {
|
|
53
|
+
expect(html(base)).toContain("main");
|
|
54
|
+
expect(html(base)).toContain("Up to date");
|
|
55
|
+
expect(html({ ...base, git: { ...base.git, ahead: 2 } })).toContain("2 to push");
|
|
56
|
+
expect(html({ ...base, git: { ...base.git, ahead: 0, behind: 3 } })).toContain("3 to pull");
|
|
57
|
+
expect(html({ ...base, git: { ...base.git, ahead: null, behind: null } })).toContain(
|
|
58
|
+
"Not pushed yet",
|
|
59
|
+
);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("bukan repo git -> ditandai, tanpa baris sinkron", () => {
|
|
63
|
+
const out = html({
|
|
64
|
+
...base,
|
|
65
|
+
git: { branch: null, ahead: null, behind: null, hasRemote: false },
|
|
66
|
+
});
|
|
67
|
+
expect(out).toContain("Not a git repository");
|
|
68
|
+
expect(out).not.toContain("Up to date");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// ---- MCP ----
|
|
72
|
+
|
|
73
|
+
test("MCP: ringkasan connected di judul + status tiap server", () => {
|
|
74
|
+
const out = html({
|
|
75
|
+
...base,
|
|
76
|
+
mcp: [
|
|
77
|
+
{ name: "context7", status: "connected", error: null },
|
|
78
|
+
{ name: "firecrawl", status: "connected", error: null },
|
|
79
|
+
{ name: "github", status: "failed", error: "token expired" },
|
|
80
|
+
],
|
|
81
|
+
});
|
|
82
|
+
expect(out).toContain("2/3 connected");
|
|
83
|
+
expect(out).toContain("context7");
|
|
84
|
+
expect(out).toContain("Failed");
|
|
85
|
+
// Pesan error terbaca lewat title tanpa memanjangkan baris.
|
|
86
|
+
expect(out).toContain('title="token expired"');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("MCP: server bermasalah ditampilkan paling atas", () => {
|
|
90
|
+
const out = html({
|
|
91
|
+
...base,
|
|
92
|
+
mcp: [
|
|
93
|
+
{ name: "aaa-ok", status: "connected", error: null },
|
|
94
|
+
{ name: "zzz-broken", status: "failed", error: null },
|
|
95
|
+
],
|
|
96
|
+
});
|
|
97
|
+
expect(out.indexOf("zzz-broken")).toBeLessThan(out.indexOf("aaa-ok"));
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("MCP kosong: keterangan menyesuaikan status Session", () => {
|
|
101
|
+
expect(html(base)).toContain("No MCP servers configured");
|
|
102
|
+
expect(html({ ...base, live: false })).toContain("Start the session to see MCP servers");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// ---- Task list ----
|
|
106
|
+
|
|
107
|
+
test("task list: progres selesai/total + status dibacakan", () => {
|
|
108
|
+
const out = html({
|
|
109
|
+
...base,
|
|
110
|
+
todos: [
|
|
111
|
+
{ content: "Sedang jalan", status: "in_progress", priority: "high" },
|
|
112
|
+
{ content: "Sudah selesai", status: "completed", priority: "low" },
|
|
113
|
+
{ content: "Belum mulai", status: "pending", priority: "medium" },
|
|
114
|
+
{ content: "Dibatalkan", status: "cancelled", priority: "low" },
|
|
115
|
+
],
|
|
116
|
+
});
|
|
117
|
+
// Task batal tidak dihitung dalam total progres.
|
|
118
|
+
expect(out).toContain("1/3 done");
|
|
119
|
+
expect(out).toContain('role="progressbar"');
|
|
120
|
+
expect(out).toContain('aria-valuenow="1"');
|
|
121
|
+
expect(out).toContain("line-through");
|
|
122
|
+
// Status dibacakan lewat teks, bukan aria-label pada ikon dekoratif.
|
|
123
|
+
expect(out).toContain("In progress: ");
|
|
124
|
+
expect(out).toContain("Completed: ");
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("task list kosong: keterangan menyesuaikan status Session", () => {
|
|
128
|
+
expect(html(base)).toContain("hasn't planned any tasks yet");
|
|
129
|
+
expect(html({ ...base, live: false })).toContain("Session is not running");
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// ---- Reference ----
|
|
133
|
+
|
|
134
|
+
test("reference: nama file, folder induk, path lengkap & jumlah", () => {
|
|
135
|
+
const out = html(base, [{ path: "src/components/App.tsx" }, { path: "README.md" }]);
|
|
136
|
+
expect(out).toContain("App.tsx");
|
|
137
|
+
expect(out).toContain("src/components");
|
|
138
|
+
expect(out).toContain('title="src/components/App.tsx"');
|
|
139
|
+
expect(out).toContain(">2<");
|
|
140
|
+
// Tidak ada lagi penanda "diubah" — Reference hanya file yang dibaca.
|
|
141
|
+
expect(out).not.toContain("edited");
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("reference kosong -> keterangan, bukan blank", () => {
|
|
145
|
+
expect(html(base)).toContain("Files the agent reads will appear here");
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("reference banyak: hanya 8 pertama + tombol 'Show N more', total tetap di judul", () => {
|
|
149
|
+
const refs = Array.from({ length: 23 }, (_, i) => ({ path: `src/file-${i}.ts` }));
|
|
150
|
+
const out = html(base, refs);
|
|
151
|
+
// Total lengkap tetap tampil di ringkasan judul.
|
|
152
|
+
expect(out).toContain(">23<");
|
|
153
|
+
// Hanya REFERENCE_PREVIEW baris yang dirender.
|
|
154
|
+
for (let i = 0; i < REFERENCE_PREVIEW; i++) expect(out).toContain(`file-${i}.ts`);
|
|
155
|
+
expect(out).not.toContain(`file-${REFERENCE_PREVIEW}.ts`);
|
|
156
|
+
expect(out).not.toContain("file-22.ts");
|
|
157
|
+
expect(out).toContain(`Show ${23 - REFERENCE_PREVIEW} more`);
|
|
158
|
+
expect(out).toContain('aria-expanded="false"');
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("reference tepat 8 -> semua tampil, tanpa tombol 'Show more'", () => {
|
|
162
|
+
const refs = Array.from({ length: REFERENCE_PREVIEW }, (_, i) => ({ path: `f${i}.ts` }));
|
|
163
|
+
const out = html(base, refs);
|
|
164
|
+
expect(out).toContain(`f${REFERENCE_PREVIEW - 1}.ts`);
|
|
165
|
+
expect(out).not.toContain("Show ");
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("reference 9 -> tombol 'Show 1 more'", () => {
|
|
169
|
+
const refs = Array.from({ length: REFERENCE_PREVIEW + 1 }, (_, i) => ({ path: `f${i}.ts` }));
|
|
170
|
+
expect(html(base, refs)).toContain("Show 1 more");
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
// ---- Kartu ----
|
|
174
|
+
|
|
175
|
+
test("fetch pertama -> skeleton, bukan bagian kosong", () => {
|
|
176
|
+
const out = html(null, [], true);
|
|
177
|
+
expect(out).toContain("animate-pulse");
|
|
178
|
+
expect(out).not.toContain("No changes");
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("kartu punya penutupnya sendiri", () => {
|
|
182
|
+
const out = html(base);
|
|
183
|
+
expect(out).toContain('aria-label="Hide session context"');
|
|
184
|
+
expect(out).toContain('aria-label="Session context"');
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("tombol pembuka: hamburger, tanpa ikut merender isi kartu", () => {
|
|
188
|
+
const out = renderToStaticMarkup(<ContextPanelTrigger onOpen={() => {}} />);
|
|
189
|
+
expect(out).toContain('aria-label="Show session context"');
|
|
190
|
+
expect(out).toContain('aria-expanded="false"');
|
|
191
|
+
expect(out).not.toContain("Environment");
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
// ---- Mobile: bottom sheet ----
|
|
195
|
+
|
|
196
|
+
test("sheet tertutup: isi tidak dirender", () => {
|
|
197
|
+
const out = renderToStaticMarkup(
|
|
198
|
+
<ContextSheet
|
|
199
|
+
open={false}
|
|
200
|
+
onOpenChange={() => {}}
|
|
201
|
+
context={base}
|
|
202
|
+
references={[]}
|
|
203
|
+
loading={false}
|
|
204
|
+
/>,
|
|
205
|
+
);
|
|
206
|
+
expect(out).not.toContain("Environment");
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test("isi sheet identik dengan kartu (bagian yang sama)", () => {
|
|
210
|
+
// Radix Dialog merender ke portal `document.body`, yang tidak ada di SSR;
|
|
211
|
+
// jadi yang diuji di sini adalah KESAMAAN isi: kartu & sheet sama-sama
|
|
212
|
+
// memakai `ContextSections`, sehingga keempat bagian selalu identik.
|
|
213
|
+
const card = html({
|
|
214
|
+
...base,
|
|
215
|
+
mcp: [{ name: "firecrawl", status: "connected", error: null }],
|
|
216
|
+
});
|
|
217
|
+
for (const title of ["Environment", "MCP", "Task list", "Reference"]) {
|
|
218
|
+
expect(card).toContain(title);
|
|
219
|
+
}
|
|
220
|
+
});
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/** Smoke: MarkdownContent merender GFM & membungkus tabel dengan scroll. */
|
|
2
|
+
import { expect, test } from "bun:test";
|
|
3
|
+
import { renderToStaticMarkup } from "react-dom/server";
|
|
4
|
+
import { MarkdownContent } from "../MarkdownContent";
|
|
5
|
+
|
|
6
|
+
function html(md: string): string {
|
|
7
|
+
return renderToStaticMarkup(<MarkdownContent>{md}</MarkdownContent>);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
test("heading, bold, list, inline code", () => {
|
|
11
|
+
const out = html("## Judul\n\n**tebal** dan `kode`\n\n- satu\n- dua");
|
|
12
|
+
expect(out).toContain("<h2");
|
|
13
|
+
expect(out).toContain("<strong");
|
|
14
|
+
expect(out).toContain("<code");
|
|
15
|
+
expect(out).toContain("<ul");
|
|
16
|
+
expect(out).toContain("<li");
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("tabel GFM dibungkus wrapper overflow-x-auto", () => {
|
|
20
|
+
const out = html("| a | b |\n|---|---|\n| 1 | 2 |");
|
|
21
|
+
expect(out).toContain("<table");
|
|
22
|
+
expect(out).toContain("overflow-x-auto");
|
|
23
|
+
// Wrapper harus mendahului <table> (pembungkus, bukan di dalamnya).
|
|
24
|
+
expect(out.indexOf("overflow-x-auto")).toBeLessThan(out.indexOf("<table"));
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("blok kode dapat scroll horizontal sendiri", () => {
|
|
28
|
+
const out = html("```ts\nconst x = 1;\n```");
|
|
29
|
+
expect(out).toContain("<pre");
|
|
30
|
+
expect(out).toContain("overflow-x-auto");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("blok kode: label bahasa, tombol salin, isi tampil langsung tanpa newline ekstra", () => {
|
|
34
|
+
const out = html("```ts\nconst x = 1;\n```");
|
|
35
|
+
// Render awal (sebelum highlight async) = teks polos, jadi kode langsung terbaca.
|
|
36
|
+
expect(out).toContain("const x = 1;</code>");
|
|
37
|
+
expect(out).toContain(">ts</span>");
|
|
38
|
+
expect(out).toContain('aria-label="Copy code"');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("kode di dalam blok tetap di-escape (bukan HTML)", () => {
|
|
42
|
+
const out = html('```html\n<script>alert("x")</script>\n```');
|
|
43
|
+
expect(out).not.toContain("<script>");
|
|
44
|
+
expect(out).toContain("<script>");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("HTML mentah TIDAK dirender sebagai DOM (anti-XSS)", () => {
|
|
48
|
+
const out = html('Halo <img src=x onerror="alert(1)"> <script>alert(2)</script>');
|
|
49
|
+
// Markup di-escape jadi teks: tidak ada tag sungguhan yang terbentuk.
|
|
50
|
+
expect(out).not.toContain("<img");
|
|
51
|
+
expect(out).not.toContain("<script");
|
|
52
|
+
expect(out).toContain("<script>");
|
|
53
|
+
// Atribut handler ikut ter-escape, jadi tidak pernah jadi atribut DOM.
|
|
54
|
+
expect(out).not.toContain('onerror="alert(1)"');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("prop internal `node` react-markdown tidak bocor jadi atribut DOM", () => {
|
|
58
|
+
const out = html("## Judul\n\nteks `kode`\n\n| a |\n|---|\n| 1 |");
|
|
59
|
+
expect(out).not.toContain("node=");
|
|
60
|
+
expect(out).not.toContain("[object Object]");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("sintaks gambar markdown tidak memicu request keluar", () => {
|
|
64
|
+
const out = html("");
|
|
65
|
+
expect(out).not.toContain("<img");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("link buka tab baru dengan rel aman", () => {
|
|
69
|
+
const out = html("[situs](https://contoh.test)");
|
|
70
|
+
expect(out).toContain('target="_blank"');
|
|
71
|
+
expect(out).toContain("noopener");
|
|
72
|
+
expect(out).toContain("noreferrer");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("strikethrough & task list (remark-gfm aktif)", () => {
|
|
76
|
+
expect(html("~~coret~~")).toContain("<del");
|
|
77
|
+
const tasks = html("- [x] selesai\n- [ ] belum");
|
|
78
|
+
expect(tasks).toContain('type="checkbox"');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("markdown setengah jadi (streaming) tidak error", () => {
|
|
82
|
+
expect(() => html("```ts\nconst a = 1;")).not.toThrow();
|
|
83
|
+
expect(() => html("| a | b |\n|---|")).not.toThrow();
|
|
84
|
+
expect(() => html("**belum ditutup")).not.toThrow();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("teks kosong aman", () => {
|
|
88
|
+
expect(() => html("")).not.toThrow();
|
|
89
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/** Smoke: rail penanda pesan user (render, ambang minimum, aksesibilitas). */
|
|
2
|
+
import { expect, test } from "bun:test";
|
|
3
|
+
import { renderToStaticMarkup } from "react-dom/server";
|
|
4
|
+
import { TooltipProvider } from "@/components/ui/tooltip";
|
|
5
|
+
import { MessageRail, type UserMessageMark } from "../MessageRail";
|
|
6
|
+
|
|
7
|
+
function html(marks: UserMessageMark[], activeId: string | null = null): string {
|
|
8
|
+
return renderToStaticMarkup(
|
|
9
|
+
<TooltipProvider>
|
|
10
|
+
<MessageRail marks={marks} activeId={activeId} onJump={() => {}} />
|
|
11
|
+
</TooltipProvider>,
|
|
12
|
+
);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
test("satu pesan user -> rail tidak dirender (tidak berguna)", () => {
|
|
16
|
+
expect(html([{ id: "a", preview: "halo" }])).toBe("");
|
|
17
|
+
expect(html([])).toBe("");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("dua pesan user -> satu penanda per pesan + label lompat", () => {
|
|
21
|
+
const out = html([
|
|
22
|
+
{ id: "a", preview: "pesan pertama" },
|
|
23
|
+
{ id: "b", preview: "pesan kedua" },
|
|
24
|
+
]);
|
|
25
|
+
expect(out.match(/<button/g)).toHaveLength(2);
|
|
26
|
+
expect(out).toContain("Jump to your message 1: pesan pertama");
|
|
27
|
+
expect(out).toContain("Jump to your message 2: pesan kedua");
|
|
28
|
+
expect(out).toContain('aria-label="Your messages"');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("penanda aktif ditandai aria-current", () => {
|
|
32
|
+
const out = html(
|
|
33
|
+
[
|
|
34
|
+
{ id: "a", preview: "satu" },
|
|
35
|
+
{ id: "b", preview: "dua" },
|
|
36
|
+
],
|
|
37
|
+
"b",
|
|
38
|
+
);
|
|
39
|
+
expect(out.match(/aria-current="true"/g)).toHaveLength(1);
|
|
40
|
+
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Kartu izin tool — render statis (Requirement 8.4, 8.5): struktur, teks,
|
|
3
|
+
* dan aksesibilitas. Respon tiap tombol & pintasan keyboard diverifikasi di
|
|
4
|
+
* browser sungguhan (bukan di sini — tidak ada DOM interaktif di bun test).
|
|
5
|
+
*/
|
|
6
|
+
import { describe, expect, test } from "bun:test";
|
|
7
|
+
import { renderToStaticMarkup } from "react-dom/server";
|
|
8
|
+
import type { InteractivePrompt } from "@/types";
|
|
9
|
+
import { PromptCard } from "../PromptCard";
|
|
10
|
+
|
|
11
|
+
function permission(title: string | null, id = "per_1"): InteractivePrompt {
|
|
12
|
+
return {
|
|
13
|
+
id,
|
|
14
|
+
sessionId: "s1",
|
|
15
|
+
kind: "permission",
|
|
16
|
+
type: "confirmation",
|
|
17
|
+
title,
|
|
18
|
+
options: null,
|
|
19
|
+
status: "pending",
|
|
20
|
+
createdAt: 0,
|
|
21
|
+
resolvedAt: null,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function html(prompts: InteractivePrompt[]): string {
|
|
26
|
+
return renderToStaticMarkup(<PromptCard prompts={prompts} onResolve={() => {}} />);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe("PermissionCard — tampilan", () => {
|
|
30
|
+
test("judul = kalimat ramah; nama permission internal jadi label kecil", () => {
|
|
31
|
+
const out = html([permission("external_directory — `/home/u/.config/opencode/*`")]);
|
|
32
|
+
expect(out).toContain("Access a folder outside the project");
|
|
33
|
+
expect(out).toContain(">external_directory</code>");
|
|
34
|
+
// Teks generik lama sudah tidak dipakai.
|
|
35
|
+
expect(out).not.toContain("Tool permission");
|
|
36
|
+
expect(out).not.toContain("CLI_Agent");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("target tampil tanpa backtick, satu per baris", () => {
|
|
40
|
+
const out = html([permission("edit — `src/a.ts` — `src/b.ts`")]);
|
|
41
|
+
expect(out).toContain(">src/a.ts</li>");
|
|
42
|
+
expect(out).toContain(">src/b.ts</li>");
|
|
43
|
+
expect(out).not.toContain("`src/a.ts`");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("risiko tinggi diberi label 'Review carefully'", () => {
|
|
47
|
+
expect(html([permission("bash — `rm -rf build`")])).toContain("Review carefully");
|
|
48
|
+
expect(html([permission("read — `README.md`")])).toContain("Read-only");
|
|
49
|
+
expect(html([permission("edit — `a.ts`")])).not.toContain("Review carefully");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("aksi: Always allow, Deny, Allow once — tanpa Cancel duplikat", () => {
|
|
53
|
+
const out = html([permission("bash — `ls`")]);
|
|
54
|
+
expect(out).toContain("Always allow");
|
|
55
|
+
expect(out).toContain("Deny");
|
|
56
|
+
expect(out).toContain("Allow once");
|
|
57
|
+
// Cancel identik dengan Deny (Req 8.5) — tidak lagi ditampilkan terpisah.
|
|
58
|
+
expect(out).not.toMatch(/>Cancel</);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("aksesibilitas: alertdialog berlabel + pintasan diumumkan", () => {
|
|
62
|
+
const out = html([permission("bash — `ls`", "per_x")]);
|
|
63
|
+
expect(out).toContain('role="alertdialog"');
|
|
64
|
+
expect(out).toContain('aria-labelledby="perm-per_x-title"');
|
|
65
|
+
expect(out).toContain('aria-keyshortcuts="Enter"');
|
|
66
|
+
expect(out).toContain('aria-keyshortcuts="Escape"');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("permission identik bertumpuk -> badge ×N", () => {
|
|
70
|
+
const out = html([permission("bash — `ls`", "per_1"), permission("bash — `ls`", "per_2")]);
|
|
71
|
+
expect(out).toContain("×2");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("judul fallback / kosong -> label umum, tanpa daftar target", () => {
|
|
75
|
+
const out = html([permission(null)]);
|
|
76
|
+
expect(out).toContain("Use a tool");
|
|
77
|
+
expect(out).not.toContain("<ul");
|
|
78
|
+
});
|
|
79
|
+
});
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit test `PromptCard.tsx` (task 24.6).
|
|
3
|
+
*
|
|
4
|
+
* Logika aksi diekstrak sebagai fungsi murni `getPromptActions` sehingga dapat
|
|
5
|
+
* diuji tanpa DOM (pola yang sama dengan `useTheme.test.ts`).
|
|
6
|
+
*
|
|
7
|
+
* - Requirement 8.4: tombol Approve, Deny, dan Cancel tampil untuk tipe
|
|
8
|
+
* "confirmation".
|
|
9
|
+
* - Requirement 8.5: klik "Cancel" memakai handler/respon yang sama dengan
|
|
10
|
+
* "Deny" (diteruskan mengikuti mekanisme penyelesaian Requirement 6).
|
|
11
|
+
*/
|
|
12
|
+
import { describe, expect, test } from "bun:test";
|
|
13
|
+
import { getPromptActions, getPromptQuestions, groupPrompts } from "@/lib/prompts";
|
|
14
|
+
import type { InteractivePrompt, PromptResponse } from "@/types";
|
|
15
|
+
|
|
16
|
+
function makePrompt(overrides: Partial<InteractivePrompt> = {}): InteractivePrompt {
|
|
17
|
+
return {
|
|
18
|
+
id: "p1",
|
|
19
|
+
sessionId: "s1",
|
|
20
|
+
kind: "permission",
|
|
21
|
+
type: "confirmation",
|
|
22
|
+
title: "bash:ls",
|
|
23
|
+
options: null,
|
|
24
|
+
status: "pending",
|
|
25
|
+
createdAt: 0,
|
|
26
|
+
resolvedAt: null,
|
|
27
|
+
...overrides,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe("prompt-card — aksi Interactive_Prompt", () => {
|
|
32
|
+
test("tipe confirmation menampilkan tombol Approve, Always allow, Deny, dan Cancel (Req 8.4)", () => {
|
|
33
|
+
const actions = getPromptActions(makePrompt());
|
|
34
|
+
expect(actions.map((a) => a.label)).toEqual(["Approve", "Always allow", "Deny", "Cancel"]);
|
|
35
|
+
expect(actions.map((a) => a.key)).toEqual(["approve", "always", "deny", "cancel"]);
|
|
36
|
+
expect(actions.find((a) => a.key === "always")?.response).toBe("always");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("Cancel memakai respon yang sama dengan Deny (Req 8.5)", () => {
|
|
40
|
+
const actions = getPromptActions(makePrompt());
|
|
41
|
+
const deny = actions.find((a) => a.key === "deny");
|
|
42
|
+
const cancel = actions.find((a) => a.key === "cancel");
|
|
43
|
+
expect(deny).toBeDefined();
|
|
44
|
+
expect(cancel).toBeDefined();
|
|
45
|
+
// Klik Cancel memperlakukan sebagai respon Deny (bukan "cancel").
|
|
46
|
+
expect(deny?.response).toBe("deny");
|
|
47
|
+
expect(cancel?.response).toBe(deny?.response);
|
|
48
|
+
expect(cancel?.response).toBe("deny");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("tipe menu menghasilkan satu aksi per opsi dengan respon { option } (Req 6.3)", () => {
|
|
52
|
+
const prompt = makePrompt({
|
|
53
|
+
type: "menu",
|
|
54
|
+
options: [
|
|
55
|
+
{ label: "Lanjutkan", description: null },
|
|
56
|
+
{ label: "Batal", description: "hentikan turn" },
|
|
57
|
+
],
|
|
58
|
+
});
|
|
59
|
+
const actions = getPromptActions(prompt);
|
|
60
|
+
expect(actions).toHaveLength(2);
|
|
61
|
+
expect(actions[0]?.label).toBe("Lanjutkan");
|
|
62
|
+
expect(actions[0]?.response).toEqual({ option: "Lanjutkan" });
|
|
63
|
+
expect(actions[1]?.label).toBe("Batal");
|
|
64
|
+
expect(actions[1]?.response).toEqual({ option: "Batal" });
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("menu multi-select tetap menghasilkan aksi per opsi (UI yang mengumpulkan jawaban)", () => {
|
|
68
|
+
const prompt = makePrompt({
|
|
69
|
+
kind: "question",
|
|
70
|
+
type: "menu",
|
|
71
|
+
multiple: true,
|
|
72
|
+
options: [
|
|
73
|
+
{ label: "A", description: null },
|
|
74
|
+
{ label: "B", description: null },
|
|
75
|
+
],
|
|
76
|
+
});
|
|
77
|
+
const actions = getPromptActions(prompt);
|
|
78
|
+
expect(actions.map((a) => a.response)).toEqual([{ option: "A" }, { option: "B" }]);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("menu tanpa opsi menghasilkan daftar aksi kosong", () => {
|
|
82
|
+
expect(getPromptActions(makePrompt({ type: "menu", options: [] }))).toHaveLength(0);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("question custom: aksi opsi tetap sama — jawaban bebas dikirim sebagai { option }", () => {
|
|
86
|
+
const prompt = makePrompt({
|
|
87
|
+
kind: "question",
|
|
88
|
+
type: "menu",
|
|
89
|
+
custom: true,
|
|
90
|
+
options: [{ label: "Fix bug", description: null }],
|
|
91
|
+
});
|
|
92
|
+
const actions = getPromptActions(prompt);
|
|
93
|
+
expect(actions.map((a) => a.response)).toEqual([{ option: "Fix bug" }]);
|
|
94
|
+
// Bentuk respon jawaban kustom identik dengan opsi terdaftar.
|
|
95
|
+
const custom: PromptResponse = { option: "jawaban sendiri" };
|
|
96
|
+
expect(custom).toEqual({ option: "jawaban sendiri" });
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe("getPromptQuestions — normalisasi daftar pertanyaan kartu", () => {
|
|
101
|
+
test("pertanyaan tunggal diwrap dari field legacy", () => {
|
|
102
|
+
const questions = getPromptQuestions(
|
|
103
|
+
makePrompt({
|
|
104
|
+
kind: "question",
|
|
105
|
+
type: "menu",
|
|
106
|
+
title: "Lanjut?",
|
|
107
|
+
options: [{ label: "Ya", description: null }],
|
|
108
|
+
}),
|
|
109
|
+
);
|
|
110
|
+
expect(questions).toHaveLength(1);
|
|
111
|
+
expect(questions[0]?.question).toBe("Lanjut?");
|
|
112
|
+
expect(questions[0]?.options?.map((o) => o.label)).toEqual(["Ya"]);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("multi-question memakai field questions apa adanya", () => {
|
|
116
|
+
const questions = getPromptQuestions(
|
|
117
|
+
makePrompt({
|
|
118
|
+
kind: "question",
|
|
119
|
+
type: "menu",
|
|
120
|
+
questions: [
|
|
121
|
+
{ question: "A?", options: [{ label: "a1", description: null }] },
|
|
122
|
+
{ question: "B?", options: [{ label: "b1", description: null }], multiple: true },
|
|
123
|
+
],
|
|
124
|
+
}),
|
|
125
|
+
);
|
|
126
|
+
expect(questions).toHaveLength(2);
|
|
127
|
+
expect(questions[1]?.multiple).toBe(true);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("permission tidak menghasilkan pertanyaan", () => {
|
|
131
|
+
expect(getPromptQuestions(makePrompt())).toEqual([]);
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
describe("groupPrompts — pengelompokan permission identik (bug kartu nyepam)", () => {
|
|
136
|
+
test("prompt identik (kind+title) masuk satu grup", () => {
|
|
137
|
+
const a = makePrompt({ id: "per_a", title: "external_directory — `D:\\p\\**`" });
|
|
138
|
+
const b = makePrompt({ id: "per_b", title: "external_directory — `D:\\p\\**`", createdAt: 2 });
|
|
139
|
+
const c = makePrompt({ id: "per_c", title: "bash — `ls`", createdAt: 3 });
|
|
140
|
+
const groups = groupPrompts([a, b, c]);
|
|
141
|
+
expect(groups).toHaveLength(2);
|
|
142
|
+
expect(groups[0]?.map((p) => p.id)).toEqual(["per_a", "per_b"]);
|
|
143
|
+
expect(groups[1]?.map((p) => p.id)).toEqual(["per_c"]);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("question tidak tercampur permission walau judul sama", () => {
|
|
147
|
+
const q = makePrompt({ id: "que_1", kind: "question", type: "menu", title: "lanjut?" });
|
|
148
|
+
const p = makePrompt({ id: "per_1", title: "lanjut?" });
|
|
149
|
+
const groups = groupPrompts([q, p]);
|
|
150
|
+
expect(groups).toHaveLength(2);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("title null dan urutan kemunculan dipertahankan", () => {
|
|
154
|
+
const groups = groupPrompts([
|
|
155
|
+
makePrompt({ id: "p2", title: null, createdAt: 2 }),
|
|
156
|
+
makePrompt({ id: "p1", title: "bash — `ls`", createdAt: 1 }),
|
|
157
|
+
makePrompt({ id: "p3", title: null, createdAt: 3 }),
|
|
158
|
+
]);
|
|
159
|
+
expect(groups.map((g) => g[0]?.id)).toEqual(["p2", "p1"]);
|
|
160
|
+
expect(groups[0]).toHaveLength(2);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("daftar kosong -> tanpa grup", () => {
|
|
164
|
+
expect(groupPrompts([])).toHaveLength(0);
|
|
165
|
+
});
|
|
166
|
+
});
|