total-recall-brain 3.28.2 → 3.30.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 (132) hide show
  1. package/bin/total-recall.mjs +24 -1
  2. package/docs/ARCHITECTURE.md +26 -18
  3. package/extension/background.js +259 -84
  4. package/extension/content-script.css +7 -13
  5. package/extension/content-script.js +252 -477
  6. package/extension/icons/icon-128.png +0 -0
  7. package/extension/icons/icon-16.png +0 -0
  8. package/extension/icons/icon-32.png +0 -0
  9. package/extension/icons/icon-48.png +0 -0
  10. package/extension/icons/mark.svg +6 -0
  11. package/extension/lib/brain-client.js +160 -73
  12. package/extension/lib/tokens.css +187 -0
  13. package/extension/lib/ui-helpers.js +229 -0
  14. package/extension/lib/ui-helpers.spec.mjs +94 -0
  15. package/extension/manifest.json +20 -5
  16. package/extension/options/options.css +64 -218
  17. package/extension/options/options.html +91 -43
  18. package/extension/options/options.js +208 -85
  19. package/extension/sidepanel/sidepanel.css +268 -970
  20. package/extension/sidepanel/sidepanel.html +200 -166
  21. package/extension/sidepanel/sidepanel.js +788 -942
  22. package/frontend/dist/assets/index-DGMNZi_1.js +5742 -0
  23. package/frontend/dist/index.html +1 -1
  24. package/frontend/src/api/plugins.ts +124 -92
  25. package/frontend/src/components/ResearchAgendaTab.spec.tsx +11 -0
  26. package/frontend/src/components/ResearchAgendaTab.tsx +19 -14
  27. package/frontend/src/pages/PluginsPage.spec.tsx +87 -16
  28. package/frontend/src/pages/PluginsPage.tsx +454 -1218
  29. package/package.json +2 -1
  30. package/plugins/code-quality/README.md +25 -0
  31. package/plugins/code-quality/cli.mjs +25 -0
  32. package/plugins/code-quality/plugin.json +17 -0
  33. package/plugins/git-sentinel/README.md +27 -0
  34. package/{scaffold/.agent/plugins → plugins}/git-sentinel/cli.mjs +8 -5
  35. package/plugins/git-sentinel/plugin.json +18 -0
  36. package/plugins/system-monitor/README.md +26 -0
  37. package/{scaffold/.agent/plugins → plugins}/system-monitor/cli.mjs +5 -4
  38. package/{scaffold/.agent/plugins → plugins}/system-monitor/generator.mjs +4 -5
  39. package/plugins/system-monitor/plugin.json +29 -0
  40. package/scaffold/.agent/skills/meta-harness/SKILL.md +4 -4
  41. package/scaffold/.agent/skills/plugins/SKILL.md +53 -0
  42. package/scaffold/.agent/skills/total-recall/scripts/brain-state.json +1 -0
  43. package/src/cli/agent.mjs +1 -1
  44. package/src/cli/app/index.mjs +493 -0
  45. package/src/cli/brain.mjs +33 -3
  46. package/src/cli/command.mjs +80 -41
  47. package/src/cli/harness.mjs +1 -1
  48. package/src/cli/init.mjs +3 -1
  49. package/src/cli/mesh.mjs +118 -12
  50. package/src/cli/plugin/create.mjs +144 -25
  51. package/src/cli/plugin/index.mjs +83 -53
  52. package/src/cli/plugin/install.mjs +10 -127
  53. package/src/cli/plugin/list.mjs +44 -44
  54. package/src/cli/plugin/remove.mjs +23 -30
  55. package/src/cli/plugin/scaffold-cmd.mjs +30 -0
  56. package/src/cli/plugin/search.mjs +108 -23
  57. package/src/cli/rebuild.mjs +37 -1
  58. package/src/cli/recall.mjs +17 -2
  59. package/src/cli/research.mjs +4 -6
  60. package/src/cli/share.mjs +2 -0
  61. package/src/core/app-deploy/apply.mjs +386 -0
  62. package/src/core/app-deploy/design-tokens.mjs +217 -0
  63. package/src/core/app-deploy/plan.mjs +249 -0
  64. package/src/core/app-deploy/resolve.mjs +295 -0
  65. package/src/core/app-deploy/source.mjs +337 -0
  66. package/src/core/app-deploy/standalone.mjs +248 -0
  67. package/src/core/app-deploy/upgrade.mjs +256 -0
  68. package/src/core/app-deploy/verify.mjs +213 -0
  69. package/src/core/bound-hosts.mjs +1 -1
  70. package/src/core/brain-registry.mjs +146 -0
  71. package/src/core/brain-state.json +1 -0
  72. package/src/core/daemon-loop.mjs +30 -26
  73. package/src/core/dream.mjs +5 -17
  74. package/src/core/fact-seeker.mjs +53 -368
  75. package/src/core/lan-discovery.mjs +3 -3
  76. package/src/core/mesh-access-sync.mjs +134 -0
  77. package/src/core/mesh-access.mjs +18 -3
  78. package/src/core/mesh-discover.mjs +173 -0
  79. package/src/core/mesh.mjs +131 -32
  80. package/src/core/optimizer.mjs +4 -41
  81. package/src/core/plugin-bundle.mjs +167 -0
  82. package/src/core/plugin-context.mjs +8 -13
  83. package/src/core/plugin-loader.mjs +272 -68
  84. package/src/core/plugin-peers.mjs +163 -0
  85. package/src/core/plugin-public.mjs +85 -0
  86. package/src/core/plugin-runner-child.mjs +24 -0
  87. package/src/core/plugin-runner.mjs +102 -0
  88. package/src/core/plugin-store.mjs +384 -0
  89. package/src/core/plugin-tasks.mjs +212 -0
  90. package/src/core/post-mortem.mjs +40 -9
  91. package/src/core/research-gate.mjs +182 -0
  92. package/src/core/research-queue.mjs +47 -7
  93. package/src/core/research-surface.mjs +211 -0
  94. package/src/core/research.mjs +25 -32
  95. package/src/core/scheduler.mjs +67 -153
  96. package/src/core/schema.mjs +129 -0
  97. package/src/core/search.mjs +12 -1
  98. package/src/core/secret-integration-research.mjs +12 -8
  99. package/src/core/secrets-store.mjs +32 -2
  100. package/src/core/session-watcher.mjs +6 -0
  101. package/src/core/surface.mjs +64 -3
  102. package/src/core/task-executors.mjs +8 -45
  103. package/src/core/vault-cache.mjs +70 -4
  104. package/src/server/api.mjs +21 -0
  105. package/src/server/rest.mjs +2 -0
  106. package/src/server/route-manifest.json +21 -5
  107. package/src/server/routes/collab.mjs +38 -11
  108. package/src/server/routes/extension.mjs +3 -2
  109. package/src/server/routes/memory.mjs +24 -2
  110. package/src/server/routes/mesh.mjs +7 -7
  111. package/src/server/routes/plugins-mesh.mjs +49 -0
  112. package/src/server/routes/plugins.mjs +91 -426
  113. package/src/server/routes/research.mjs +5 -2
  114. package/src/server/routes/share.mjs +2 -0
  115. package/src/server/tools.mjs +29 -2
  116. package/extension/icons/README.md +0 -27
  117. package/extension/lib/preconfigured.js +0 -5
  118. package/extension/popup/popup.css +0 -227
  119. package/extension/popup/popup.html +0 -50
  120. package/extension/popup/popup.js +0 -175
  121. package/frontend/dist/assets/index-U7o-qU99.js +0 -5742
  122. package/scaffold/.agent/plugins/.gitkeep +0 -0
  123. package/scaffold/.agent/plugins/code-quality/README.md +0 -16
  124. package/scaffold/.agent/plugins/code-quality/cli.mjs +0 -15
  125. package/scaffold/.agent/plugins/code-quality/plugin.json +0 -34
  126. package/scaffold/.agent/plugins/git-sentinel/README.md +0 -16
  127. package/scaffold/.agent/plugins/git-sentinel/plugin.json +0 -34
  128. package/scaffold/.agent/plugins/meta-harness/README.md +0 -16
  129. package/scaffold/.agent/plugins/meta-harness/cli.mjs +0 -34
  130. package/scaffold/.agent/plugins/meta-harness/plugin.json +0 -34
  131. package/scaffold/.agent/plugins/system-monitor/README.md +0 -16
  132. package/scaffold/.agent/plugins/system-monitor/plugin.json +0 -38
