@comfanion/workflow 4.38.3-dev.2 → 4.38.4-dev.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.
@@ -1,425 +0,0 @@
1
- import { describe, it, expect, beforeEach, afterEach } from "bun:test"
2
- import { join } from "path"
3
- import { writeFile } from "fs/promises"
4
- import { FileIndexerPlugin } from "../file-indexer"
5
- import {
6
- createMockCtx,
7
- createTempDir,
8
- cleanupTempDir,
9
- FIXTURE_CONFIG_YAML,
10
- } from "./helpers/mock-ctx"
11
- import path from "path"
12
-
13
- // =============================================================================
14
- // LOCAL REPLICAS of internal functions (not exported from plugin)
15
- // These mirror the source logic so we can unit-test the algorithms directly.
16
- // =============================================================================
17
-
18
- const DEFAULT_CONFIG = {
19
- enabled: true,
20
- auto_index: true,
21
- debounce_ms: 1000,
22
- indexes: {
23
- code: { enabled: true, extensions: ['.js', '.ts', '.jsx', '.tsx', '.py', '.go', '.rs', '.java', '.kt', '.swift', '.c', '.cpp', '.h', '.hpp', '.cs', '.rb', '.php', '.scala', '.clj'] },
24
- docs: { enabled: true, extensions: ['.md', '.mdx', '.txt', '.rst', '.adoc'] },
25
- config: { enabled: false, extensions: ['.yaml', '.yml', '.json', '.toml', '.ini', '.xml'] },
26
- },
27
- exclude: ['node_modules', 'vendor', 'dist', 'build', 'out', '__pycache__'],
28
- }
29
-
30
- interface VectorizerConfig {
31
- enabled: boolean
32
- auto_index: boolean
33
- debounce_ms: number
34
- indexes: Record<string, { enabled: boolean; extensions: string[] }>
35
- exclude: string[]
36
- }
37
-
38
- function getIndexForFile(filePath: string, config: VectorizerConfig): string | null {
39
- const ext = path.extname(filePath).toLowerCase()
40
- for (const [indexName, indexConfig] of Object.entries(config.indexes)) {
41
- if (indexConfig.enabled && indexConfig.extensions.includes(ext)) {
42
- return indexName
43
- }
44
- }
45
- return null
46
- }
47
-
48
- function isExcluded(relativePath: string, config: VectorizerConfig): boolean {
49
- return config.exclude.some(pattern => relativePath.startsWith(pattern))
50
- }
51
-
52
- function estimateTime(fileCount: number): number {
53
- const modelLoadTime = 30
54
- const perFileTime = 0.5
55
- const totalSeconds = modelLoadTime + (fileCount * perFileTime)
56
- return Math.ceil(totalSeconds / 60)
57
- }
58
-
59
- function formatDuration(seconds: number): string {
60
- if (seconds < 60) return `${Math.round(seconds)}s`
61
- const mins = Math.floor(seconds / 60)
62
- const secs = Math.round(seconds % 60)
63
- return secs > 0 ? `${mins}m ${secs}s` : `${mins}m`
64
- }
65
-
66
- // =============================================================================
67
- // UNIT TESTS: getIndexForFile (replica)
68
- // =============================================================================
69
- describe("getIndexForFile", () => {
70
- const config = DEFAULT_CONFIG as VectorizerConfig
71
-
72
- it("returns 'code' for TypeScript files", () => {
73
- expect(getIndexForFile("src/app.ts", config)).toBe("code")
74
- expect(getIndexForFile("src/app.tsx", config)).toBe("code")
75
- })
76
-
77
- it("returns 'code' for JavaScript files", () => {
78
- expect(getIndexForFile("src/index.js", config)).toBe("code")
79
- })
80
-
81
- it("returns null for .mjs/.cjs (not in default extensions)", () => {
82
- expect(getIndexForFile("src/util.mjs", config)).toBeNull()
83
- expect(getIndexForFile("src/util.cjs", config)).toBeNull()
84
- })
85
-
86
- it("returns 'code' for various language files", () => {
87
- expect(getIndexForFile("main.py", config)).toBe("code")
88
- expect(getIndexForFile("main.go", config)).toBe("code")
89
- expect(getIndexForFile("main.rs", config)).toBe("code")
90
- expect(getIndexForFile("Main.java", config)).toBe("code")
91
- expect(getIndexForFile("Main.kt", config)).toBe("code")
92
- expect(getIndexForFile("App.swift", config)).toBe("code")
93
- expect(getIndexForFile("main.c", config)).toBe("code")
94
- expect(getIndexForFile("main.cpp", config)).toBe("code")
95
- expect(getIndexForFile("main.h", config)).toBe("code")
96
- expect(getIndexForFile("main.hpp", config)).toBe("code")
97
- expect(getIndexForFile("Program.cs", config)).toBe("code")
98
- expect(getIndexForFile("app.rb", config)).toBe("code")
99
- expect(getIndexForFile("index.php", config)).toBe("code")
100
- expect(getIndexForFile("App.scala", config)).toBe("code")
101
- expect(getIndexForFile("core.clj", config)).toBe("code")
102
- })
103
-
104
- it("returns 'docs' for documentation files", () => {
105
- expect(getIndexForFile("README.md", config)).toBe("docs")
106
- expect(getIndexForFile("docs/api.mdx", config)).toBe("docs")
107
- expect(getIndexForFile("notes.txt", config)).toBe("docs")
108
- expect(getIndexForFile("guide.rst", config)).toBe("docs")
109
- expect(getIndexForFile("manual.adoc", config)).toBe("docs")
110
- })
111
-
112
- it("returns null for config files (disabled by default)", () => {
113
- expect(getIndexForFile("config.yaml", config)).toBeNull()
114
- expect(getIndexForFile("package.json", config)).toBeNull()
115
- expect(getIndexForFile("settings.toml", config)).toBeNull()
116
- })
117
-
118
- it("returns null for unknown file extensions", () => {
119
- expect(getIndexForFile("image.png", config)).toBeNull()
120
- expect(getIndexForFile("archive.zip", config)).toBeNull()
121
- expect(getIndexForFile("data.csv", config)).toBeNull()
122
- expect(getIndexForFile("noextension", config)).toBeNull()
123
- })
124
-
125
- it("returns config index when enabled", () => {
126
- const enabledConfig: VectorizerConfig = {
127
- ...config,
128
- indexes: {
129
- ...config.indexes,
130
- config: { enabled: true, extensions: [".yaml", ".yml", ".json", ".toml", ".ini", ".xml"] },
131
- },
132
- }
133
- expect(getIndexForFile("config.yaml", enabledConfig)).toBe("config")
134
- expect(getIndexForFile("package.json", enabledConfig)).toBe("config")
135
- })
136
- })
137
-
138
- // =============================================================================
139
- // UNIT TESTS: isExcluded (replica)
140
- // =============================================================================
141
- describe("isExcluded", () => {
142
- const config = DEFAULT_CONFIG as VectorizerConfig
143
-
144
- it("excludes node_modules paths", () => {
145
- expect(isExcluded("node_modules/lodash/index.js", config)).toBe(true)
146
- })
147
-
148
- it("excludes vendor paths", () => {
149
- expect(isExcluded("vendor/package/lib.go", config)).toBe(true)
150
- })
151
-
152
- it("excludes dist paths", () => {
153
- expect(isExcluded("dist/bundle.js", config)).toBe(true)
154
- })
155
-
156
- it("excludes build paths", () => {
157
- expect(isExcluded("build/output.js", config)).toBe(true)
158
- })
159
-
160
- it("excludes __pycache__ paths", () => {
161
- expect(isExcluded("__pycache__/module.pyc", config)).toBe(true)
162
- })
163
-
164
- it("does not exclude regular source paths", () => {
165
- expect(isExcluded("src/app.ts", config)).toBe(false)
166
- expect(isExcluded("lib/utils.js", config)).toBe(false)
167
- expect(isExcluded("components/Button.tsx", config)).toBe(false)
168
- })
169
-
170
- it("does not exclude nested non-excluded paths", () => {
171
- expect(isExcluded("src/vendor-custom/lib.ts", config)).toBe(false)
172
- })
173
-
174
- it("handles empty exclude list", () => {
175
- const emptyConfig: VectorizerConfig = { ...config, exclude: [] }
176
- expect(isExcluded("node_modules/test.js", emptyConfig)).toBe(false)
177
- })
178
- })
179
-
180
- // =============================================================================
181
- // UNIT TESTS: estimateTime (replica)
182
- // =============================================================================
183
- describe("estimateTime", () => {
184
- it("returns at least 1 minute for small counts", () => {
185
- expect(estimateTime(1)).toBeGreaterThanOrEqual(1)
186
- })
187
-
188
- it("scales with file count", () => {
189
- const small = estimateTime(10)
190
- const large = estimateTime(1000)
191
- expect(large).toBeGreaterThan(small)
192
- })
193
-
194
- it("includes model load time", () => {
195
- expect(estimateTime(0)).toBe(1)
196
- })
197
-
198
- it("estimates correctly for 100 files", () => {
199
- expect(estimateTime(100)).toBe(2)
200
- })
201
-
202
- it("estimates correctly for 1000 files", () => {
203
- expect(estimateTime(1000)).toBe(9)
204
- })
205
- })
206
-
207
- // =============================================================================
208
- // UNIT TESTS: formatDuration (replica)
209
- // =============================================================================
210
- describe("formatDuration", () => {
211
- it("formats seconds only", () => {
212
- expect(formatDuration(30)).toBe("30s")
213
- expect(formatDuration(59)).toBe("59s")
214
- })
215
-
216
- it("formats minutes and seconds", () => {
217
- expect(formatDuration(90)).toBe("1m 30s")
218
- expect(formatDuration(125)).toBe("2m 5s")
219
- })
220
-
221
- it("formats exact minutes", () => {
222
- expect(formatDuration(60)).toBe("1m")
223
- expect(formatDuration(120)).toBe("2m")
224
- expect(formatDuration(300)).toBe("5m")
225
- })
226
-
227
- it("rounds seconds", () => {
228
- expect(formatDuration(0.4)).toBe("0s")
229
- expect(formatDuration(0.6)).toBe("1s")
230
- })
231
-
232
- it("handles zero", () => {
233
- expect(formatDuration(0)).toBe("0s")
234
- })
235
- })
236
-
237
- // =============================================================================
238
- // INTEGRATION: Plugin initialization & config parsing (via plugin interface)
239
- // =============================================================================
240
- describe("FileIndexerPlugin", () => {
241
- let tempDir: string
242
-
243
- beforeEach(async () => {
244
- tempDir = await createTempDir()
245
- })
246
-
247
- afterEach(async () => {
248
- await cleanupTempDir(tempDir)
249
- })
250
-
251
- it("returns no-op event handler when disabled", async () => {
252
- await writeFile(
253
- join(tempDir, ".opencode", "config.yaml"),
254
- `vectorizer:\n enabled: false\n auto_index: false\n`
255
- )
256
- const ctx = createMockCtx(tempDir)
257
- const hooks = await FileIndexerPlugin(ctx as any)
258
-
259
- expect(hooks).toBeDefined()
260
- expect(hooks.event).toBeFunction()
261
-
262
- // Should not throw
263
- await hooks.event!({ event: { type: "file.edited" } as any })
264
- })
265
-
266
- it("returns no-op when auto_index is false", async () => {
267
- await writeFile(
268
- join(tempDir, ".opencode", "config.yaml"),
269
- `vectorizer:\n enabled: true\n auto_index: false\n`
270
- )
271
- const ctx = createMockCtx(tempDir)
272
- const hooks = await FileIndexerPlugin(ctx as any)
273
- expect(hooks.event).toBeFunction()
274
- })
275
-
276
- it("returns active event handler when enabled", async () => {
277
- await writeFile(join(tempDir, ".opencode", "config.yaml"), FIXTURE_CONFIG_YAML)
278
- const ctx = createMockCtx(tempDir)
279
- const hooks = await FileIndexerPlugin(ctx as any)
280
-
281
- expect(hooks).toBeDefined()
282
- expect(hooks.event).toBeFunction()
283
- })
284
-
285
- it("event handler processes file.edited events without crashing", async () => {
286
- await writeFile(join(tempDir, ".opencode", "config.yaml"), FIXTURE_CONFIG_YAML)
287
- const ctx = createMockCtx(tempDir)
288
- const hooks = await FileIndexerPlugin(ctx as any)
289
-
290
- // Queue a file edit — should not throw
291
- await hooks.event!({
292
- event: {
293
- type: "file.edited",
294
- properties: { file: join(tempDir, "src", "app.ts") },
295
- } as any,
296
- })
297
- })
298
-
299
- it("event handler ignores non-file events", async () => {
300
- await writeFile(join(tempDir, ".opencode", "config.yaml"), FIXTURE_CONFIG_YAML)
301
- const ctx = createMockCtx(tempDir)
302
- const hooks = await FileIndexerPlugin(ctx as any)
303
-
304
- // These should be silently ignored
305
- await hooks.event!({ event: { type: "session.idle" } as any })
306
- await hooks.event!({ event: { type: "todo.updated" } as any })
307
- })
308
-
309
- it("event handler ignores events without file path", async () => {
310
- await writeFile(join(tempDir, ".opencode", "config.yaml"), FIXTURE_CONFIG_YAML)
311
- const ctx = createMockCtx(tempDir)
312
- const hooks = await FileIndexerPlugin(ctx as any)
313
-
314
- await hooks.event!({
315
- event: { type: "file.edited", properties: {} } as any,
316
- })
317
- })
318
-
319
- it("loads default config when no config.yaml exists", async () => {
320
- // No config.yaml file at all
321
- const ctx = createMockCtx(tempDir)
322
- const hooks = await FileIndexerPlugin(ctx as any)
323
- expect(hooks).toBeDefined()
324
- expect(hooks.event).toBeFunction()
325
- })
326
-
327
- it("parses vectorizer section from config.yaml", async () => {
328
- await writeFile(join(tempDir, ".opencode", "config.yaml"), FIXTURE_CONFIG_YAML)
329
- const ctx = createMockCtx(tempDir)
330
- const hooks = await FileIndexerPlugin(ctx as any)
331
- // If config parsed correctly, plugin should return active event handler
332
- expect(hooks).toBeDefined()
333
- expect(hooks.event).toBeFunction()
334
- })
335
-
336
- it("parses disabled vectorizer and returns no-op", async () => {
337
- await writeFile(
338
- join(tempDir, ".opencode", "config.yaml"),
339
- `vectorizer:\n enabled: false\n auto_index: false\n`
340
- )
341
- const ctx = createMockCtx(tempDir)
342
- const hooks = await FileIndexerPlugin(ctx as any)
343
- expect(hooks.event).toBeFunction()
344
- // Event should be a no-op (disabled plugin)
345
- await hooks.event!({
346
- event: {
347
- type: "file.edited",
348
- properties: { file: join(tempDir, "src", "app.ts") },
349
- } as any,
350
- })
351
- })
352
-
353
- it("getLanguage returns 'uk' for Ukrainian config (verified via plugin init)", async () => {
354
- await writeFile(
355
- join(tempDir, ".opencode", "config.yaml"),
356
- `communication_language: Ukrainian\nvectorizer:\n enabled: true\n auto_index: true\n`
357
- )
358
- const ctx = createMockCtx(tempDir)
359
- const hooks = await FileIndexerPlugin(ctx as any)
360
- // Plugin loaded successfully with Ukrainian language detection
361
- expect(hooks).toBeDefined()
362
- })
363
-
364
- it("getLanguage returns 'en' by default", async () => {
365
- // No communication_language in config
366
- await writeFile(
367
- join(tempDir, ".opencode", "config.yaml"),
368
- `vectorizer:\n enabled: true\n auto_index: true\n`
369
- )
370
- const ctx = createMockCtx(tempDir)
371
- const hooks = await FileIndexerPlugin(ctx as any)
372
- expect(hooks).toBeDefined()
373
- })
374
- })
375
-
376
- // =============================================================================
377
- // MEMORY SAFETY: Plugin instance leak detection
378
- // =============================================================================
379
- describe("memory safety: plugin instances", () => {
380
- it("multiple plugin instantiations don't cause excessive memory growth", async () => {
381
- const memBefore = process.memoryUsage().heapUsed
382
-
383
- for (let i = 0; i < 20; i++) {
384
- const dir = await createTempDir({
385
- ".opencode/config.yaml": `vectorizer:\n enabled: false\n auto_index: false\n`,
386
- })
387
- const ctx = createMockCtx(dir)
388
- const hooks = await FileIndexerPlugin(ctx as any)
389
- await hooks.event!({ event: { type: "session.idle" } as any })
390
- await cleanupTempDir(dir)
391
- }
392
-
393
- if ((globalThis as any).gc) (globalThis as any).gc()
394
-
395
- const memAfter = process.memoryUsage().heapUsed
396
- const growthMB = (memAfter - memBefore) / 1024 / 1024
397
-
398
- // 20 disabled-plugin instances shouldn't use >50MB
399
- expect(growthMB).toBeLessThan(50)
400
- })
401
-
402
- it("repeated file.edited events don't crash (enabled plugin, no vectorizer)", async () => {
403
- const dir = await createTempDir({
404
- ".opencode/config.yaml": FIXTURE_CONFIG_YAML,
405
- })
406
- const ctx = createMockCtx(dir)
407
- const hooks = await FileIndexerPlugin(ctx as any)
408
-
409
- // Send many events — plugin will queue them but vectorizer not installed
410
- // so the timeout callback should clear pendingFiles (fix #2)
411
- for (let i = 0; i < 100; i++) {
412
- await hooks.event!({
413
- event: {
414
- type: "file.edited",
415
- properties: { file: join(dir, `src/file-${i}.ts`) },
416
- } as any,
417
- })
418
- }
419
-
420
- // Wait for debounce timeout to fire and clear pendingFiles
421
- await new Promise(resolve => setTimeout(resolve, 1500))
422
-
423
- await cleanupTempDir(dir)
424
- })
425
- })
@@ -1,171 +0,0 @@
1
- /**
2
- * Mock PluginInput context for testing plugins.
3
- * Creates a temp directory with optional fixture files.
4
- */
5
- import { mkdtemp, mkdir, writeFile, rm } from "fs/promises"
6
- import { tmpdir } from "os"
7
- import { join } from "path"
8
-
9
- export interface MockCtx {
10
- directory: string
11
- worktree: string
12
- client: {
13
- tui: {
14
- showToast: (...args: any[]) => Promise<void>
15
- }
16
- }
17
- project: { name: string }
18
- serverUrl: URL
19
- $: any
20
- }
21
-
22
- export function createMockCtx(directory: string): MockCtx {
23
- const calls: any[][] = []
24
- const showToast = (...args: any[]) => {
25
- calls.push(args)
26
- return Promise.resolve()
27
- }
28
- // Attach calls for inspection
29
- ;(showToast as any).calls = calls
30
-
31
- return {
32
- directory,
33
- worktree: directory,
34
- client: {
35
- tui: { showToast },
36
- },
37
- project: { name: "test-project" },
38
- serverUrl: new URL("http://localhost:3000"),
39
- $: {},
40
- } as any
41
- }
42
-
43
- /**
44
- * Create a temp directory with .opencode/ subdirectory
45
- * and optional fixture files.
46
- */
47
- export async function createTempDir(
48
- fixtures?: Record<string, string>
49
- ): Promise<string> {
50
- const dir = await mkdtemp(join(tmpdir(), "plugin-test-"))
51
- await mkdir(join(dir, ".opencode"), { recursive: true })
52
- await mkdir(join(dir, ".opencode", "state"), { recursive: true })
53
-
54
- if (fixtures) {
55
- for (const [relativePath, content] of Object.entries(fixtures)) {
56
- const fullPath = join(dir, relativePath)
57
- const parentDir = join(fullPath, "..")
58
- await mkdir(parentDir, { recursive: true })
59
- await writeFile(fullPath, content, "utf-8")
60
- }
61
- }
62
-
63
- return dir
64
- }
65
-
66
- /**
67
- * Remove temp directory
68
- */
69
- export async function cleanupTempDir(dir: string): Promise<void> {
70
- try {
71
- await rm(dir, { recursive: true, force: true })
72
- } catch {
73
- // ignore cleanup errors
74
- }
75
- }
76
-
77
- /**
78
- * Fixture: session-state.yaml for dev agent working on a story
79
- */
80
- export const FIXTURE_SESSION_STATE = `command: /dev-epic
81
- agent: dev
82
- sprint:
83
- number: 1
84
- status: in-progress
85
- epic:
86
- id: AUTH-E01
87
- title: Authentication Module
88
- file: docs/sprint-artifacts/sprint-1/epic-01-auth.md
89
- progress: 2/5 stories done
90
- story:
91
- id: AUTH-S01-03
92
- title: JWT Token Refresh
93
- file: docs/sprint-artifacts/sprint-1/stories/story-01-03-jwt-refresh.md
94
- current_task: T2
95
- completed_tasks: [T1]
96
- pending_tasks: [T2, T3, T4]
97
- next_action: Implement T2 - token refresh endpoint
98
- key_decisions:
99
- - Using RS256 for JWT signing
100
- - Refresh tokens stored in Redis
101
- `
102
-
103
- /**
104
- * Fixture: story markdown file
105
- */
106
- export const FIXTURE_STORY_MD = `# AUTH-S01-03: JWT Token Refresh
107
-
108
- **Status:** in-progress
109
-
110
- ## Tasks
111
-
112
- - [x] **T1**: Define refresh token interface
113
- - [ ] **T2**: Implement refresh endpoint
114
- - [ ] **T3**: Add token rotation logic
115
- - [ ] **T4**: Write integration tests
116
-
117
- ## Acceptance Criteria
118
-
119
- - [x] Refresh tokens are stored securely
120
- - [ ] Token rotation works on refresh
121
- - [ ] Expired tokens are rejected
122
- `
123
-
124
- /**
125
- * Fixture: epic state YAML
126
- */
127
- export const FIXTURE_EPIC_STATE = `epic_id: "AUTH-E01"
128
- epic_title: "Authentication Module"
129
- status: "in-progress"
130
- current_story_index: 2
131
- total_stories: 5
132
- completed_stories:
133
- - path: docs/sprint-artifacts/sprint-1/stories/story-01-01-login.md
134
- status: done
135
- - path: docs/sprint-artifacts/sprint-1/stories/story-01-02-register.md
136
- status: done
137
- pending_stories:
138
- - path: docs/sprint-artifacts/sprint-1/stories/story-01-03-jwt-refresh.md
139
- status: in-progress
140
- - path: docs/sprint-artifacts/sprint-1/stories/story-01-04-logout.md
141
- status: pending
142
- - path: docs/sprint-artifacts/sprint-1/stories/story-01-05-password-reset.md
143
- status: pending
144
- next_action: "Continue story-01-03-jwt-refresh.md"
145
- `
146
-
147
- /**
148
- * Fixture: config.yaml
149
- */
150
- export const FIXTURE_CONFIG_YAML = `project_name: "test-project"
151
- version: "1.0.0"
152
- communication_language: "Ukrainian"
153
-
154
- vectorizer:
155
- enabled: true
156
- auto_index: true
157
- debounce_ms: 500
158
- exclude:
159
- - node_modules
160
- - dist
161
- - build
162
- `
163
-
164
- /**
165
- * Fixture: todo list
166
- */
167
- export const FIXTURE_TODOS = JSON.stringify([
168
- { id: "1", content: "Implement T2 - refresh endpoint", status: "in_progress", priority: "high" },
169
- { id: "2", content: "Write T3 - token rotation", status: "pending", priority: "high" },
170
- { id: "3", content: "Write T4 - integration tests", status: "pending", priority: "medium" },
171
- ])