openzellij 1.0.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 +96 -0
- package/config/.gitkeep +0 -0
- package/dist/index.d.ts +99 -0
- package/dist/index.js +6568 -0
- package/dist/index.js.map +7 -0
- package/docs/.gitkeep +0 -0
- package/docs/CONFIG.md +302 -0
- package/docs/INSTALL.md +202 -0
- package/docs/LLM_SETUP_SCRIPT.md +138 -0
- package/package.json +33 -0
- package/scripts/build.js +55 -0
- package/scripts/build.ts +26 -0
- package/src/config.ts +46 -0
- package/src/index.ts +4 -0
- package/src/lifecycle.ts +364 -0
- package/src/types.ts +99 -0
- package/src/utils/zellij.ts +66 -0
- package/tests/config.test.ts +33 -0
- package/tests/lifecycle.test.ts +633 -0
- package/tests/zellij-utils.test.ts +13 -0
- package/tsconfig.json +15 -0
|
@@ -0,0 +1,633 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
PaneRegistryImpl,
|
|
4
|
+
onActivate,
|
|
5
|
+
onDeactivate,
|
|
6
|
+
handleEvent,
|
|
7
|
+
startPolling,
|
|
8
|
+
stopPolling,
|
|
9
|
+
checkPaneCompletion,
|
|
10
|
+
getRegistry,
|
|
11
|
+
getPollingIntervalId,
|
|
12
|
+
_setDependencies,
|
|
13
|
+
_resetState,
|
|
14
|
+
getActivePanes,
|
|
15
|
+
} from '../src/lifecycle'
|
|
16
|
+
import type {
|
|
17
|
+
PluginInput,
|
|
18
|
+
PluginEventInput,
|
|
19
|
+
TrackedPane,
|
|
20
|
+
SessionDescriptor,
|
|
21
|
+
PluginConfig,
|
|
22
|
+
OpencodeClient,
|
|
23
|
+
} from '../src/types'
|
|
24
|
+
import type { ZellijPaneInfo } from '../src/utils/zellij'
|
|
25
|
+
|
|
26
|
+
vi.mock('../src/config', () => ({
|
|
27
|
+
loadConfig: vi.fn(() => ({
|
|
28
|
+
enableLogging: true,
|
|
29
|
+
spawnDelayMs: 250,
|
|
30
|
+
maxConcurrentSpawns: 1,
|
|
31
|
+
paneLayout: 'tiled',
|
|
32
|
+
zellijBinary: 'zellij',
|
|
33
|
+
listIntervalMs: 5000,
|
|
34
|
+
autoClosePanes: true,
|
|
35
|
+
panePollIntervalMs: 2000,
|
|
36
|
+
paneMissingGraceMs: 6000,
|
|
37
|
+
})),
|
|
38
|
+
}))
|
|
39
|
+
|
|
40
|
+
vi.mock('../src/utils/zellij', () => ({
|
|
41
|
+
ZellijCLI: vi.fn().mockImplementation(() => ({
|
|
42
|
+
listPanes: vi.fn().mockResolvedValue([]),
|
|
43
|
+
spawnPane: vi.fn().mockResolvedValue(undefined),
|
|
44
|
+
closePane: vi.fn().mockResolvedValue(undefined),
|
|
45
|
+
})),
|
|
46
|
+
}))
|
|
47
|
+
|
|
48
|
+
function createMockClient(): OpencodeClient {
|
|
49
|
+
return {
|
|
50
|
+
session: {
|
|
51
|
+
status: vi.fn().mockResolvedValue({ sessions: [] }),
|
|
52
|
+
subscribe: vi.fn().mockReturnValue(() => {}),
|
|
53
|
+
},
|
|
54
|
+
logger: {
|
|
55
|
+
info: vi.fn(),
|
|
56
|
+
warn: vi.fn(),
|
|
57
|
+
error: vi.fn(),
|
|
58
|
+
debug: vi.fn(),
|
|
59
|
+
},
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function createMockConfig(overrides: Partial<PluginConfig> = {}): PluginConfig {
|
|
64
|
+
return {
|
|
65
|
+
enableLogging: true,
|
|
66
|
+
spawnDelayMs: 250,
|
|
67
|
+
maxConcurrentSpawns: 1,
|
|
68
|
+
paneLayout: 'tiled',
|
|
69
|
+
zellijBinary: 'zellij',
|
|
70
|
+
listIntervalMs: 5000,
|
|
71
|
+
autoClosePanes: true,
|
|
72
|
+
panePollIntervalMs: 2000,
|
|
73
|
+
paneMissingGraceMs: 6000,
|
|
74
|
+
...overrides,
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
describe('PaneRegistryImpl', () => {
|
|
79
|
+
let registry: PaneRegistryImpl
|
|
80
|
+
|
|
81
|
+
beforeEach(() => {
|
|
82
|
+
registry = new PaneRegistryImpl()
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('adds a pane', () => {
|
|
86
|
+
const pane: TrackedPane = {
|
|
87
|
+
sessionId: 'ses-1',
|
|
88
|
+
paneId: 'pane-1',
|
|
89
|
+
createdAt: Date.now(),
|
|
90
|
+
lastUpdatedAt: Date.now(),
|
|
91
|
+
}
|
|
92
|
+
registry.add(pane)
|
|
93
|
+
expect(registry.get('ses-1')).toEqual(pane)
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('removes a pane', () => {
|
|
97
|
+
const pane: TrackedPane = {
|
|
98
|
+
sessionId: 'ses-1',
|
|
99
|
+
paneId: 'pane-1',
|
|
100
|
+
createdAt: Date.now(),
|
|
101
|
+
lastUpdatedAt: Date.now(),
|
|
102
|
+
}
|
|
103
|
+
registry.add(pane)
|
|
104
|
+
registry.remove('ses-1')
|
|
105
|
+
expect(registry.get('ses-1')).toBeUndefined()
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('returns undefined for non-existent pane', () => {
|
|
109
|
+
expect(registry.get('non-existent')).toBeUndefined()
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('returns all panes', () => {
|
|
113
|
+
const pane1: TrackedPane = {
|
|
114
|
+
sessionId: 'ses-1',
|
|
115
|
+
paneId: 'pane-1',
|
|
116
|
+
createdAt: Date.now(),
|
|
117
|
+
lastUpdatedAt: Date.now(),
|
|
118
|
+
}
|
|
119
|
+
const pane2: TrackedPane = {
|
|
120
|
+
sessionId: 'ses-2',
|
|
121
|
+
paneId: 'pane-2',
|
|
122
|
+
createdAt: Date.now(),
|
|
123
|
+
lastUpdatedAt: Date.now(),
|
|
124
|
+
}
|
|
125
|
+
registry.add(pane1)
|
|
126
|
+
registry.add(pane2)
|
|
127
|
+
expect(registry.getAll()).toHaveLength(2)
|
|
128
|
+
expect(registry.getAll()).toContainEqual(pane1)
|
|
129
|
+
expect(registry.getAll()).toContainEqual(pane2)
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('updates lastSeen timestamp', () => {
|
|
133
|
+
const originalTime = Date.now() - 10000
|
|
134
|
+
const pane: TrackedPane = {
|
|
135
|
+
sessionId: 'ses-1',
|
|
136
|
+
paneId: 'pane-1',
|
|
137
|
+
createdAt: originalTime,
|
|
138
|
+
lastUpdatedAt: originalTime,
|
|
139
|
+
missingSince: originalTime,
|
|
140
|
+
}
|
|
141
|
+
registry.add(pane)
|
|
142
|
+
registry.updateLastSeen('ses-1')
|
|
143
|
+
const updated = registry.get('ses-1')!
|
|
144
|
+
expect(updated.lastUpdatedAt).toBeGreaterThan(originalTime)
|
|
145
|
+
expect(updated.missingSince).toBeUndefined()
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it('updateLastSeen does nothing for non-existent pane', () => {
|
|
149
|
+
expect(() => registry.updateLastSeen('non-existent')).not.toThrow()
|
|
150
|
+
})
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
describe('onActivate', () => {
|
|
154
|
+
beforeEach(() => {
|
|
155
|
+
vi.useFakeTimers()
|
|
156
|
+
_resetState()
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
afterEach(() => {
|
|
160
|
+
vi.useRealTimers()
|
|
161
|
+
_resetState()
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it('initializes plugin and starts polling', async () => {
|
|
165
|
+
const mockClient = createMockClient()
|
|
166
|
+
const input: PluginInput = {
|
|
167
|
+
context: {
|
|
168
|
+
client: mockClient,
|
|
169
|
+
directory: '/test',
|
|
170
|
+
},
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const result = await onActivate(input)
|
|
174
|
+
|
|
175
|
+
expect(result.name).toBe('openzellij')
|
|
176
|
+
expect(result.event.type).toBe('custom')
|
|
177
|
+
expect(mockClient.session.subscribe).toHaveBeenCalled()
|
|
178
|
+
expect(getRegistry()).toBeInstanceOf(PaneRegistryImpl)
|
|
179
|
+
expect(getPollingIntervalId()).not.toBeNull()
|
|
180
|
+
})
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
describe('onDeactivate', () => {
|
|
184
|
+
beforeEach(() => {
|
|
185
|
+
vi.useFakeTimers()
|
|
186
|
+
_resetState()
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
afterEach(() => {
|
|
190
|
+
vi.useRealTimers()
|
|
191
|
+
_resetState()
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
it('stops polling and clears registry', async () => {
|
|
195
|
+
const mockClient = createMockClient()
|
|
196
|
+
const input: PluginInput = {
|
|
197
|
+
context: {
|
|
198
|
+
client: mockClient,
|
|
199
|
+
directory: '/test',
|
|
200
|
+
},
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
await onActivate(input)
|
|
204
|
+
expect(getPollingIntervalId()).not.toBeNull()
|
|
205
|
+
|
|
206
|
+
await onDeactivate()
|
|
207
|
+
expect(getPollingIntervalId()).toBeNull()
|
|
208
|
+
expect(getRegistry()).toBeNull()
|
|
209
|
+
})
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
describe('handleEvent', () => {
|
|
213
|
+
beforeEach(() => {
|
|
214
|
+
vi.useFakeTimers()
|
|
215
|
+
_resetState()
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
afterEach(() => {
|
|
219
|
+
vi.useRealTimers()
|
|
220
|
+
_resetState()
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
it('handles session.created by spawning pane', async () => {
|
|
224
|
+
const mockClient = createMockClient()
|
|
225
|
+
await onActivate({
|
|
226
|
+
context: { client: mockClient, directory: '/test' },
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
const event: PluginEventInput = {
|
|
230
|
+
type: 'session.created',
|
|
231
|
+
session: {
|
|
232
|
+
id: 'ses-1',
|
|
233
|
+
status: 'running',
|
|
234
|
+
},
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
await handleEvent(event)
|
|
238
|
+
const registry = getRegistry()!
|
|
239
|
+
expect(registry.get('ses-1')).toBeDefined()
|
|
240
|
+
// verify logger called on spawn
|
|
241
|
+
expect(mockClient.logger?.info).toHaveBeenCalledWith(
|
|
242
|
+
'Spawned pane for session',
|
|
243
|
+
expect.objectContaining({ sessionId: 'ses-1', paneId: expect.any(String), title: expect.any(String) })
|
|
244
|
+
)
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
it('handles session.deleted by closing pane', async () => {
|
|
248
|
+
const mockClient = createMockClient()
|
|
249
|
+
await onActivate({
|
|
250
|
+
context: { client: mockClient, directory: '/test' },
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
const registry = getRegistry()!
|
|
254
|
+
registry.add({
|
|
255
|
+
sessionId: 'ses-1',
|
|
256
|
+
paneId: 'pane-1',
|
|
257
|
+
createdAt: Date.now(),
|
|
258
|
+
lastUpdatedAt: Date.now(),
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
const event: PluginEventInput = {
|
|
262
|
+
type: 'session.deleted',
|
|
263
|
+
session: {
|
|
264
|
+
id: 'ses-1',
|
|
265
|
+
status: 'completed',
|
|
266
|
+
},
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
await handleEvent(event)
|
|
270
|
+
expect(registry.get('ses-1')).toBeUndefined()
|
|
271
|
+
expect(mockClient.logger?.info).toHaveBeenCalledWith(
|
|
272
|
+
'Closed pane for session',
|
|
273
|
+
expect.objectContaining({ sessionId: 'ses-1', paneId: 'pane-1', reason: expect.any(String) })
|
|
274
|
+
)
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
it('handles session.updated with terminal state', async () => {
|
|
278
|
+
const mockClient = createMockClient()
|
|
279
|
+
await onActivate({
|
|
280
|
+
context: { client: mockClient, directory: '/test' },
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
const registry = getRegistry()!
|
|
284
|
+
registry.add({
|
|
285
|
+
sessionId: 'ses-1',
|
|
286
|
+
paneId: 'pane-1',
|
|
287
|
+
createdAt: Date.now(),
|
|
288
|
+
lastUpdatedAt: Date.now(),
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
const event: PluginEventInput = {
|
|
292
|
+
type: 'session.updated',
|
|
293
|
+
session: {
|
|
294
|
+
id: 'ses-1',
|
|
295
|
+
status: 'completed',
|
|
296
|
+
},
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
await handleEvent(event)
|
|
300
|
+
expect(registry.get('ses-1')).toBeUndefined()
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
it('does nothing when not initialized', async () => {
|
|
304
|
+
const event: PluginEventInput = {
|
|
305
|
+
type: 'session.created',
|
|
306
|
+
session: {
|
|
307
|
+
id: 'ses-1',
|
|
308
|
+
status: 'running',
|
|
309
|
+
},
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
await expect(handleEvent(event)).resolves.not.toThrow()
|
|
313
|
+
})
|
|
314
|
+
})
|
|
315
|
+
|
|
316
|
+
describe('startPolling / stopPolling', () => {
|
|
317
|
+
beforeEach(() => {
|
|
318
|
+
vi.useFakeTimers()
|
|
319
|
+
_resetState()
|
|
320
|
+
})
|
|
321
|
+
|
|
322
|
+
afterEach(() => {
|
|
323
|
+
vi.useRealTimers()
|
|
324
|
+
_resetState()
|
|
325
|
+
})
|
|
326
|
+
|
|
327
|
+
it('starts polling with configured interval', async () => {
|
|
328
|
+
const mockClient = createMockClient()
|
|
329
|
+
await onActivate({
|
|
330
|
+
context: { client: mockClient, directory: '/test' },
|
|
331
|
+
})
|
|
332
|
+
|
|
333
|
+
expect(getPollingIntervalId()).not.toBeNull()
|
|
334
|
+
|
|
335
|
+
const registry = getRegistry()!
|
|
336
|
+
registry.add({
|
|
337
|
+
sessionId: 'ses-1',
|
|
338
|
+
paneId: 'pane-1',
|
|
339
|
+
createdAt: Date.now(),
|
|
340
|
+
lastUpdatedAt: Date.now(),
|
|
341
|
+
})
|
|
342
|
+
|
|
343
|
+
vi.advanceTimersByTime(2000)
|
|
344
|
+
await vi.runOnlyPendingTimersAsync()
|
|
345
|
+
|
|
346
|
+
expect(mockClient.session.status).toHaveBeenCalled()
|
|
347
|
+
})
|
|
348
|
+
|
|
349
|
+
it('stops polling on stopPolling call', async () => {
|
|
350
|
+
const mockClient = createMockClient()
|
|
351
|
+
await onActivate({
|
|
352
|
+
context: { client: mockClient, directory: '/test' },
|
|
353
|
+
})
|
|
354
|
+
|
|
355
|
+
stopPolling()
|
|
356
|
+
expect(getPollingIntervalId()).toBeNull()
|
|
357
|
+
})
|
|
358
|
+
|
|
359
|
+
it('does not start polling without config', () => {
|
|
360
|
+
startPolling()
|
|
361
|
+
expect(getPollingIntervalId()).toBeNull()
|
|
362
|
+
})
|
|
363
|
+
})
|
|
364
|
+
|
|
365
|
+
describe('checkPaneCompletion', () => {
|
|
366
|
+
const cfg = createMockConfig()
|
|
367
|
+
|
|
368
|
+
it('returns pane_exited when pane has exit status', () => {
|
|
369
|
+
const tracked: TrackedPane = {
|
|
370
|
+
sessionId: 'ses-1',
|
|
371
|
+
paneId: 'pane-1',
|
|
372
|
+
createdAt: Date.now(),
|
|
373
|
+
lastUpdatedAt: Date.now(),
|
|
374
|
+
}
|
|
375
|
+
const zellijPanes = new Map<string, ZellijPaneInfo>([
|
|
376
|
+
['pane-1', { id: 'pane-1', exit_status: 0 }],
|
|
377
|
+
])
|
|
378
|
+
const sessions = new Map<string, SessionDescriptor>([
|
|
379
|
+
['ses-1', { id: 'ses-1', status: 'running' }],
|
|
380
|
+
])
|
|
381
|
+
|
|
382
|
+
expect(checkPaneCompletion(tracked, zellijPanes, sessions, Date.now(), cfg)).toBe('pane_exited')
|
|
383
|
+
})
|
|
384
|
+
|
|
385
|
+
it('returns session_completed for completed session', () => {
|
|
386
|
+
const tracked: TrackedPane = {
|
|
387
|
+
sessionId: 'ses-1',
|
|
388
|
+
paneId: 'pane-1',
|
|
389
|
+
createdAt: Date.now(),
|
|
390
|
+
lastUpdatedAt: Date.now(),
|
|
391
|
+
}
|
|
392
|
+
const zellijPanes = new Map<string, ZellijPaneInfo>([
|
|
393
|
+
['pane-1', { id: 'pane-1', exit_status: null }],
|
|
394
|
+
])
|
|
395
|
+
const sessions = new Map<string, SessionDescriptor>([
|
|
396
|
+
['ses-1', { id: 'ses-1', status: 'completed' }],
|
|
397
|
+
])
|
|
398
|
+
|
|
399
|
+
expect(checkPaneCompletion(tracked, zellijPanes, sessions, Date.now(), cfg)).toBe('session_completed')
|
|
400
|
+
})
|
|
401
|
+
|
|
402
|
+
it('returns session_failed for failed session', () => {
|
|
403
|
+
const tracked: TrackedPane = {
|
|
404
|
+
sessionId: 'ses-1',
|
|
405
|
+
paneId: 'pane-1',
|
|
406
|
+
createdAt: Date.now(),
|
|
407
|
+
lastUpdatedAt: Date.now(),
|
|
408
|
+
}
|
|
409
|
+
const zellijPanes = new Map<string, ZellijPaneInfo>()
|
|
410
|
+
const sessions = new Map<string, SessionDescriptor>([
|
|
411
|
+
['ses-1', { id: 'ses-1', status: 'failed' }],
|
|
412
|
+
])
|
|
413
|
+
|
|
414
|
+
expect(checkPaneCompletion(tracked, zellijPanes, sessions, Date.now(), cfg)).toBe('session_failed')
|
|
415
|
+
})
|
|
416
|
+
|
|
417
|
+
it('returns session_idle for idle session', () => {
|
|
418
|
+
const tracked: TrackedPane = {
|
|
419
|
+
sessionId: 'ses-1',
|
|
420
|
+
paneId: 'pane-1',
|
|
421
|
+
createdAt: Date.now(),
|
|
422
|
+
lastUpdatedAt: Date.now(),
|
|
423
|
+
}
|
|
424
|
+
const zellijPanes = new Map<string, ZellijPaneInfo>()
|
|
425
|
+
const sessions = new Map<string, SessionDescriptor>([
|
|
426
|
+
['ses-1', { id: 'ses-1', status: 'idle' }],
|
|
427
|
+
])
|
|
428
|
+
|
|
429
|
+
expect(checkPaneCompletion(tracked, zellijPanes, sessions, Date.now(), cfg)).toBe('session_idle')
|
|
430
|
+
})
|
|
431
|
+
|
|
432
|
+
it('sets missingSince on first missing session detection', () => {
|
|
433
|
+
const now = Date.now()
|
|
434
|
+
const tracked: TrackedPane = {
|
|
435
|
+
sessionId: 'ses-1',
|
|
436
|
+
paneId: 'pane-1',
|
|
437
|
+
createdAt: now,
|
|
438
|
+
lastUpdatedAt: now,
|
|
439
|
+
}
|
|
440
|
+
const zellijPanes = new Map<string, ZellijPaneInfo>()
|
|
441
|
+
const sessions = new Map<string, SessionDescriptor>()
|
|
442
|
+
|
|
443
|
+
const result = checkPaneCompletion(tracked, zellijPanes, sessions, now, cfg)
|
|
444
|
+
expect(result).toBeNull()
|
|
445
|
+
expect(tracked.missingSince).toBe(now)
|
|
446
|
+
})
|
|
447
|
+
|
|
448
|
+
it('returns session_missing_grace_exceeded after grace period', () => {
|
|
449
|
+
const startTime = Date.now()
|
|
450
|
+
const tracked: TrackedPane = {
|
|
451
|
+
sessionId: 'ses-1',
|
|
452
|
+
paneId: 'pane-1',
|
|
453
|
+
createdAt: startTime,
|
|
454
|
+
lastUpdatedAt: startTime,
|
|
455
|
+
missingSince: startTime,
|
|
456
|
+
}
|
|
457
|
+
const zellijPanes = new Map<string, ZellijPaneInfo>()
|
|
458
|
+
const sessions = new Map<string, SessionDescriptor>()
|
|
459
|
+
|
|
460
|
+
const afterGrace = startTime + 6001
|
|
461
|
+
const result = checkPaneCompletion(tracked, zellijPanes, sessions, afterGrace, cfg)
|
|
462
|
+
expect(result).toBe('session_missing_grace_exceeded')
|
|
463
|
+
})
|
|
464
|
+
|
|
465
|
+
it('returns null within grace period', () => {
|
|
466
|
+
const startTime = Date.now()
|
|
467
|
+
const tracked: TrackedPane = {
|
|
468
|
+
sessionId: 'ses-1',
|
|
469
|
+
paneId: 'pane-1',
|
|
470
|
+
createdAt: startTime,
|
|
471
|
+
lastUpdatedAt: startTime,
|
|
472
|
+
missingSince: startTime,
|
|
473
|
+
}
|
|
474
|
+
const zellijPanes = new Map<string, ZellijPaneInfo>()
|
|
475
|
+
const sessions = new Map<string, SessionDescriptor>()
|
|
476
|
+
|
|
477
|
+
const withinGrace = startTime + 5000
|
|
478
|
+
const result = checkPaneCompletion(tracked, zellijPanes, sessions, withinGrace, cfg)
|
|
479
|
+
expect(result).toBeNull()
|
|
480
|
+
})
|
|
481
|
+
|
|
482
|
+
it('clears missingSince when session reappears', () => {
|
|
483
|
+
const tracked: TrackedPane = {
|
|
484
|
+
sessionId: 'ses-1',
|
|
485
|
+
paneId: 'pane-1',
|
|
486
|
+
createdAt: Date.now(),
|
|
487
|
+
lastUpdatedAt: Date.now(),
|
|
488
|
+
missingSince: Date.now() - 1000,
|
|
489
|
+
}
|
|
490
|
+
const zellijPanes = new Map<string, ZellijPaneInfo>()
|
|
491
|
+
const sessions = new Map<string, SessionDescriptor>([
|
|
492
|
+
['ses-1', { id: 'ses-1', status: 'running' }],
|
|
493
|
+
])
|
|
494
|
+
|
|
495
|
+
checkPaneCompletion(tracked, zellijPanes, sessions, Date.now(), cfg)
|
|
496
|
+
expect(tracked.missingSince).toBeUndefined()
|
|
497
|
+
})
|
|
498
|
+
|
|
499
|
+
it('returns null for running session', () => {
|
|
500
|
+
const tracked: TrackedPane = {
|
|
501
|
+
sessionId: 'ses-1',
|
|
502
|
+
paneId: 'pane-1',
|
|
503
|
+
createdAt: Date.now(),
|
|
504
|
+
lastUpdatedAt: Date.now(),
|
|
505
|
+
}
|
|
506
|
+
const zellijPanes = new Map<string, ZellijPaneInfo>([
|
|
507
|
+
['pane-1', { id: 'pane-1' }],
|
|
508
|
+
])
|
|
509
|
+
const sessions = new Map<string, SessionDescriptor>([
|
|
510
|
+
['ses-1', { id: 'ses-1', status: 'running' }],
|
|
511
|
+
])
|
|
512
|
+
|
|
513
|
+
expect(checkPaneCompletion(tracked, zellijPanes, sessions, Date.now(), cfg)).toBeNull()
|
|
514
|
+
})
|
|
515
|
+
})
|
|
516
|
+
|
|
517
|
+
describe('polling auto-close integration', () => {
|
|
518
|
+
beforeEach(() => {
|
|
519
|
+
vi.useFakeTimers()
|
|
520
|
+
_resetState()
|
|
521
|
+
})
|
|
522
|
+
|
|
523
|
+
afterEach(() => {
|
|
524
|
+
vi.useRealTimers()
|
|
525
|
+
_resetState()
|
|
526
|
+
})
|
|
527
|
+
|
|
528
|
+
it('auto-closes panes when session completes', async () => {
|
|
529
|
+
const mockClient = createMockClient()
|
|
530
|
+
;(mockClient.session.status as Mock).mockResolvedValue({
|
|
531
|
+
sessions: [{ id: 'ses-1', status: 'completed' }],
|
|
532
|
+
})
|
|
533
|
+
|
|
534
|
+
await onActivate({
|
|
535
|
+
context: { client: mockClient, directory: '/test' },
|
|
536
|
+
})
|
|
537
|
+
|
|
538
|
+
const registry = getRegistry()!
|
|
539
|
+
registry.add({
|
|
540
|
+
sessionId: 'ses-1',
|
|
541
|
+
paneId: 'pane-1',
|
|
542
|
+
createdAt: Date.now(),
|
|
543
|
+
lastUpdatedAt: Date.now(),
|
|
544
|
+
})
|
|
545
|
+
|
|
546
|
+
vi.advanceTimersByTime(2000)
|
|
547
|
+
await vi.runOnlyPendingTimersAsync()
|
|
548
|
+
|
|
549
|
+
expect(registry.get('ses-1')).toBeUndefined()
|
|
550
|
+
// verify logging of active panes every 10 cycles — simulate 9 more cycles
|
|
551
|
+
for (let i = 0; i < 9; i++) {
|
|
552
|
+
vi.advanceTimersByTime(2000)
|
|
553
|
+
// run pending
|
|
554
|
+
// eslint-disable-next-line no-await-in-loop
|
|
555
|
+
await vi.runOnlyPendingTimersAsync()
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// after 10 cycles, debug should have been called with active panes (may be 'No active panes')
|
|
559
|
+
expect(mockClient.logger?.debug).toHaveBeenCalled()
|
|
560
|
+
})
|
|
561
|
+
|
|
562
|
+
it('respects grace period for missing sessions', async () => {
|
|
563
|
+
const baseTime = new Date('2024-01-01T00:00:00Z').getTime()
|
|
564
|
+
vi.setSystemTime(baseTime)
|
|
565
|
+
|
|
566
|
+
const mockClient = createMockClient()
|
|
567
|
+
;(mockClient.session.status as Mock).mockResolvedValue({ sessions: [] })
|
|
568
|
+
|
|
569
|
+
await onActivate({
|
|
570
|
+
context: { client: mockClient, directory: '/test' },
|
|
571
|
+
})
|
|
572
|
+
|
|
573
|
+
const registry = getRegistry()!
|
|
574
|
+
registry.add({
|
|
575
|
+
sessionId: 'ses-1',
|
|
576
|
+
paneId: 'pane-1',
|
|
577
|
+
createdAt: Date.now(),
|
|
578
|
+
lastUpdatedAt: Date.now(),
|
|
579
|
+
})
|
|
580
|
+
|
|
581
|
+
await vi.advanceTimersByTimeAsync(2500)
|
|
582
|
+
|
|
583
|
+
const pane1 = registry.get('ses-1')
|
|
584
|
+
expect(pane1).toBeDefined()
|
|
585
|
+
expect(pane1!.missingSince).toBeDefined()
|
|
586
|
+
|
|
587
|
+
await vi.advanceTimersByTimeAsync(2500)
|
|
588
|
+
|
|
589
|
+
const pane2 = registry.get('ses-1')
|
|
590
|
+
expect(pane2).toBeDefined()
|
|
591
|
+
|
|
592
|
+
await vi.advanceTimersByTimeAsync(4000)
|
|
593
|
+
|
|
594
|
+
expect(registry.get('ses-1')).toBeUndefined()
|
|
595
|
+
})
|
|
596
|
+
|
|
597
|
+
it('does not auto-close when autoClosePanes is false', async () => {
|
|
598
|
+
const { loadConfig } = await import('../src/config')
|
|
599
|
+
;(loadConfig as Mock).mockReturnValue({
|
|
600
|
+
enableLogging: true,
|
|
601
|
+
spawnDelayMs: 250,
|
|
602
|
+
maxConcurrentSpawns: 1,
|
|
603
|
+
paneLayout: 'tiled',
|
|
604
|
+
zellijBinary: 'zellij',
|
|
605
|
+
listIntervalMs: 5000,
|
|
606
|
+
autoClosePanes: false,
|
|
607
|
+
panePollIntervalMs: 2000,
|
|
608
|
+
paneMissingGraceMs: 6000,
|
|
609
|
+
})
|
|
610
|
+
|
|
611
|
+
const mockClient = createMockClient()
|
|
612
|
+
;(mockClient.session.status as Mock).mockResolvedValue({
|
|
613
|
+
sessions: [{ id: 'ses-1', status: 'completed' }],
|
|
614
|
+
})
|
|
615
|
+
|
|
616
|
+
await onActivate({
|
|
617
|
+
context: { client: mockClient, directory: '/test' },
|
|
618
|
+
})
|
|
619
|
+
|
|
620
|
+
const registry = getRegistry()!
|
|
621
|
+
registry.add({
|
|
622
|
+
sessionId: 'ses-1',
|
|
623
|
+
paneId: 'pane-1',
|
|
624
|
+
createdAt: Date.now(),
|
|
625
|
+
lastUpdatedAt: Date.now(),
|
|
626
|
+
})
|
|
627
|
+
|
|
628
|
+
vi.advanceTimersByTime(2000)
|
|
629
|
+
await vi.runOnlyPendingTimersAsync()
|
|
630
|
+
|
|
631
|
+
expect(registry.get('ses-1')).toBeDefined()
|
|
632
|
+
})
|
|
633
|
+
})
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { parseJson } from '../src/utils/zellij'
|
|
3
|
+
|
|
4
|
+
describe('parseJson', () => {
|
|
5
|
+
it('parses valid json', () => {
|
|
6
|
+
const input = '{"panes": []}'
|
|
7
|
+
expect(parseJson(input)).toEqual({ panes: [] })
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
it('throws on invalid json', () => {
|
|
11
|
+
expect(() => parseJson('not json')).toThrow(/Failed to parse/)
|
|
12
|
+
})
|
|
13
|
+
})
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "CommonJS",
|
|
5
|
+
"strict": true,
|
|
6
|
+
"esModuleInterop": true,
|
|
7
|
+
"outDir": "dist",
|
|
8
|
+
"rootDir": "src",
|
|
9
|
+
"declaration": true,
|
|
10
|
+
"sourceMap": true,
|
|
11
|
+
"skipLibCheck": true
|
|
12
|
+
},
|
|
13
|
+
"include": ["src/**/*"],
|
|
14
|
+
"exclude": ["node_modules", "dist", "scripts"]
|
|
15
|
+
}
|