@@ -17,7 +17,7 @@
17
17
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
18
18
  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
19
19
  <title>Total Recall — Portable Memory</title>
20
- <script type="module" crossorigin src="/assets/index-U7o-qU99.js"></script>
20
+ <script type="module" crossorigin src="/assets/index-DGMNZi_1.js"></script>
21
21
  <link rel="stylesheet" crossorigin href="/assets/index-C7_Xpk27.css">
22
22
  </head>
23
23
  <body>
@@ -1,61 +1,130 @@
1
1
  // ─── Plugins domain ───────────────────────────────────────────────────────────
2
+ //
3
+ // Every field here is something the node can verify: the manifest, where the
4
+ // plugin came from, its content hash, whether it is shared, and — for peers —
5
+ // whether that peer answered. There are no ratings, reviews or install counts.
2
6
 
3
7
  import { apiFetch, API_BASE } from "./_base"
4
8
 
9
+ export type PluginSourceKind = "bundled" | "public" | "peer" | "git" | "local" | "link"
10
+
11
+ export interface PluginSource {
12
+ kind: PluginSourceKind
13
+ ref: string
14
+ peer_hostname?: string | null
15
+ }
16
+
17
+ export interface PluginTask {
18
+ intent: string
19
+ schedule: string
20
+ command: string
21
+ last_run: string | null
22
+ }
23
+
5
24
  export interface PluginInfo {
6
25
  id: string
7
26
  name: string
8
27
  version: string
9
28
  description: string
29
+ author: string | null
30
+ license: string | null
31
+ homepage: string | null
32
+ use_cases: string[]
10
33
  valid: boolean
11
34
  errors: string[]
35
+ scope: "project" | "global"
36
+ linked: boolean
12
37
  dir: string
13
- rating: number
14
- reviewCount: number
15
- installCount: string
16
- userRating?: number | null
17
- userReview?: string | null
18
- verified?: boolean
19
- categories: Array<{ name: string; description?: string }>
20
- tasks: Array<{ intent: string; schedule: string }>
21
- openwiki_hubs: Array<{ title: string; path: string }>
22
- tools: Array<{ name: string; description?: string }>
23
- cli?: { command: string; handler: string } | null
38
+ shared: boolean
39
+ mesh_shared: boolean
40
+ share_url: string | null
41
+ source: PluginSource
42
+ installed_at: string | null
43
+ sha256: string | null
44
+ installed_sha256: string | null
45
+ modified_since_install: boolean
46
+ file_count: number | null
47
+ size_bytes: number | null
48
+ categories: Array<{ name: string; description?: string; node_type?: string }>
49
+ tasks: PluginTask[]
50
+ openwiki_hubs: Array<{ title?: string; path: string }>
51
+ cli: { command: string | null; subcommands: Array<{ name: string; description?: string }> } | null
52
+ has_generator: boolean
24
53
  manifest?: Record<string, unknown>
25
54
  }
26
55
 
27
- export interface CatalogPlugin {
56
+ export interface BundledPlugin {
57
+ id: string
58
+ name: string
59
+ version: string
60
+ description: string
61
+ use_cases: string[]
62
+ categories: Array<{ name: string; description?: string }>
63
+ tasks: Array<{ intent: string; schedule: string; command: string }>
64
+ cli: { command: string; subcommands: Array<{ name: string; description?: string }> } | null
65
+ installed: boolean
66
+ }
67
+
68
+ export interface PeerPlugin {
28
69
  id: string
29
70
  name: string
30
71
  version: string
31
72
  description: string
32
- tags: string[]
33
- author: string
34
- sourceUrl: string
35
- rating: number
36
- reviewCount: number
37
- installCount: string
38
- userRating?: number | null
39
- verified?: boolean
40
- isInstalled: boolean
73
+ use_cases: string[]
74
+ sha256: string
75
+ file_count: number
76
+ size_bytes: number
77
+ installed: boolean
78
+ same_as_installed: boolean
41
79
  }
42
80
 
43
- export interface PluginsResponse {
44
- success: boolean
45
- count: number
46
- plugins: PluginInfo[]
81
+ export type PeerStatus = "ok" | "offline" | "unreachable" | "not_configured" | "unsupported" | "error"
82
+
83
+ export interface PeerNode {
84
+ hostname: string
85
+ ip: string
86
+ online: boolean
87
+ os: string | null
88
+ status: PeerStatus
89
+ error?: string
90
+ plugins: PeerPlugin[]
91
+ }
92
+
93
+ export interface PeersResponse {
94
+ mesh: { available: boolean; configured: boolean }
95
+ peers: PeerNode[]
47
96
  }
48
97
 
49
- export interface CatalogResponse {
50
- success: boolean
51
- catalog: CatalogPlugin[]
98
+ type Result = { success: boolean; message?: string; error?: string; plugin?: PluginInfo }
99
+
100
+ async function readJson(res: Response): Promise<any> {
101
+ try {
102
+ return await res.json()
103
+ } catch {
104
+ return {}
105
+ }
106
+ }
107
+
108
+ async function post(url: string, body: unknown, method = "POST"): Promise<Result & Record<string, any>> {
109
+ try {
110
+ const res = await apiFetch(url, {
111
+ method,
112
+ headers: { "Content-Type": "application/json" },
113
+ body: body === undefined ? undefined : JSON.stringify(body)
114
+ })
115
+ const data = await readJson(res)
116
+ if (!res.ok) return { success: false, error: data.error || data.message || `Request failed (${res.status})` }
117
+ return { success: true, ...data }
118
+ } catch (err: any) {
119
+ return { success: false, error: err?.message || "Network error" }
120
+ }
52
121
  }
53
122
 
54
123
  export async function fetchPlugins(): Promise<PluginInfo[]> {
55
124
  try {
56
125
  const res = await apiFetch(`${API_BASE}/api/plugins`)
57
126
  if (!res.ok) return []
58
- const data: PluginsResponse = await res.json()
127
+ const data = await readJson(res)
59
128
  return Array.isArray(data.plugins) ? data.plugins : []
60
129
  } catch {
61
130
  return []
@@ -66,102 +135,65 @@ export async function fetchPlugin(id: string): Promise<PluginInfo | null> {
66
135
  try {
67
136
  const res = await apiFetch(`${API_BASE}/api/plugins/${encodeURIComponent(id)}`)
68
137
  if (!res.ok) return null
69
- const data = await res.json()
138
+ const data = await readJson(res)
70
139
  return data.plugin || null
71
140
  } catch {
72
141
  return null
73
142
  }
74
143
  }
75
144
 
76
- export async function fetchPluginCatalog(): Promise<CatalogPlugin[]> {
145
+ export async function fetchBundledPlugins(): Promise<BundledPlugin[]> {
77
146
  try {
78
- const res = await apiFetch(`${API_BASE}/api/plugins/catalog`)
147
+ const res = await apiFetch(`${API_BASE}/api/plugins/available`)
79
148
  if (!res.ok) return []
80
- const data: CatalogResponse = await res.json()
81
- return Array.isArray(data.catalog) ? data.catalog : []
149
+ const data = await readJson(res)
150
+ return Array.isArray(data.plugins) ? data.plugins : []
82
151
  } catch {
83
152
  return []
84
153
  }
85
154
  }
86
155
 
87
- export async function installPlugin(options: { source: string; link?: boolean; global?: boolean }): Promise<{ success: boolean; message?: string; error?: string }> {
156
+ /** Asks every mesh peer live; `null` means this node could not ask at all. */
157
+ export async function fetchPeerPlugins(): Promise<PeersResponse | null> {
88
158
  try {
89
- const res = await apiFetch(`${API_BASE}/api/plugins/install`, {
90
- method: "POST",
91
- headers: { "Content-Type": "application/json" },
92
- body: JSON.stringify(options)
93
- })
94
- const data = await res.json()
95
- if (!res.ok) {
96
- return { success: false, error: data.error || data.message || "Failed to install plugin" }
97
- }
98
- return { success: true, message: data.message }
99
- } catch (err: any) {
100
- return { success: false, error: err.message || "Network error" }
159
+ const res = await apiFetch(`${API_BASE}/api/plugins/peers`)
160
+ if (!res.ok) return null
161
+ const data = await readJson(res)
162
+ return { mesh: data.mesh || { available: false, configured: false }, peers: Array.isArray(data.peers) ? data.peers : [] }
163
+ } catch {
164
+ return null
101
165
  }
102
166
  }
103
167
 
104
- export async function removePlugin(id: string, global = false): Promise<{ success: boolean; message?: string; error?: string }> {
105
- try {
106
- const res = await apiFetch(`${API_BASE}/api/plugins/${encodeURIComponent(id)}?global=${global ? "true" : "false"}`, {
107
- method: "DELETE"
108
- })
109
- const data = await res.json()
110
- if (!res.ok) {
111
- return { success: false, error: data.error || data.message || "Failed to remove plugin" }
112
- }
113
- return { success: true, message: data.message }
114
- } catch (err: any) {
115
- return { success: false, error: err.message || "Network error" }
116
- }
168
+ export function installPlugin(options: { source: string; link?: boolean; global?: boolean }): Promise<Result> {
169
+ return post(`${API_BASE}/api/plugins/install`, options)
117
170
  }
118
171
 
119
- export async function ratePlugin(id: string, rating: number, review?: string): Promise<{ success: boolean; message?: string; error?: string }> {
120
- try {
121
- const res = await apiFetch(`${API_BASE}/api/plugins/${encodeURIComponent(id)}/rate`, {
122
- method: "POST",
123
- headers: { "Content-Type": "application/json" },
124
- body: JSON.stringify({ rating, review })
125
- })
126
- const data = await res.json()
127
- if (!res.ok) {
128
- return { success: false, error: data.error || data.message || "Failed to submit rating" }
129
- }
130
- return { success: true, message: data.message }
131
- } catch (err: any) {
132
- return { success: false, error: err.message || "Network error" }
133
- }
172
+ export function removePlugin(id: string, global = false): Promise<Result> {
173
+ return post(`${API_BASE}/api/plugins/${encodeURIComponent(id)}${global ? "?global=true" : ""}`, undefined, "DELETE")
174
+ }
175
+
176
+ export function setPluginShared(id: string, shared: boolean): Promise<Result> {
177
+ return post(`${API_BASE}/api/plugins/${encodeURIComponent(id)}/share`, { shared })
134
178
  }
135
179
 
136
180
  export async function runPluginCommand(
137
181
  id: string,
138
182
  subcommand = "",
139
183
  args: string[] = []
140
- ): Promise<{ success: boolean; output?: string; error?: string }> {
141
- try {
142
- const res = await apiFetch(`${API_BASE}/api/plugins/${encodeURIComponent(id)}/run`, {
143
- method: "POST",
144
- headers: { "Content-Type": "application/json" },
145
- body: JSON.stringify({ subcommand, args })
146
- })
147
- const data = await res.json()
148
- if (!res.ok) {
149
- return { success: false, error: data.error || data.message || "Failed to execute plugin command" }
150
- }
151
- return { success: true, output: data.output || "" }
152
- } catch (err: any) {
153
- return { success: false, error: err.message || "Network error" }
154
- }
184
+ ): Promise<{ success: boolean; output?: string; error?: string; exitCode?: number | null; timedOut?: boolean }> {
185
+ const res = await post(`${API_BASE}/api/plugins/${encodeURIComponent(id)}/run`, { subcommand, args })
186
+ if (res.error) return { success: false, error: res.error }
187
+ return { success: !!res.ok, output: res.output || "", exitCode: res.exitCode, timedOut: res.timedOut }
155
188
  }
156
189
 
157
190
  export async function fetchPluginReadme(id: string): Promise<string> {
158
191
  try {
159
192
  const res = await apiFetch(`${API_BASE}/api/plugins/${encodeURIComponent(id)}/readme`)
160
193
  if (!res.ok) return ""
161
- const data = await res.json()
194
+ const data = await readJson(res)
162
195
  return data.readme || ""
163
196
  } catch {
164
197
  return ""
165
198
  }
166
199
  }
167
-
@@ -46,6 +46,17 @@ describe('ResearchAgendaTab', () => {
46
46
  expect(screen.getByPlaceholderText('e.g. Bun vs Node.js HTTP clustering performance benchmarks')).toBeInTheDocument();
47
47
  });
48
48
 
49
+ it('labels who wanted each research item', () => {
50
+ const now = new Date().toISOString();
51
+ const items = [
52
+ { id: 'u', topic: 'Asked', status: 'pending' as const, priority: 'high' as const, notes: null, created_at: now, updated_at: now, origin: 'user' as const, requested_via: 'chat' },
53
+ { id: 'a', topic: 'Auto', status: 'pending' as const, priority: 'medium' as const, notes: null, created_at: now, updated_at: now, origin: 'autonomous' as const, project: 'total-recall', rationale: 'agent guessed' },
54
+ ];
55
+ render(<ResearchAgendaTab {...defaultProps} researchItems={items} />);
56
+ expect(screen.getByText('You asked · chat')).toBeInTheDocument();
57
+ expect(screen.getByText('Background research for total-recall')).toHaveAttribute('title', 'agent guessed');
58
+ });
59
+
49
60
  it('renders research items', () => {
50
61
  const items = [
51
62
  {
@@ -13,6 +13,11 @@ export interface ResearchItem {
13
13
  research_phase?: string
14
14
  created_at: string
15
15
  updated_at: string
16
+ /** 'user' = a human asked; 'autonomous' = background research for a project; 'legacy' = predates provenance. */
17
+ origin?: 'user' | 'autonomous' | 'legacy'
18
+ requested_via?: string | null
19
+ project?: string | null
20
+ rationale?: string | null
16
21
  }
17
22
 
18
23
  interface ResearchAgendaTabProps {
@@ -239,6 +244,16 @@ export default function ResearchAgendaTab(props: ResearchAgendaTabProps) {
239
244
  </td>
240
245
  <td style={{ padding: '14px 16px' }}>
241
246
  <div style={{ fontWeight: 600, color: 'var(--text-primary)' }}>{item.topic}</div>
247
+ {(item.origin === 'autonomous' || item.origin === 'user') && (
248
+ <div
249
+ style={{ fontSize: 11, color: 'var(--text-tertiary)', marginTop: 2 }}
250
+ title={item.origin === 'autonomous' ? item.rationale || undefined : undefined}
251
+ >
252
+ {item.origin === 'autonomous'
253
+ ? `Background research${item.project ? ` for ${item.project}` : ''}`
254
+ : `You asked${item.requested_via ? ` · ${item.requested_via}` : ''}`}
255
+ </div>
256
+ )}
242
257
  {item.notes && (
243
258
  <div style={{ fontSize: 12, color: 'var(--text-secondary)', marginTop: 4 }}>
244
259
  {item.notes}
@@ -312,25 +327,15 @@ export default function ResearchAgendaTab(props: ResearchAgendaTabProps) {
312
327
  icon: '✨',
313
328
  desc: 'Markdown polish & premium layout restructuring'
314
329
  },
315
- {
316
- title: 'Source Monitoring',
317
- phase: 'monitoring',
318
- icon: '📡',
319
- desc: 'Ongoing newsletter, RSS & release tracking'
320
- },
321
- {
322
- title: 'Tangent Spawning',
323
- phase: 'expansion',
324
- icon: '🚀',
325
- desc: 'Autonomously brainstorming & enqueuing tangents'
326
- }
327
330
  ].map((step, idx) => {
328
- const phases = ['acquisition', 'deliberation', 'improvement', 'monitoring', 'expansion']
331
+ // Research ends after improvement: it never re-monitors or
332
+ // spawns tangents (RESEARCH_SYSTEM2).
333
+ const phases = ['acquisition', 'deliberation', 'improvement']
329
334
  const activePhase = item.research_phase || 'acquisition'
330
335
  const activeIndex = phases.indexOf(activePhase)
331
336
 
332
337
  let state: 'completed' | 'active' | 'waiting' | 'failed' | 'upcoming' = 'upcoming'
333
- if (idx < activeIndex) {
338
+ if (item.status === 'done' || idx < activeIndex) {
334
339
  state = 'completed'
335
340
  } else if (idx === activeIndex) {
336
341
  if (item.status === 'in_progress') state = 'active'
@@ -1,25 +1,96 @@
1
- import { describe, it, expect, vi } from 'vitest';
2
- import { render, screen } from '@testing-library/react';
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { render, screen, fireEvent, waitFor } from '@testing-library/react';
3
3
  import { MemoryRouter } from 'react-router-dom';
4
4
  import PluginsPage from './PluginsPage';
5
5
 
6
- // Mock fetch globally
7
- const mockFetch = vi.fn().mockResolvedValue({
8
- ok: true,
9
- json: async () => ({ installed: [], catalog: [] })
6
+ const installed = [{
7
+ id: 'git-sentinel', name: 'Git Sentinel', version: '1.1.0', description: 'Repository state',
8
+ author: 'Total Recall', license: 'MIT', homepage: null, use_cases: ['software-development'],
9
+ valid: true, errors: [], scope: 'project', linked: false, dir: '/p/git-sentinel', shared: true,
10
+ source: { kind: 'peer', ref: 'peer:mac-mini/git-sentinel', peer_hostname: 'mac-mini' },
11
+ installed_at: '2026-09-22T12:00:00.000Z', sha256: 'a'.repeat(64), installed_sha256: 'a'.repeat(64),
12
+ modified_since_install: false, file_count: 3, size_bytes: 2048, categories: [], tasks: [],
13
+ openwiki_hubs: [], cli: { command: 'git-sentinel', subcommands: [{ name: 'audit' }] }, has_generator: false
14
+ }];
15
+
16
+ const bundled = [
17
+ { id: 'git-sentinel', name: 'Git Sentinel', version: '1.1.0', description: 'Repository state', use_cases: ['software-development'], categories: [], tasks: [], cli: null, installed: true },
18
+ { id: 'system-monitor', name: 'System Monitor', version: '1.1.0', description: 'Host resources', use_cases: ['operations'], categories: [], tasks: [{ intent: 'x', schedule: '*/15 * * * *', command: 'sample' }], cli: null, installed: false }
19
+ ];
20
+
21
+ const peers = {
22
+ mesh: { available: true, configured: true },
23
+ peers: [
24
+ { hostname: 'mac-mini', ip: '100.64.0.2', online: true, os: 'macOS', status: 'ok', plugins: [
25
+ { id: 'reading-list', name: 'Reading List', version: '0.2.0', description: 'Papers to read', use_cases: ['research'], sha256: 'b'.repeat(64), file_count: 4, size_bytes: 900, installed: false, same_as_installed: false }
26
+ ] },
27
+ { hostname: 'droplet', ip: '100.64.0.1', online: true, os: 'linux', status: 'unsupported', error: 'Peer does not serve plugins (older Total Recall version)', plugins: [] }
28
+ ]
29
+ };
30
+
31
+ const calls: Array<{ url: string; init?: RequestInit }> = [];
32
+
33
+ function respond(body: unknown, status = 200) {
34
+ return Promise.resolve({ ok: status < 400, status, json: async () => body } as Response);
35
+ }
36
+
37
+ beforeEach(() => {
38
+ calls.length = 0;
39
+ globalThis.fetch = vi.fn((url: RequestInfo | URL, init?: RequestInit) => {
40
+ const u = String(url);
41
+ calls.push({ url: u, init });
42
+ if (u.endsWith('/api/plugins/available')) return respond({ success: true, plugins: bundled });
43
+ if (u.endsWith('/api/plugins/peers')) return respond({ success: true, ...peers });
44
+ if (u.endsWith('/api/plugins/install')) return respond({ success: true, message: 'Installed System Monitor 1.1.0 (bundled)' });
45
+ if (u.includes('/readme')) return respond({ success: true, readme: '# Git Sentinel' });
46
+ if (u.endsWith('/api/plugins')) return respond({ success: true, count: 1, plugins: installed });
47
+ return respond({}, 404);
48
+ }) as unknown as typeof fetch;
10
49
  });
11
- globalThis.fetch = mockFetch as unknown as typeof fetch;
50
+
51
+ function renderPage() {
52
+ return render(<MemoryRouter><PluginsPage /></MemoryRouter>);
53
+ }
12
54
 
13
55
  describe('PluginsPage', () => {
14
- it('renders plugins page', () => {
15
- // PluginsPage reads useSearchParams(), so it must be rendered inside a
16
- // Router — without one react-router throws and nothing mounts.
17
- render(
18
- <MemoryRouter>
19
- <PluginsPage />
20
- </MemoryRouter>,
21
- );
22
- // getByText(/Plugin/i) matched both the heading and the install button.
56
+ it('renders installed plugins with provenance and no fabricated social proof', async () => {
57
+ const { container } = renderPage();
23
58
  expect(screen.getByRole('heading', { name: /Plugins/i })).toBeInTheDocument();
59
+ expect(await screen.findByText('from mac-mini')).toBeInTheDocument();
60
+ expect(screen.getByText('shared')).toBeInTheDocument();
61
+ expect(container.textContent).not.toMatch(/★|rating|reviews?\b|installs\b|downloads?|verified/i);
62
+ });
63
+
64
+ it('installs a bundled plugin', async () => {
65
+ renderPage();
66
+ await screen.findByText('from mac-mini');
67
+ fireEvent.click(screen.getByRole('tab', { name: /Bundled/ }));
68
+ fireEvent.click(await screen.findByRole('button', { name: 'Install' }));
69
+ await waitFor(() => expect(calls.some(c => c.url.endsWith('/api/plugins/install'))).toBe(true));
70
+ const body = JSON.parse(String(calls.find(c => c.url.endsWith('/api/plugins/install'))!.init!.body));
71
+ expect(body).toEqual({ source: 'system-monitor' });
72
+ });
73
+
74
+ it('shows what each mesh peer shares and reports peers it could not use', async () => {
75
+ const { container } = renderPage();
76
+ await screen.findByText('from mac-mini');
77
+ fireEvent.click(screen.getByRole('tab', { name: /On the mesh/ }));
78
+ expect(await screen.findByText('Reading List')).toBeInTheDocument();
79
+ expect(screen.getByText(/no plugin sharing \(older version\)/)).toBeInTheDocument();
80
+ fireEvent.click(screen.getByRole('button', { name: 'Install' }));
81
+ await waitFor(() => {
82
+ const req = calls.find(c => c.url.endsWith('/api/plugins/install'));
83
+ expect(JSON.parse(String(req!.init!.body))).toEqual({ source: 'peer:mac-mini/reading-list' });
84
+ });
85
+ expect(container.textContent).not.toMatch(/★|rating|downloads?|verified/i);
86
+ });
87
+
88
+ it('filters by use case', async () => {
89
+ renderPage();
90
+ await screen.findByText('from mac-mini');
91
+ fireEvent.click(screen.getByRole('tab', { name: /Bundled/ }));
92
+ fireEvent.change(screen.getByLabelText('Filter by use case'), { target: { value: 'operations' } });
93
+ expect(screen.getByText('System Monitor')).toBeInTheDocument();
94
+ expect(screen.queryByText('Repository state')).not.toBeInTheDocument();
24
95
  });
25
96
  });